Guard IndexedDB private work cleanup #1001

Merged
timmy merged 1 commits from timmy/1000-guard-indexeddb-private-work into main 2026-08-17 04:45:02 +00:00
10 changed files with 195 additions and 9 deletions
Showing only changes of commit a2c91e0296 - Show all commits

View File

@ -12,11 +12,14 @@
caches:root.caches,
storageManager:root.navigator?.storage,
privateDatabases:root.stackchainPrivateDatabases,
inspectPrivateDatabases:root.inspectStackchainPrivateDatabases,
clearPrivateDeviceData:root.stackchainPrivateDeviceData,
});
}
})(typeof self !== 'undefined' ? self : this, function createDeviceStorage(options) {
let privateItemCount = 0;
let privateRecordCount = 0;
let inventoryUnavailable = false;
let fullClearArmed = false;
const ownedCaches = async () => (await options.caches?.keys?.() || [])
.filter(name => name.startsWith('stackchain-dashboard-'));
@ -44,11 +47,15 @@
const itemCount = ownedStorageCount(options.localStorage)
+ (options.sessionStorage === options.localStorage ? 0 : ownedStorageCount(options.sessionStorage));
privateItemCount = itemCount;
const inventory = await options.inspectPrivateDatabases?.(options.privateDatabases)
|| { recordCount: 0, unavailable: true };
privateRecordCount = inventory.recordCount;
inventoryUnavailable = inventory.unavailable;
const cacheCount = (await ownedCaches()).length;
options.detail.textContent = `${itemCount} private browser item${itemCount === 1 ? '' : 's'} · `
+ `${options.privateDatabases.length} private work stores · `
+ `${inventoryUnavailable ? 'private work status unknown' : `${privateRecordCount} private work record${privateRecordCount === 1 ? '' : 's'}`} · `
+ `${cacheCount} cached app cop${cacheCount === 1 ? 'y' : 'ies'}`;
return { itemCount, cacheCount };
return { itemCount, privateRecordCount, inventoryUnavailable, cacheCount };
}
async function clearCaches() {
@ -65,7 +72,7 @@
}
async function clearAll() {
if (privateItemCount > 0 && !fullClearArmed) {
if ((privateItemCount > 0 || privateRecordCount > 0 || inventoryUnavailable) && !fullClearArmed) {
fullClearArmed = true;
options.clearAllButton.textContent = 'Confirm: clear private work';
options.summary.textContent = 'Private drafts or queued work may not be synced. Press confirm to clear them from this device.';
@ -77,7 +84,7 @@
privateItemCount = 0;
fullClearArmed = false;
options.clearAllButton.textContent = 'Clear all private data';
options.detail.textContent = '0 private browser items · 0 private work stores · 0 cached app copies';
options.detail.textContent = '0 private browser items · 0 private work records · 0 cached app copies';
options.summary.textContent = 'All Stackchain private data was cleared from this device.';
} catch (error) {
options.summary.textContent = `Private data was not fully cleared: ${error.message}`;

View File

@ -1649,6 +1649,7 @@
<script src="static/mobile-insights.js"></script>
<script src="static/mobile-app-shortcuts.js"></script>
<script src="static/install-app.js"></script>
<script src="static/private-data-inventory.js"></script>
<script src="static/private-device-data.js"></script>
<script src="static/device-storage.js"></script>
<script src="static/mobile-device-setup.js"></script>

View File

@ -0,0 +1,43 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.inspectStackchainPrivateDatabases = factory(root.indexedDB);
})(typeof globalThis !== 'undefined' ? globalThis : this, function createPrivateDataInspector(indexedDB) {
function requestResult(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('Private storage inventory failed.'));
request.onblocked = () => reject(new Error('Private storage inventory was blocked.'));
});
}
async function countDatabase(name) {
const database = await requestResult(indexedDB.open(name));
try {
const storeNames = Array.from(database.objectStoreNames);
if (!storeNames.length) return 0;
const transaction = database.transaction(storeNames, 'readonly');
const counts = await Promise.all(storeNames.map(storeName =>
requestResult(transaction.objectStore(storeName).count())
));
return counts.reduce((total, count) => total + Number(count || 0), 0);
} finally {
database.close();
}
}
return async function inspectPrivateDatabases(registeredNames) {
if (!indexedDB?.databases) return { recordCount: 0, unavailable: true };
try {
const existing = new Set((await indexedDB.databases()).map(database => database.name));
const counts = await Promise.all(
registeredNames.filter(name => existing.has(name)).map(countDatabase)
);
return {
recordCount: counts.reduce((total, count) => total + count, 0),
unavailable: false,
};
} catch (_error) {
return { recordCount: 0, unavailable: true };
}
};
});

View File

@ -118,6 +118,7 @@ const SHELL = [
BASE + 'static/mobile-insights.js',
BASE + 'static/mobile-app-shortcuts.js',
BASE + 'static/install-app.js',
BASE + 'static/private-data-inventory.js',
BASE + 'static/private-device-data.js',
BASE + 'static/device-storage.js',
BASE + 'static/mobile-device-setup.js',

View File

@ -28,7 +28,7 @@ FEATURE_SOURCES = {
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
"push-notifications": ("static/push-notifications.js",),
"device-setup": (
"static/install-app.js", "static/private-device-data.js",
"static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js",
"static/device-storage.js", "static/mobile-device-setup.js",
),
"security-center": ("static/security-center.js",),

View File

@ -84,7 +84,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
page.locator("#open-device-setup").click()
expect(page.locator("#device-setup-sheet")).to_be_visible()
expect(page.locator("#device-storage-heading")).to_have_text("Private device storage")
expect(page.locator("#device-storage-detail")).to_contain_text("private work stores")
expect(page.locator("#device-storage-detail")).to_contain_text("private work records")
for selector in ("#clear-device-caches", "#clear-private-device-data"):
bounds = page.locator(selector).bounding_box()
assert bounds and bounds["height"] >= 44

View File

@ -16,6 +16,7 @@ class Target {
}
const summary=new Target(), detail=new Target(), clearCachesButton=new Target(), clearAllButton=new Target();
let purgeCalls=0, purgeError='';
let inventory={recordCount:0, unavailable:false};
const localStorage={
values:new Map([['stackchain.draft.1','private'],['other.preference','keep']]),
get length(){return this.values.size;},
@ -29,6 +30,7 @@ const caches={
const controller=createDeviceStorage({
summary, detail, clearCachesButton, clearAllButton, localStorage, caches,
privateDatabases:['stackchain-background-outbox-v1','stackchain-offline-work-v2'],
inspectPrivateDatabases:async()=>inventory,
storageManager:{estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024})},
clearPrivateDeviceData:async()=>{purgeCalls++;if(purgeError)throw new Error(purgeError);},
});
@ -47,7 +49,7 @@ process.stdout.write(JSON.stringify({summary:summary.textContent,detail:detail.t
assert result == {
"summary": "2 MB of 10 MB browser storage used.",
"detail": "1 private browser item · 2 private work stores · 1 cached app copy",
"detail": "1 private browser item · 0 private work records · 1 cached app copy",
}
@ -67,7 +69,7 @@ process.stdout.write(JSON.stringify({
"caches": ["other-app"],
"storage": ["stackchain.draft.1", "other.preference"],
"status": "Cached app copies cleared. Private work was kept.",
"detail": "1 private browser item · 2 private work stores · 0 cached app copies",
"detail": "1 private browser item · 0 private work records · 0 cached app copies",
}
@ -77,7 +79,7 @@ await controller.start();
await clearAllButton.dispatch('click');
const warning={calls:purgeCalls,label:clearAllButton.textContent,status:summary.textContent};
await clearAllButton.dispatch('click');
process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.textContent}));
process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.textContent,detail:detail.textContent}));
""")
assert result == {
@ -88,6 +90,49 @@ process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.tex
},
"calls": 1,
"status": "All Stackchain private data was cleared from this device.",
"detail": "0 private browser items · 0 private work records · 0 cached app copies",
}
def test_indexeddb_only_work_requires_confirmation_before_purge():
result = run_scenario("""
localStorage.values.clear();
inventory={recordCount:3, unavailable:false};
await controller.start();
await clearAllButton.dispatch('click');
process.stdout.write(JSON.stringify({
calls:purgeCalls,
label:clearAllButton.textContent,
status:summary.textContent,
detail:detail.textContent,
}));
""")
assert result == {
"calls": 0,
"label": "Confirm: clear private work",
"status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.",
"detail": "0 private browser items · 3 private work records · 1 cached app copy",
}
def test_unavailable_indexeddb_inventory_requires_confirmation_before_purge():
result = run_scenario("""
localStorage.values.clear();
inventory={recordCount:0, unavailable:true};
await controller.start();
await clearAllButton.dispatch('click');
process.stdout.write(JSON.stringify({
calls:purgeCalls,
label:clearAllButton.textContent,
detail:detail.textContent,
}));
""")
assert result == {
"calls": 0,
"label": "Confirm: clear private work",
"detail": "0 private browser items · private work status unknown · 1 cached app copy",
}

View File

@ -0,0 +1,79 @@
import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "private-data-inventory.js"
def run_inventory(databases: dict[str, list[int]], registered: list[str]) -> dict:
script = r"""
const createInspector = require(__MODULE__);
const databases = __DATABASES__;
const indexedDB = {
async databases() { return Object.keys(databases).map(name => ({name})); },
open(name) {
const request = {};
queueMicrotask(() => {
const stores = databases[name];
const db = {
objectStoreNames: stores.map((_records, index) => `store-${index}`),
transaction(names) {
return {
objectStore(storeName) {
return {
count() {
const countRequest = {};
const index = Number(storeName.split('-')[1]);
queueMicrotask(() => {
countRequest.result = stores[index];
countRequest.onsuccess?.();
});
return countRequest;
},
};
},
};
},
close() {},
};
request.result = db;
request.onsuccess?.();
});
return request;
},
};
createInspector(indexedDB)(__REGISTERED__)
.then(result => process.stdout.write(JSON.stringify(result)))
.catch(error => { console.error(error); process.exit(1); });
"""
script = (
script.replace("__MODULE__", json.dumps(str(MODULE)))
.replace("__DATABASES__", json.dumps(databases))
.replace("__REGISTERED__", json.dumps(registered))
)
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_inventory_counts_records_without_creating_absent_registered_databases():
result = run_inventory(
{"private-a": [2, 1], "other-app": [99]},
["private-a", "private-empty", "private-missing"],
)
assert result == {"recordCount": 3, "unavailable": False}
def test_inventory_is_conservatively_unavailable_when_enumeration_is_unsupported():
script = f"""
const createInspector = require({json.dumps(str(MODULE))});
createInspector({{}})(['private-a'])
.then(result => process.stdout.write(JSON.stringify(result)))
.catch(error => {{ console.error(error.message); process.exit(1); }});
"""
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout) == {"recordCount": 0, "unavailable": True}

View File

@ -44,3 +44,12 @@ def test_every_private_data_purge_context_consumes_the_shared_registry():
html = (FRONTEND / "index.html").read_text()
assert html.index('static/private-data-registry.js') < html.index('static/session.js')
def test_private_inventory_loads_after_registry_and_before_storage_controller():
html = (FRONTEND / "index.html").read_text()
registry = html.index('static/private-data-registry.js')
inventory = html.index('static/private-data-inventory.js')
controller = html.index('static/device-storage.js')
assert registry < inventory < controller

View File

@ -1055,6 +1055,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/mobile-insights.js",
"/dashboard/static/mobile-app-shortcuts.js",
"/dashboard/static/install-app.js",
"/dashboard/static/private-data-inventory.js",
"/dashboard/static/private-device-data.js",
"/dashboard/static/device-storage.js",
"/dashboard/static/mobile-device-setup.js",