From b8532f587d8e2d8ee04b24173d7cde4305a4eaa4 Mon Sep 17 00:00:00 2001 From: Timmy Date: Sat, 22 Aug 2026 20:32:57 +0000 Subject: [PATCH 1/3] feat: version ledger migrations and hardened JSON portability Implements #35. - importLedger migrates prior schema versions (v0 bare-array legacy exports and the v1 envelope) and fails safely on future versions, malformed JSON, wrong-product envelopes, and oversized files with a new 2 MiB MAX_IMPORT_BYTES guard applied before parsing. - exportLedger normalizes entries through sanitizeEntry so confirmed values and bounded provenance round-trip while smuggled secrets and unknown fields never enter the portable file. - Entries may carry a whitelisted provenance origin ('user' or 'ai-suggestion'); mergeVisualSuggestion records 'ai-suggestion' only when a suggestion is actually applied, keeping nonvisual fields user-owned. - App import now merges into the existing ledger instead of replacing it, so a failed or partial import can never silently drop user-owned records. - Service-worker shell cache bumped to v6 (per base-path namespace) so installed PWAs receive the migration code; old v5 caches are purged on activation. - New tests/ledger-portability.acceptance.mjs browser gate covers export round trip, merge import, safe-failure surfacing, root vs /timmy-staging storage isolation, and Delete Everything for both namespaces; wired into package.json test:portability and CI quality.yml. Deterministic medical safety unchanged: urgent-flag detection, red-flag copy, and chat escalation paths are untouched; all fixtures synthetic. --- .gitea/workflows/quality.yml | 1 + app.js | 2 +- package.json | 1 + service-worker.js | 2 +- src/analysis.js | 2 +- src/domain.js | 40 ++++++- tests/analysis.test.js | 12 +- tests/domain.test.js | 118 ++++++++++++++++++++ tests/ledger-portability.acceptance.mjs | 140 ++++++++++++++++++++++++ tests/service-worker-runtime.test.js | 4 +- 10 files changed, 310 insertions(+), 12 deletions(-) create mode 100644 tests/ledger-portability.acceptance.mjs diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml index c77cc35..0dc62f0 100644 --- a/.gitea/workflows/quality.yml +++ b/.gitea/workflows/quality.yml @@ -51,6 +51,7 @@ jobs: npm run test:ui npm run test:photo npm run test:sleek + npm run test:portability - name: Dependency audit run: npm audit --audit-level=high - name: Syntax checks diff --git a/app.js b/app.js index 1d9c082..c8eee79 100644 --- a/app.js +++ b/app.js @@ -119,7 +119,7 @@ function privacy(){ document.querySelector('#export').onclick=exportData;document.querySelector('#import').onchange=importData;document.querySelector('#delete-all').onclick=deleteData; } function exportData(){const blob=new Blob([exportLedger(entries)],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='timmy-ledger.json';a.click();URL.revokeObjectURL(a.href);toast('Export created');} -async function importData(e){try{const text=await e.target.files[0].text();entries=importLedger(text);saveEntries();render();toast('Ledger imported')}catch(err){toast(err.message)}} +async function importData(e){try{const text=await e.target.files[0].text();entries=[...entries,...importLedger(text)];saveEntries();render();toast('Ledger imported')}catch(err){toast(err.message)}} function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];localStorage.removeItem(STORE);localStorage.removeItem(LEGACY_STORE);render();toast('Local ledger deleted')}} function openPhotoFirst(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;visionStatus=null;showPhotoFirst('pick');loadVisionStatus()} diff --git a/package.json b/package.json index 31839e0..e5a2754 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "test:ui": "node tests/ui.acceptance.mjs", "test:photo": "node tests/photo-first.acceptance.mjs", "test:sleek": "node tests/sleek-chat.acceptance.mjs", + "test:portability": "node tests/ledger-portability.acceptance.mjs", "test:staging-smoke": "node tests/staging.acceptance.mjs", "check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py", "check:diff": "bash scripts/check_diff.sh", diff --git a/service-worker.js b/service-worker.js index bbe69e7..9a60f51 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,7 +1,7 @@ const ROOT = new URL(self.registration.scope).pathname; const appPath = path => `${ROOT}${String(path).replace(/^\/+/, '')}`; const CACHE_NAMESPACE = `timmy-shell:${ROOT}:`; -const CACHE = `${CACHE_NAMESPACE}v5`; +const CACHE = `${CACHE_NAMESPACE}v6`; const ASSETS = [ '', 'index.html', diff --git a/src/analysis.js b/src/analysis.js index 8053cfc..e36d019 100644 --- a/src/analysis.js +++ b/src/analysis.js @@ -42,7 +42,7 @@ export function parseVisionResponse(raw) { export function mergeVisualSuggestion(form, suggestion) { if (suggestion?.status !== 'suggestion') return { ...form }; - return { ...form, bristolType: suggestion.bristolType, color: suggestion.color }; + return { ...form, bristolType: suggestion.bristolType, color: suggestion.color, provenance: { origin: 'ai-suggestion' } }; } export function validatePhotoPayload(payload = {}) { diff --git a/src/domain.js b/src/domain.js index a7b7832..2964163 100644 --- a/src/domain.js +++ b/src/domain.js @@ -1,4 +1,5 @@ const URGENT_KEYS = ['blood', 'blackOrDarkRed', 'severePain', 'vomiting', 'fever', 'cannotPassGas']; +const KNOWN_PROVENANCE_ORIGINS = Object.freeze({ user: true, 'ai-suggestion': true }); const URGENT_MESSAGE = 'These reported symptoms can need prompt medical care. Contact a clinician or urgent service now; call emergency services for heavy or nonstop bleeding, fainting, or severe worsening symptoms.'; const URGENT_TEXT_PATTERNS = Object.freeze([ ['blood', /\b(?:rectal bleeding|bleeding from (?:the )?(?:rectum|bottom)|blood(?:y)? (?:in|on|with) (?:my |the )?(?:stool|poop|bowel movement)|(?:stool|poop) (?:has|contains|with) blood)\b/i], @@ -57,7 +58,11 @@ export function sanitizeEntry(input = {}) { const symptoms = {}; for (const key of URGENT_KEYS) symptoms[key] = input.symptoms?.[key] === true; const bristolType = Math.min(7, Math.max(1, Number(input.bristolType) || 4)); - return { + const provenanceOrigin = input.provenance && typeof input.provenance === 'object' + && input.provenance.origin in KNOWN_PROVENANCE_ORIGINS + ? input.provenance.origin + : null; + const entry = { id: String(input.id || globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`), occurredAt: new Date(input.occurredAt || Date.now()).toISOString(), bristolType, @@ -68,6 +73,8 @@ export function sanitizeEntry(input = {}) { photoDataUrl: typeof input.photoDataUrl === 'string' && input.photoDataUrl.startsWith('data:image/') ? input.photoDataUrl : '', symptoms, }; + if (provenanceOrigin) entry.provenance = { origin: provenanceOrigin }; + return entry; } export function photoQualityMessage({ width = 0, height = 0, brightness = 0.5 } = {}) { @@ -79,16 +86,37 @@ export function photoQualityMessage({ width = 0, height = 0, brightness = 0.5 } export function exportLedger(entries, exportedAt = new Date().toISOString()) { return JSON.stringify({ - product: 'Timmy the Talking Turd', - schemaVersion: 1, + product: PRODUCT_NAME, + schemaVersion: SCHEMA_VERSION, exportedAt, - entries: Array.isArray(entries) ? entries : [], + entries: Array.isArray(entries) ? entries.map(sanitizeEntry) : [], }, null, 2); } +export const MAX_IMPORT_BYTES = 2 * 1024 * 1024; +const PRODUCT_NAME = 'Timmy the Talking Turd'; +const SCHEMA_VERSION = 1; +const KNOWN_SCHEMA_VERSIONS = new Set([0, SCHEMA_VERSION]); + export function importLedger(text) { - const parsed = JSON.parse(text); - if (parsed?.schemaVersion !== 1 || !Array.isArray(parsed.entries)) throw new Error('This is not a supported Timmy export.'); + if (typeof text !== 'string' || text.length === 0) throw new Error('This is not a supported Timmy export.'); + if (text.length > MAX_IMPORT_BYTES) throw new RangeError('That file is too large to be a Timmy export.'); + let parsed; + try { + parsed = JSON.parse(text); + } catch { + throw new Error('This is not a supported Timmy export.'); + } + if (Array.isArray(parsed)) return parsed.map(sanitizeEntry); + const hasLedgerEnvelope = typeof parsed?.product === 'string' + && Number.isInteger(parsed?.schemaVersion) + && Array.isArray(parsed?.entries); + if (!hasLedgerEnvelope || parsed.product !== PRODUCT_NAME) { + throw new Error('This is not a supported Timmy export.'); + } + if (!KNOWN_SCHEMA_VERSIONS.has(parsed.schemaVersion)) { + throw new RangeError(`This export uses schema version ${parsed.schemaVersion} from a newer Timmy app. Update Timmy first, then import again.`); + } return parsed.entries.map(sanitizeEntry); } diff --git a/tests/analysis.test.js b/tests/analysis.test.js index 56e130e..db99f70 100644 --- a/tests/analysis.test.js +++ b/tests/analysis.test.js @@ -57,7 +57,17 @@ test('rejects malformed model output instead of guessing defaults', () => { test('merges only visual fields and preserves user-reported context', () => { const form = { bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true } }; const merged = mergeVisualSuggestion(form, { status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.8 }); - assert.deepEqual(merged, { bristolType: 4, color: 'brown', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true } }); + assert.deepEqual(merged, { bristolType: 4, color: 'brown', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true }, provenance: { origin: 'ai-suggestion' } }); +}); + +test('records ai-suggestion provenance only when a suggestion is actually applied', () => { + const form = { bristolType: 2, color: 'green', urgency: 1, discomfort: 0, note: '', symptoms: {} }; + const abstained = mergeVisualSuggestion(form, { status: 'needs_user_input', reason: 'too uncertain' }); + assert.deepEqual(abstained, form); + assert.equal(abstained.provenance, undefined); + const suggested = mergeVisualSuggestion(form, { status: 'suggestion', bristolType: 5, color: 'yellow', confidence: 0.9 }); + assert.deepEqual(suggested.provenance, { origin: 'ai-suggestion' }); + assert.equal(suggested.urgency, form.urgency, 'nonvisual fields stay user-owned'); }); test('accepts bounded JPEG/PNG/WebP data URLs and rejects oversized or unsupported input', () => { diff --git a/tests/domain.test.js b/tests/domain.test.js index 08c43f3..e2df743 100644 --- a/tests/domain.test.js +++ b/tests/domain.test.js @@ -8,6 +8,8 @@ import { detectUrgentText, exportLedger, hasUrgentLedgerContext, + importLedger, + MAX_IMPORT_BYTES, photoQualityMessage, sanitizeEntry, } from '../src/domain.js'; @@ -118,6 +120,29 @@ test('sanitizes a user entry to the MVP data contract', () => { assert.equal(entry.unexpected, undefined); }); +test('keeps only a bounded provenance origin and strips smuggled secrets', () => { + const entry = sanitizeEntry({ + id: 'prov-1', + bristolType: 4, + provenance: { origin: 'ai-suggestion', suggestedBristolType: 4, apiToken: 'sk-secret-value', sessionId: 'hermes-session-x' }, + }); + assert.deepEqual(entry.provenance, { origin: 'ai-suggestion' }); + assert.doesNotMatch(JSON.stringify(entry), /secret|session/i); +}); + +test('omits provenance entirely when none was recorded', () => { + const entry = sanitizeEntry({ id: 'plain-1', bristolType: 3 }); + assert.equal(entry.provenance, undefined); +}); + +test('rejects provenance origins outside the recorded vocabulary', () => { + for (const bogus of ['clinician', 'self-diagnosis', '']) { + const entry = sanitizeEntry({ id: 'x', bristolType: 4, provenance: { origin: bogus } }); + assert.equal(entry.provenance, undefined, bogus); + } +}); + + test('photo quality guidance is deterministic and does not claim visual diagnosis', () => { assert.match(photoQualityMessage({ width: 300, height: 300, brightness: 0.5 }), /closer/i); assert.match(photoQualityMessage({ width: 1200, height: 900, brightness: 0.02 }), /light/i); @@ -132,3 +157,96 @@ test('export ledger is portable JSON with version and entries', () => { assert.equal(parsed.exportedAt, '2026-08-18T00:00:00.000Z'); assert.equal(parsed.entries.length, 1); }); + +test('import migrates the legacy bare-array ledger to the current versioned envelope', () => { + const legacy = JSON.stringify([ + { id: 'legacy-1', occurredAt: '2026-08-17T12:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'older export' }, + { id: 'legacy-2', bristolType: 7 }, + ]); + const entries = importLedger(legacy); + assert.equal(entries.length, 2); + assert.equal(entries[0].id, 'legacy-1'); + assert.equal(entries[0].bristolType, 2); + assert.equal(entries[0].note, 'older export'); +}); + +test('import accepts every prior schema version and migrates entries forward', () => { + for (const version of [0, 1]) { + const payload = version === 0 + ? [{ id: `v${version}`, bristolType: 3 }] + : { product: 'Timmy the Talking Turd', schemaVersion: version, exportedAt: '2026-08-18T00:00:00.000Z', entries: [{ id: `v${version}`, bristolType: 3 }] }; + const entries = importLedger(JSON.stringify(payload)); + assert.equal(entries.length, 1, `schemaVersion ${version}`); + assert.equal(entries[0].bristolType, 3, `schemaVersion ${version}`); + } +}); + +test('import fails safely on a newer schema version instead of guessing', () => { + for (const schemaVersion of [2, 99]) { + assert.throws( + () => importLedger(JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion, entries: [{ id: 'x' }] })), + error => error instanceof RangeError && /newer Timmy app/i.test(error.message), + `schemaVersion ${schemaVersion}`, + ); + } +}); + +test('import fails safely on malformed or wrong-shaped payloads', () => { + for (const payload of [ + 'not json', + '{"schemaVersion":1,"entries":{}}', + '{"entries":[]}', + '{"product":"Other App","schemaVersion":1,"entries":[]}', + null, + 42, + ]) { + assert.throws(() => importLedger(payload), /not a supported Timmy export/, JSON.stringify(String(payload)).slice(0, 40)); + } +}); + +test('import rejects oversized ledgers before parsing user data', () => { + const huge = JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 1, exportedAt: '2026-08-18T00:00:00.000Z', entries: [{ id: 'x', note: 'n'.repeat(MAX_IMPORT_BYTES + 1024) }] }); + assert.ok(huge.length > MAX_IMPORT_BYTES); + assert.throws(() => importLedger(huge), RangeError); +}); + +test('round trip preserves confirmed values and provenance without leaking secrets', () => { + const saved = [ + sanitizeEntry({ + id: 'r1', + occurredAt: '2026-08-19T08:30:00.000Z', + bristolType: 2, + color: 'green', + urgency: 3, + discomfort: 2, + note: 'rough morning', + provenance: { origin: 'ai-suggestion', apiToken: 'sk-leaked-token' }, + }), + { + id: 'raw-2', bristolType: 9, color: 'chartreuse', urgency: 11, discomfort: -4, + note: 'odd shape', symptoms: { blood: true }, sessionCookie: 'SID=hijack', + }, + ]; + const exported = exportLedger(saved, '2026-08-20T00:00:00.000Z'); + assert.doesNotMatch(exported, /sk-leaked-token|SID=hijack|chartreuse/); + const roundTripped = importLedger(exported); + assert.equal(roundTripped.length, 2); + assert.deepEqual( + { id: roundTripped[0].id, occurredAt: roundTripped[0].occurredAt, bristolType: roundTripped[0].bristolType, color: roundTripped[0].color, urgency: roundTripped[0].urgency, discomfort: roundTripped[0].discomfort, note: roundTripped[0].note }, + { id: 'r1', occurredAt: '2026-08-19T08:30:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'rough morning' }, + ); + assert.equal(roundTripped[0].symptoms.blood, false); + assert.deepEqual(roundTripped[0].provenance, { origin: 'ai-suggestion' }); + assert.equal(roundTripped[1].bristolType, 7); + assert.equal(roundTripped[1].color, 'brown'); + assert.equal(roundTripped[1].urgency, 4); + assert.equal(roundTripped[1].discomfort, 0); + assert.deepEqual(roundTripped[1].provenance, undefined); +}); + +test('re-exporting an imported ledger converges to the same portable document', () => { + const entries = [{ id: 'c1', occurredAt: '2026-08-19T08:30:00.000Z', bristolType: 6, color: 'yellow', urgency: 2, discomfort: 1, note: 'loose', provenance: { origin: 'user' } }]; + const first = JSON.parse(exportLedger(entries, '2026-08-20T00:00:00.000Z')); + const second = JSON.parse(exportLedger(importLedger(exportLedger(entries, '2026-08-20T00:00:00.000Z')), '2026-08-20T00:00:00.000Z')); + assert.deepEqual(second, first); +}); diff --git a/tests/ledger-portability.acceptance.mjs b/tests/ledger-portability.acceptance.mjs new file mode 100644 index 0000000..acf25ac --- /dev/null +++ b/tests/ledger-portability.acceptance.mjs @@ -0,0 +1,140 @@ +import { chromium } from 'playwright'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; + +const ROOT_STORE = 'timmy:/:ledger-v1'; +const LEGACY_STORE = 'timmy-ledger-v1'; + +await mkdtemp(join(tmpdir(), 'timmy-portability-')).then(async (workDir) => { + const browser = await chromium.launch({ headless: true }); + try { + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, serviceWorkers: 'block' }); + const page = await context.newPage(); + const errors = []; + page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); + page.on('pageerror', error => errors.push(error.message)); + page.on('dialog', dialog => dialog.accept()); + await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' }); + await page.evaluate(() => localStorage.clear()); + await page.reload({ waitUntil: 'networkidle' }); + + // Seed one confirmed, user-owned entry with provenance. + await page.evaluate(() => { + localStorage.setItem('timmy:/:ledger-v1', JSON.stringify([ + { + id: 'seed-1', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2, color: 'green', + urgency: 3, discomfort: 2, note: 'seeded confirmed entry', photoDataUrl: '', + symptoms: { blood: false }, provenance: { origin: 'ai-suggestion' }, + }, + ])); + }); + await page.reload({ waitUntil: 'networkidle' }); + await page.locator('[data-view="calendar"]').last().click(); + assert.equal(await page.locator('.entry').count(), 1, 'seeded entry renders'); + + // Export writes the versioned envelope with provenance and no secrets. + await page.locator('[data-view="calendar"]').last().click(); + await page.locator('[data-view="privacy"]').click(); + const downloadPromise = page.waitForEvent('download'); + await page.locator('#export').click(); + const download = await downloadPromise; + const exportedText = await download.path().then(readFile).then(buffer => buffer.toString('utf8')); + const exported = JSON.parse(exportedText); + assert.equal(exported.schemaVersion, 1); + assert.equal(exported.entries[0].provenance.origin, 'ai-suggestion'); + assert.doesNotMatch(exportedText, /apiToken|sessionId|sk-/i); + + // Importing a prior-version export merges instead of replacing user data. + const legacyPath = join(workDir, 'legacy-ledger.json'); + await writeFile(legacyPath, JSON.stringify([ + { id: 'legacy-9', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 1, discomfort: 0, note: 'legacy import' }, + ])); + await page.setInputFiles('#import', legacyPath); + await page.getByText('Ledger imported').waitFor({ timeout: 5000 }); + const mergedIds = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE); + assert.deepEqual(mergedIds.sort(), ['legacy-9', 'seed-1'], 'import must merge, not replace'); + + // A future schema version fails safely and leaves the ledger untouched. + const futurePath = join(workDir, 'future-ledger.json'); + await writeFile(futurePath, JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 2, entries: [{ id: 'from-the-future' }] })); + await page.setInputFiles('#import', futurePath); + await page.getByText(/newer Timmy app/).waitFor({ timeout: 5000 }); + const afterFuture = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE); + assert.deepEqual(afterFuture.sort(), ['legacy-9', 'seed-1'], 'failed import must not mutate the ledger'); + + // Malformed JSON fails safely and leaves the ledger untouched. + const malformedPath = join(workDir, 'malformed-ledger.json'); + await writeFile(malformedPath, '{"schemaVersion":1,"entries":'); + await page.setInputFiles('#import', malformedPath); + await page.getByText(/not a supported Timmy export/).waitFor({ timeout: 5000 }); + const afterMalformed = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE); + assert.deepEqual(afterMalformed.sort(), ['legacy-9', 'seed-1'], 'malformed import must not mutate the ledger'); + await page.screenshot({ path: 'artifacts/portability-import-mobile.png', fullPage: false }); + + // Delete Everything removes every namespaced copy of the local ledger. + await page.locator('#delete-all').click(); + await page.getByText('Local ledger deleted').waitFor({ timeout: 5000 }); + const storesAfterDelete = await page.evaluate(([rootStore, legacyStore]) => ({ + root: localStorage.getItem(rootStore), + legacy: localStorage.getItem(legacyStore), + }), [ROOT_STORE, LEGACY_STORE]); + assert.equal(storesAfterDelete.root, null, 'namespaced store cleared'); + assert.equal(storesAfterDelete.legacy, null, 'legacy store cleared'); + await page.locator('[data-view="calendar"]').last().click(); + assert.equal(await page.locator('.entry').count(), 0, 'journal is empty after delete-all'); + + // Base-path deployments keep their ledger in an isolated namespace. + const staging = spawn(process.execPath, ['server.mjs'], { + env: { ...process.env, PORT: '4179', HOST: '127.0.0.1', TIMMY_BASE_PATH: '/timmy-staging' }, + stdio: 'ignore', + }); + try { + let up = false; + for (let attempt = 0; attempt < 40 && !up; attempt += 1) { + up = await fetch('http://127.0.0.1:4179/timmy-staging/api/healthz').then(response => response.ok).catch(() => false); + if (!up) await sleep(250); + } + assert.ok(up, 'staging server must start'); + const stagingContext = await browser.newContext({ viewport: { width: 390, height: 844 }, serviceWorkers: 'block' }); + const stagingPage = await stagingContext.newPage(); + stagingPage.on('dialog', dialog => dialog.accept()); + await stagingPage.goto('http://127.0.0.1:4179/timmy-staging', { waitUntil: 'networkidle' }); + await stagingPage.evaluate(() => { + localStorage.clear(); + localStorage.setItem('timmy:/timmy-staging:ledger-v1', JSON.stringify([ + { id: 'staging-1', occurredAt: '2026-08-21T10:00:00.000Z', bristolType: 3, color: 'brown', urgency: 0, discomfort: 0, note: 'staging only', photoDataUrl: '', symptoms: {} }, + ])); + }); + await stagingPage.reload({ waitUntil: 'networkidle' }); + await stagingPage.locator('[data-view="calendar"]').last().click(); + assert.equal(await stagingPage.locator('.entry').count(), 1, 'staging entry renders under its base path'); + const isolation = await stagingPage.evaluate(() => ({ + staging: localStorage.getItem('timmy:/timmy-staging:ledger-v1'), + root: localStorage.getItem('timmy:/:ledger-v1'), + legacy: localStorage.getItem('timmy-ledger-v1'), + })); + assert.ok(isolation.staging, 'staging store keeps its data'); + assert.equal(isolation.root, null, 'root-namespaced store untouched by staging data'); + assert.equal(isolation.legacy, null, 'legacy store untouched by staging data'); + await stagingPage.locator('[data-view="privacy"]').click(); + await stagingPage.locator('#delete-all').click(); + await stagingPage.getByText('Local ledger deleted').waitFor({ timeout: 5000 }); + const stagingAfterDelete = await stagingPage.evaluate(() => localStorage.getItem('timmy:/timmy-staging:ledger-v1')); + assert.equal(stagingAfterDelete, null, 'delete-all clears the base-path store'); + await stagingContext.close(); + } finally { + staging.kill(); + } + + assert.deepEqual(errors, [], 'no console or page errors'); + await context.close(); + console.log('PASS ledger portability: export round trip, merge import, safe failures, isolation, delete-all'); + } finally { + await browser.close(); + await rm(workDir, { recursive: true, force: true }); + } +}); diff --git a/tests/service-worker-runtime.test.js b/tests/service-worker-runtime.test.js index e93fa4e..c497392 100644 --- a/tests/service-worker-runtime.test.js +++ b/tests/service-worker-runtime.test.js @@ -43,7 +43,7 @@ async function dispatchFetch(handler, request) { return response; } -test('root activation deletes only its obsolete Timmy caches including the legacy v4 cache', async () => { +test('root activation deletes only its obsolete Timmy caches including the legacy v4 and previous v5 shells', async () => { const { listeners, deleted } = loadWorker({ cacheKeys: [ 'timmy-shell-v4', @@ -56,7 +56,7 @@ test('root activation deletes only its obsolete Timmy caches including the legac await dispatchExtendable(listeners.get('activate')); - assert.deepEqual(deleted.sort(), ['timmy-shell-v4', 'timmy-shell:/:v4']); + assert.deepEqual(deleted.sort(), ['timmy-shell-v4', 'timmy-shell:/:v4', 'timmy-shell:/:v5']); }); test('offline shell lookup uses only the current named cache', async () => { -- 2.43.0 From dd86d6675d5ff6f8e3e2675f36347469f7c7b927 Mon Sep 17 00:00:00 2001 From: Timmy Date: Sat, 22 Aug 2026 21:53:08 +0000 Subject: [PATCH 2/3] fix: close hostile-review blockers in ledger portability - provenance origin set is own-safe exact membership (Set.has); inherited toString/constructor/__proto__ names can never become origins - import/export symmetry restored with an explicit bounded policy: MAX_IMPORT_BYTES raised 2 MiB -> 16 MiB UTF-8 bytes, above any export this app can produce (photos capped at 4 MiB binary), so valid exports always re-import without silent data loss while hostile files stay bounded - byte limit is byte-exact now: utf8ByteLength() measures real UTF-8 bytes (multibyte boundaries tested), and the browser rejects oversized files by File.size BEFORE File.text() reads user data - collision-safe deterministic mergeLedgers(): existing user-owned rows win, incoming rows only ever added for new ids, intra-file duplicates collapse deterministically, every collision reported explicitly in the import toast (no duplicate/overwrite/shadow of user records) - base-path Delete Everything is namespace-scoped: root still cleans/ migrates the legacy store to prevent resurrection, /timmy-staging no longer erases another namespace's global legacy ledger (browser regression covers deletion with root legacy data present) - strict current-schema values: Bristol 1-7 / urgency 0-4 / discomfort 0-4 must be true integers (out-of-range falls back instead of silent clamping), photos restricted to JPEG/PNG/WebP base64 raster data URLs (SVG/GIF/non-base64 dropped), invalid dates never throw or persist Invalid Date values Verification: npm test 91/91, test:ui/test:photo/test:sleek/test:portability PASS, staging-deploy 20/20 OK, check:syntax clean, npm audit 0 high, check_diff clean, adversarial probe battery (exact-byte boundary at cap, prototype pollution via JSON, lone surrogates, data-URL strictness) green. --- .gitignore | 1 + app.js | 6 +- artifacts/portability-import-mobile.png | Bin 0 -> 108815 bytes service-worker.js | 2 +- src/domain.js | 81 +++++++++-- tests/domain.test.js | 186 +++++++++++++++++++++++- tests/ledger-portability.acceptance.mjs | 75 +++++++++- 7 files changed, 335 insertions(+), 16 deletions(-) create mode 100644 artifacts/portability-import-mobile.png diff --git a/.gitignore b/.gitignore index a8758b0..8625199 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ *.pyc .env .env.* +.worktrees/ diff --git a/app.js b/app.js index c8eee79..663f81e 100644 --- a/app.js +++ b/app.js @@ -1,4 +1,4 @@ -import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js'; +import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, MAX_IMPORT_BYTES, mergeLedgers, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js'; import { mergeVisualSuggestion } from './src/analysis.js'; const runtimeConfig = { @@ -119,8 +119,8 @@ function privacy(){ document.querySelector('#export').onclick=exportData;document.querySelector('#import').onchange=importData;document.querySelector('#delete-all').onclick=deleteData; } function exportData(){const blob=new Blob([exportLedger(entries)],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='timmy-ledger.json';a.click();URL.revokeObjectURL(a.href);toast('Export created');} -async function importData(e){try{const text=await e.target.files[0].text();entries=[...entries,...importLedger(text)];saveEntries();render();toast('Ledger imported')}catch(err){toast(err.message)}} -function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];localStorage.removeItem(STORE);localStorage.removeItem(LEGACY_STORE);render();toast('Local ledger deleted')}} +async function importData(e){try{const file=e.target.files[0];if(!file)return;if(file.size>MAX_IMPORT_BYTES)throw new RangeError('That file is too large to be a Timmy export.');const text=await file.text();const result=mergeLedgers(entries,importLedger(text));entries=result.merged;saveEntries();render();toast(result.added.length?`Ledger imported: ${result.added.length} new log${result.added.length===1?'':'s'}`:`Already in your ledger: ${result.skippedIds.length} log${result.skippedIds.length===1?'':'s'} skipped (kept your saved version)`)}catch(err){toast(err.message)}} +function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];localStorage.removeItem(STORE);if(BASE_PATH==='/')localStorage.removeItem(LEGACY_STORE);render();toast('Local ledger deleted')}} function openPhotoFirst(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;visionStatus=null;showPhotoFirst('pick');loadVisionStatus()} async function loadVisionStatus(){ diff --git a/artifacts/portability-import-mobile.png b/artifacts/portability-import-mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..8313356a4c17cd30f8101a073874fa7efce5737c GIT binary patch literal 108815 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