fix: close hostile-review blockers in ledger portability
Some checks failed
Quality gates / quality (pull_request) Failing after 1m28s

- 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.
This commit is contained in:
Timmy 2026-08-22 21:53:08 +00:00
parent b8532f587d
commit dd86d6675d
7 changed files with 335 additions and 16 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ __pycache__/
*.pyc
.env
.env.*
.worktrees/

6
app.js
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@ -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}v6`;
const CACHE = `${CACHE_NAMESPACE}v7`;
const ASSETS = [
'',
'index.html',

View File

@ -1,5 +1,6 @@
const URGENT_KEYS = ['blood', 'blackOrDarkRed', 'severePain', 'vomiting', 'fever', 'cannotPassGas'];
const KNOWN_PROVENANCE_ORIGINS = Object.freeze({ user: true, 'ai-suggestion': true });
const UTF8_ENCODER = new TextEncoder();
const KNOWN_PROVENANCE_ORIGINS = Object.freeze(new Set(['user', 'ai-suggestion']));
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],
@ -54,23 +55,41 @@ export function buildTimmySummary(entries = []) {
return `${pieces.join(' · ')}. Patterns matter more than one entry. You choose what to eat; I only help you notice changes.`;
}
// Current-schema strictness: numeric fields must be true integers inside the
// clinical bounds (no silent clamping of out-of-range values), and photos must
// be approved raster JPEG/PNG/WebP base64 data URLs only — SVG, GIF, and
// non-base64 payloads are script-execution and smuggling risks and are dropped.
const APPROVED_PHOTO_DATA_URL = /^data:image\/(?:jpeg|png|webp);base64,[A-Za-z0-9+/]+={0,2}$/;
function toSchemaInteger(value, { min, max, fallback }) {
if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) return fallback;
return value;
}
// Invalid or missing dates must never throw and never persist Invalid Date.
function safeIsoTimestamp(value) {
const parsed = value instanceof Date ? value : new Date(value);
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : new Date().toISOString();
}
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));
const bristolType = toSchemaInteger(input.bristolType, { min: 1, max: 7, fallback: 4 });
const provenanceOrigin = input.provenance && typeof input.provenance === 'object'
&& input.provenance.origin in KNOWN_PROVENANCE_ORIGINS
&& typeof input.provenance.origin === 'string'
&& KNOWN_PROVENANCE_ORIGINS.has(input.provenance.origin)
? 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(),
occurredAt: safeIsoTimestamp(input.occurredAt || Date.now()),
bristolType,
color: ['brown', 'green', 'yellow', 'pale', 'red', 'black'].includes(input.color) ? input.color : 'brown',
urgency: Math.min(4, Math.max(0, Number(input.urgency) || 0)),
discomfort: Math.min(4, Math.max(0, Number(input.discomfort) || 0)),
urgency: toSchemaInteger(input.urgency, { min: 0, max: 4, fallback: 0 }),
discomfort: toSchemaInteger(input.discomfort, { min: 0, max: 4, fallback: 0 }),
note: String(input.note || '').trim().slice(0, 500),
photoDataUrl: typeof input.photoDataUrl === 'string' && input.photoDataUrl.startsWith('data:image/') ? input.photoDataUrl : '',
photoDataUrl: typeof input.photoDataUrl === 'string' && APPROVED_PHOTO_DATA_URL.test(input.photoDataUrl) ? input.photoDataUrl : '',
symptoms,
};
if (provenanceOrigin) entry.provenance = { origin: provenanceOrigin };
@ -93,14 +112,58 @@ export function exportLedger(entries, exportedAt = new Date().toISOString()) {
}, null, 2);
}
export const MAX_IMPORT_BYTES = 2 * 1024 * 1024;
export function utf8ByteLength(text) {
return UTF8_ENCODER.encode(text).length;
}
// Collision-safe deterministic merge: existing (user-owned) rows always win;
// incoming rows are added only when their id is new. Re-importing a file can
// never duplicate, overwrite, or shadow user records, and every collision is
// reported back for explicit user feedback.
export function mergeLedgers(existing, incoming) {
const local = Array.isArray(existing)
? existing.filter(entry => entry && typeof entry === 'object' && !Array.isArray(entry))
: [];
const seen = new Set();
const merged = [];
for (const entry of local) {
const id = String(entry.id ?? '');
if (!id || seen.has(id)) continue;
seen.add(id);
merged.push(entry);
}
const added = [];
const skippedIds = [];
const source = Array.isArray(incoming) ? incoming : [];
for (const rawEntry of source) {
const entry = sanitizeEntry(rawEntry || {});
if (!entry.id || seen.has(entry.id)) {
skippedIds.push(String(rawEntry?.id ?? entry.id ?? ''));
continue;
}
seen.add(entry.id);
merged.push(entry);
added.push(entry);
}
return { merged, added, skippedIds };
}
// Portability policy (explicit bound, symmetric by construction):
// - The app accepts photos up to 4 MiB of decoded binary (analysis.js
// MAX_IMAGE_BYTES); base64 encoding expands that to ~5.6 MiB and JSON adds a
// small envelope per entry, so any export this app can produce fits inside
// 16 MiB.
// - Imports are rejected past MAX_IMPORT_BYTES UTF-8 bytes before parsing user
// data. Every app-produced export therefore re-imports byte-symmetrically,
// while hostile or runaway files stay bounded.
export const MAX_IMPORT_BYTES = 16 * 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) {
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.');
if (utf8ByteLength(text) > MAX_IMPORT_BYTES) throw new RangeError('That file is too large to be a Timmy export.');
let parsed;
try {
parsed = JSON.parse(text);

View File

@ -10,6 +10,8 @@ import {
hasUrgentLedgerContext,
importLedger,
MAX_IMPORT_BYTES,
mergeLedgers,
utf8ByteLength,
photoQualityMessage,
sanitizeEntry,
} from '../src/domain.js';
@ -142,6 +144,17 @@ test('rejects provenance origins outside the recorded vocabulary', () => {
}
});
test('provenance membership is own-property safe: inherited Object names are not origins', () => {
for (const poisoned of ['toString', 'constructor', '__proto__', 'hasOwnProperty', 'valueOf', 'isPrototypeOf']) {
const entry = sanitizeEntry({ id: 'x', bristolType: 4, provenance: { origin: poisoned } });
assert.equal(entry.provenance, undefined, poisoned);
assert.doesNotMatch(JSON.stringify(entry), new RegExp(poisoned), poisoned);
}
// Even a null-prototype provenance carrying a real origin stays acceptable.
const nullProto = sanitizeEntry({ id: 'y', bristolType: 4, provenance: Object.assign(Object.create(null), { origin: 'user' }) });
assert.deepEqual(nullProto.provenance, { origin: 'user' });
});
test('photo quality guidance is deterministic and does not claim visual diagnosis', () => {
assert.match(photoQualityMessage({ width: 300, height: 300, brightness: 0.5 }), /closer/i);
@ -210,6 +223,175 @@ test('import rejects oversized ledgers before parsing user data', () => {
assert.throws(() => importLedger(huge), RangeError);
});
test('utf8ByteLength measures UTF-8 bytes, not UTF-16 code units', () => {
assert.equal(utf8ByteLength(''), 0);
assert.equal(utf8ByteLength('abc'), 3);
// é is 1 UTF-16 unit but 2 UTF-8 bytes; 💩 is 2 UTF-16 units but 4 UTF-8 bytes.
assert.equal(utf8ByteLength('é'), 2);
assert.equal(utf8ByteLength('💩'), 4);
assert.equal(utf8ByteLength('aé💩b'), 1 + 2 + 4 + 1);
const emojiBlob = '💩'.repeat(1000);
assert.equal(emojiBlob.length, 2000, 'sanity: two code units each');
assert.equal(utf8ByteLength(emojiBlob), 4000);
});
test('import rejects oversized payloads by UTF-8 bytes regardless of composition', () => {
const asciiOver = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + 'n'.repeat(MAX_IMPORT_BYTES) + '"}]}';
assert.ok(utf8ByteLength(asciiOver) > MAX_IMPORT_BYTES);
assert.throws(() => importLedger(asciiOver), RangeError);
// 4 bytes per glyph: byte size crosses the cap at half the code-unit count.
const emojiOver = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + '💩'.repeat(Math.ceil(MAX_IMPORT_BYTES / 2)) + '"}]}';
assert.ok(emojiOver.length <= MAX_IMPORT_BYTES * 1.01, 'code-unit count must not be what trips this');
assert.ok(utf8ByteLength(emojiOver) > MAX_IMPORT_BYTES);
assert.throws(() => importLedger(emojiOver), RangeError);
});
test('import accepts a dense multibyte payload just under the byte cap', () => {
// Many small multibyte entries packed deterministically to just under the byte
// cap without tripping per-field bounds (note <= 500 chars).
const head = '{"product":"Timmy the Talking Turd","schemaVersion":1,"exportedAt":"2026-08-22T00:00:00.000Z","entries":[';
const makeEntry = i => ({ id: `m${i}`, occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, discomfort: 0, note: 'café ☕', photoDataUrl: '', symptoms: {} });
const perEntryBytes = utf8ByteLength(JSON.stringify(makeEntry(0))) + 1; // + comma
const budget = Math.floor((MAX_IMPORT_BYTES - utf8ByteLength(head) - 2) * 0.97);
const count = Math.max(1, Math.floor(budget / perEntryBytes));
const payload = `${head}${Array.from({ length: count }, (_, i) => JSON.stringify(makeEntry(i))).join(',')}]}`;
assert.ok(utf8ByteLength(payload) <= MAX_IMPORT_BYTES);
assert.ok(utf8ByteLength(payload) > MAX_IMPORT_BYTES * 0.95, 'payload must sit close to the boundary');
const imported = importLedger(payload);
assert.equal(imported.length, count, 'every packed entry survives');
assert.equal(imported[imported.length - 1].note, 'café ☕');
});
test('a maximal app-produced export with a 4 MiB photo round trips byte-symmetrically', () => {
// 4 MiB binary is the app-wide photo ceiling (analysis.js MAX_IMAGE_BYTES).
const photoBytes = 4 * 1024 * 1024;
let b64 = Buffer.from('a'.repeat(photoBytes)).toString('base64');
const entry = sanitizeEntry({
id: 'big-photo',
bristolType: 4,
photoDataUrl: `data:image/jpeg;base64,${b64}`,
note: 'boundary photo',
});
const exported = exportLedger([entry], '2026-08-22T00:00:00.000Z');
assert.ok(utf8ByteLength(exported) <= MAX_IMPORT_BYTES, 'largest producible export must stay inside the import cap');
const roundTripped = importLedger(exported);
assert.equal(roundTripped.length, 1);
assert.equal(roundTripped[0].photoDataUrl, entry.photoDataUrl, 'photo survives the round trip without silent loss');
});
test('import rejects payloads past the explicit portability ceiling before parsing', () => {
const huge = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + 'n'.repeat(MAX_IMPORT_BYTES + 1024) + '"}]}';
assert.ok(utf8ByteLength(huge) > MAX_IMPORT_BYTES);
assert.throws(() => importLedger(huge), RangeError);
});
test('merge keeps every distinct record and never duplicates or overwrites user-owned entries', () => {
const local = [
{ id: 'a', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, note: 'local version of shared id' },
{ id: 'b', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 1, discomfort: 0, note: 'local b', photoDataUrl: '', symptoms: {} },
];
const incoming = importLedger(JSON.stringify([
{ id: 'b', occurredAt: '2026-01-01T00:00:00.000Z', bristolType: 7, color: 'black', urgency: 4, note: 'hostile rewrite of existing id' },
{ id: 'c', occurredAt: '2026-08-22T09:00:00.000Z', bristolType: 6, note: 'new from file' },
]));
const result = mergeLedgers(local, incoming);
assert.equal(result.merged.length, 3, 'one row per unique id');
assert.equal(result.merged.filter(entry => entry.id === 'b').length, 1, 'no duplicate ids');
assert.equal(result.merged.find(entry => entry.id === 'b').note, 'local b', 'existing user-owned entry is never overwritten');
assert.deepEqual(result.added.map(entry => entry.id), ['c'], 'only genuinely new records are added');
assert.deepEqual(result.skippedIds, ['b'], 'collisions are reported explicitly');
// Order stays deterministic: local rows first in their stored order, then additions in incoming order.
assert.deepEqual(result.merged.map(entry => entry.id), ['a', 'b', 'c']);
});
test('re-importing the same file twice changes nothing (idempotent)', () => {
const base = [{ id: 'seed', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2 }];
const file = importLedger(JSON.stringify([{ id: 'seed', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2 }, { id: 'extra', occurredAt: '2026-08-22T08:30:00.000Z', bristolType: 3 }]));
const first = mergeLedgers(base, file);
assert.deepEqual(first.added.map(entry => entry.id), ['extra']);
const second = mergeLedgers(first.merged, file);
assert.equal(second.merged.length, first.merged.length, 'second import adds nothing');
assert.deepEqual(second.added, [], 'second import reports no additions');
assert.deepEqual(second.skippedIds.sort(), ['extra', 'seed'], 'both already-present ids are reported as skipped');
});
test('merge sanitizes incoming entries so imports cannot smuggle hostile fields into storage', () => {
const incoming = [{ id: 'proto-entry', bristolType: 4, __proto__: { poisoned: true }, extra: 'strip me' }, { id: 'ctor-entry', bristolType: 4, sessionCookie: 'SID=x' }];
const result = mergeLedgers([], incoming);
assert.equal(result.merged.length, 2, 'both records still import as data');
for (const entry of result.merged) {
assert.equal(Object.getPrototypeOf(entry), Object.prototype, `plain-object entry ${entry.id}`);
assert.equal(entry.poisoned, undefined, 'prototype payload must not leak');
assert.equal(entry.extra, undefined, 'unknown fields stay out of storage');
assert.equal(entry.sessionCookie, undefined, 'smuggled secrets stay out of storage');
}
const serialized = JSON.stringify(result.merged);
assert.doesNotMatch(serialized, /poisoned|extra|sessionCookie|SID=/);
});
test('current-schema numeric fields are strict integers within clinical bounds', () => {
for (const bogus of [4.5, '3', 0, 8, NaN, null, true, [3]]) {
const entry = sanitizeEntry({ id: 'n', bristolType: bogus });
assert.equal(entry.bristolType, 4, `bristolType ${JSON.stringify(String(bogus))} falls back to the neutral default`);
}
assert.equal(sanitizeEntry({ id: 'ok1', bristolType: 1 }).bristolType, 1);
assert.equal(sanitizeEntry({ id: 'ok2', bristolType: 7 }).bristolType, 7);
const mixed = sanitizeEntry({ id: 'm', urgency: 2.5, discomfort: -1 });
assert.equal(mixed.urgency, 0);
assert.equal(mixed.discomfort, 0);
const strings = sanitizeEntry({ id: 's', urgency: '3', discomfort: 11 });
assert.equal(strings.urgency, 0, 'numeric strings are not schema integers');
assert.equal(strings.discomfort, 0);
const valid = sanitizeEntry({ id: 'v', urgency: 3, discomfort: 4 });
assert.equal(valid.urgency, 3);
assert.equal(valid.discomfort, 4);
});
test('photo fields accept only approved raster JPEG/PNG/WebP base64 data URLs', () => {
const okJpeg = `data:image/jpeg;base64,${Buffer.from('ok').toString('base64')}`;
const okPng = 'data:image/png;base64,iVBORw0KGgo=';
const okWebp = 'data:image/webp;base64,UklGRg==';
assert.equal(sanitizeEntry({ id: 'p1', photoDataUrl: okJpeg }).photoDataUrl, okJpeg);
assert.equal(sanitizeEntry({ id: 'p2', photoDataUrl: okPng }).photoDataUrl, okPng);
assert.equal(sanitizeEntry({ id: 'p3', photoDataUrl: okWebp }).photoDataUrl, okWebp);
for (const bad of [
'data:image/svg+xml;base64,PHN2Zy8+',
'data:image/svg+xml,<svg onload="alert(1)">',
'data:image/gif;base64,R0lGODlh',
'data:image/jpeg;base64,!!!not-base64!!!',
'data:image/jpeg,percent%2Dencoded',
'data:text/html;base64,PGh0bWw+',
'http://example.com/photo.jpg',
42,
]) {
assert.equal(sanitizeEntry({ id: 'bad', photoDataUrl: bad }).photoDataUrl, '', `rejected: ${String(bad).slice(0, 40)}`);
}
});
test('invalid or missing dates never throw and never persist Invalid Date values', () => {
for (const bad of ['not-a-date', '2026-13-45T99:99:99Z', {}, ['2026-01-01'], true]) {
let entry;
assert.doesNotThrow(() => { entry = sanitizeEntry({ id: 'd', occurredAt: bad }); }, String(bad));
assert.match(entry.occurredAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
assert.equal(Number.isNaN(new Date(entry.occurredAt).getTime()), false, `safe ISO for ${String(bad)}`);
}
const blank = sanitizeEntry({ id: 'd2', occurredAt: '' });
assert.equal(Number.isNaN(new Date(blank.occurredAt).getTime()), false);
const kept = sanitizeEntry({ id: 'd3', occurredAt: '2026-08-01T10:00:00.000Z' });
assert.equal(kept.occurredAt, '2026-08-01T10:00:00.000Z', 'valid dates pass through unchanged');
});
test('imports containing invalid dates migrate forward instead of crashing the whole ledger', () => {
const payload = JSON.stringify([
{ id: 'bad-date', occurredAt: 'garbage-date-value', bristolType: 3 },
{ id: 'good-date', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4 },
]);
const imported = importLedger(payload);
assert.equal(imported.length, 2, 'one bad field cannot destroy the batch');
assert.equal(Number.isNaN(new Date(imported[0].occurredAt).getTime()), false, 'bad date becomes a safe ISO timestamp');
assert.equal(imported[1].occurredAt, '2026-08-20T09:00:00.000Z');
});
test('round trip preserves confirmed values and provenance without leaking secrets', () => {
const saved = [
sanitizeEntry({
@ -237,9 +419,9 @@ test('round trip preserves confirmed values and provenance without leaking secre
);
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].bristolType, 4, 'out-of-range Bristol type falls back to the neutral default under strict schema');
assert.equal(roundTripped[1].color, 'brown');
assert.equal(roundTripped[1].urgency, 4);
assert.equal(roundTripped[1].urgency, 0, 'out-of-range urgency falls back to the neutral default');
assert.equal(roundTripped[1].discomfort, 0);
assert.deepEqual(roundTripped[1].provenance, undefined);
});

View File

@ -75,6 +75,70 @@ await mkdtemp(join(tmpdir(), 'timmy-portability-')).then(async (workDir) => {
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 });
// Byte-limit enforcement in the browser: File.size is checked BEFORE the
// file is read, and UTF-8 bytes (not JS characters) are the measured unit.
const MAX_IMPORT_BYTES = await page.evaluate(async () => (await import('/src/domain.js')).MAX_IMPORT_BYTES);
const oversizePath = join(workDir, 'oversize-ledger.json');
await writeFile(oversizePath, Buffer.concat([
Buffer.from('{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"'),
Buffer.alloc(MAX_IMPORT_BYTES + 1, 0x6e),
Buffer.from('"}]}'),
]));
let textReads = 0;
await page.evaluate(() => {
const original = File.prototype.text;
File.prototype.text = function (...args) {
window.__fileTextReads = (window.__fileTextReads || 0) + 1;
return original.apply(this, args);
};
});
await page.setInputFiles('#import', oversizePath);
await page.getByText(/too large/i).waitFor({ timeout: 5000 });
textReads = await page.evaluate(() => window.__fileTextReads || 0);
assert.equal(textReads, 0, 'oversized files must be rejected by File.size before File.text()');
const afterOversize = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(afterOversize.sort(), ['legacy-9', 'seed-1'], 'oversized import must not mutate the ledger');
// Multibyte boundary: a payload whose UTF-8 byte size exceeds the cap while
// its JS character count does not must still be rejected (byte-exact limit).
const emojiHead = '{"product":"Timmy the Talking Turd","schemaVersion":1,"exportedAt":"2026-08-22T00:00:00.000Z","entries":[{"id":"e","occurredAt":"2026-08-20T09:00:00.000Z","bristolType":4,"color":"brown","urgency":0,"discomfort":0,"note":"';
const emojiTail = '"}]}';
const emojiNoteUnits = Math.ceil(MAX_IMPORT_BYTES / 3); // ~1.33x cap in UTF-8 bytes, ~0.67x cap in JS units
const multibyteOverBytesPath = join(workDir, 'multibyte-over-bytes.json');
await writeFile(multibyteOverBytesPath, Buffer.from(emojiHead + '💩'.repeat(emojiNoteUnits) + emojiTail, 'utf8'));
const multibyteStats = await page.evaluate(payload => {
return { bytes: new TextEncoder().encode(payload).length, units: payload.length };
}, emojiHead + '💩'.repeat(emojiNoteUnits) + emojiTail);
assert.ok(multibyteStats.bytes > MAX_IMPORT_BYTES, 'fixture must exceed the cap in UTF-8 bytes');
assert.ok(multibyteStats.units <= MAX_IMPORT_BYTES, 'fixture must stay under the cap in JS characters');
await page.setInputFiles('#import', multibyteOverBytesPath);
await page.getByText(/too large/i).waitFor({ timeout: 5000 });
const afterMultibyte = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(afterMultibyte.sort(), ['legacy-9', 'seed-1'], 'multibyte over-byte import must not mutate the ledger');
// A dense multibyte payload just UNDER the byte cap still imports cleanly.
const underBytesPath = join(workDir, 'multibyte-under-bytes.json');
const underPayload = JSON.stringify({
product: 'Timmy the Talking Turd',
schemaVersion: 1,
exportedAt: '2026-08-22T00:00:00.000Z',
entries: [{ id: 'under-1', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, discomfort: 0, note: 'café ☕'.repeat(2000), photoDataUrl: '', symptoms: {} }],
});
const underBytes = await page.evaluate(payload => new TextEncoder().encode(payload).length, underPayload);
assert.ok(underBytes <= MAX_IMPORT_BYTES && underBytes > 10000, 'under-cap fixture must carry real multibyte mass');
await writeFile(underBytesPath, Buffer.from(underPayload, 'utf8'));
await page.setInputFiles('#import', underBytesPath);
await page.getByText('Ledger imported').waitFor({ timeout: 5000 });
const afterUnderBytes = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.ok(afterUnderBytes.includes('under-1'), 'valid multibyte import lands in the ledger');
// Collision-safe merge: re-importing a file whose ids already exist must
// not duplicate or overwrite anything and must say so explicitly.
const beforeReimport = await page.evaluate(storeKey => localStorage.getItem(storeKey), ROOT_STORE);
await page.setInputFiles('#import', underBytesPath);
await page.getByText(/already in your ledger/i).waitFor({ timeout: 5000 });
const afterReimport = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(afterReimport.sort(), JSON.parse(beforeReimport).map(entry => entry.id).sort(), 're-import is idempotent: no duplicates, no overwrites');
// 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 });
@ -108,6 +172,10 @@ await mkdtemp(join(tmpdir(), 'timmy-portability-')).then(async (workDir) => {
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: {} },
]));
// Legacy root-deployment ledger living in the same origin's storage.
localStorage.setItem('timmy-ledger-v1', JSON.stringify([
{ id: 'root-legacy-1', occurredAt: '2026-08-19T09:00:00.000Z', bristolType: 3, color: 'brown', urgency: 0, discomfort: 0, note: 'root legacy ledger', photoDataUrl: '', symptoms: {} },
]));
});
await stagingPage.reload({ waitUntil: 'networkidle' });
await stagingPage.locator('[data-view="calendar"]').last().click();
@ -119,12 +187,17 @@ await mkdtemp(join(tmpdir(), 'timmy-portability-')).then(async (workDir) => {
}));
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');
assert.ok(isolation.legacy && JSON.parse(isolation.legacy).some(entry => entry.id === 'root-legacy-1'), 'legacy store untouched by staging session');
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');
const legacyAfterStagingDelete = await stagingPage.evaluate(() => localStorage.getItem('timmy-ledger-v1'));
assert.ok(
legacyAfterStagingDelete && JSON.parse(legacyAfterStagingDelete).some(entry => entry.id === 'root-legacy-1'),
'base-path delete-all must not erase another namespaces global legacy ledger',
);
await stagingContext.close();
} finally {
staging.kill();