diff --git a/frontend/device-storage.js b/frontend/device-storage.js index afd938e..ada651b 100644 --- a/frontend/device-storage.js +++ b/frontend/device-storage.js @@ -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}`; diff --git a/frontend/index.html b/frontend/index.html index c74b08a..899f6ac 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1649,6 +1649,7 @@ + diff --git a/frontend/private-data-inventory.js b/frontend/private-data-inventory.js new file mode 100644 index 0000000..8d486bc --- /dev/null +++ b/frontend/private-data-inventory.js @@ -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 }; + } + }; +}); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 342e8f4..64d538f 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -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', diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 4fd51c6..49ddaeb 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -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",), diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index e7f1aed..b2e0fc5 100644 --- a/tests/e2e/test_mobile_home_bootstrap_release.py +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -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 diff --git a/tests/test_device_storage.py b/tests/test_device_storage.py index c27f73f..074f4f9 100644 --- a/tests/test_device_storage.py +++ b/tests/test_device_storage.py @@ -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", } diff --git a/tests/test_private_data_inventory.py b/tests/test_private_data_inventory.py new file mode 100644 index 0000000..e25070e --- /dev/null +++ b/tests/test_private_data_inventory.py @@ -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} diff --git a/tests/test_private_data_registry.py b/tests/test_private_data_registry.py index b927758..af0d8c8 100644 --- a/tests/test_private_data_registry.py +++ b/tests/test_private_data_registry.py @@ -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 diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index ff94d0d..a0dd3ad 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -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",