Munchable scans a food label and tells you whether the product suits the gut conditions you manage. The conditions, the sensitivities and the scan history live on the device and never reach the server, which is the architecture the privacy notice describes at munchable.app/privacy: "The rules engine that turns an ingredient list into Good fit, Caution or Avoid runs on the device, so the verdict is produced locally and is never transmitted anywhere."

Keeping the data on the phone moves work rather than removing it. Deleting an account now has two halves, and only one of them is a transaction.

The server half

The account deletion route cancels the Stripe subscription, deletes the customer, then empties every table keyed to the user in one transaction, and finally deletes the identity itself. Two details in it are worth lifting out.

Contributions are anonymised rather than deleted:

const DELETED_CONTRIBUTOR = '00000000-0000-0000-0000-000000000000'; 

A contributed product revision has exactly one piece of personal data on it, which is the link to the person. Re-pointing that link at a sentinel removes the personal part and leaves the catalogue intact, because the served rows still reference the same revision ids. Deleting the revisions would take other people's answers away with yours.

And it fails closed:

if (!hasServiceRole()) { return NextResponse.json({ error: 'service_role_missing' }, { status: 501 }); } 

Without the key that can delete the identity, this would be a half-deletion: rows gone, account still able to sign in. A 501 is a worse user experience and an honest one.

The device half, which is a list

The client runs after the server answers:

const { error } = await deleteAccount(); if (error) { showAlert('Could not delete your account', error); return; } // Server erased everything and signed us out; now clear this device. await wipeLocalData(); 

And wipeLocalData is a hand-written list of keys:

await AsyncStorage.multiRemove([ 'munchable-profile', // health profile 'munchable-history', // scan history + food log 'munchable-products-v1', // productCache (pre-v2 key, still cleared) 'munchable-products-v2', // productCache 'hints:sent', // legacy, no longer written 'taxonomy:overlay', 'taxonomy:meta', ]); 

That is the version of the file I found. Here is the constant in the module that owns the third, fourth and fifth of those entries:

const STORE_KEY = 'munchable-products-v3'; 

The cache key carries a version suffix that is bumped whenever the shape of a stored entry changes, because an entry written by an older build rehydrates with the new fields simply absent, and absent reads as a real answer rather than as "we do not know". That mechanism is good and it is the reason the suffix exists. (An earlier post is entirely about why.)

What nobody noticed is that bumping it is an edit in two files. The cache moved to v2, then to v3. The deletion list learned about v2 and then stopped. So for two versions, deleting your account cleared two keys the app no longer wrote and left the one it did.

How bad, honestly

Not very, and I want to be precise about that rather than sell the bug.

The cache holds public product data, bounded at 24 entries, and its own comment restricts what it may contain: products only, never conditions, never verdicts, never photos.

So the health data the privacy notice is about was always cleared: the profile key and the history key were in the list the whole time. What survived deletion was up to 24 product records keyed by barcode. No verdicts, no conditions, nothing a second user of the same phone could read as personal, and it would have been evicted or invalidated by the next version bump anyway.

What was actually broken was a promise. The function's own docstring says it "removes all Munchable data persisted on this device", deletion is offered in the app as a thing you can do without asking us, and the list of barcodes you scanned is yours even when every field in it is public. A function that enumerates what to delete is only as true as its list.

The test cannot import either file

The obvious test is to import the cache module, read its key, and assert the wipe list contains it. That does not work: both modules import AsyncStorage, which needs a native host that node --test does not have. The test suite for this app runs in plain Node with no simulator, and I would rather keep it that way than add a mocking layer so that one assertion can exist.

So the test reads the two files as text:

const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8'); test('account deletion clears the product cache key the app actually writes', () => { const key = /STORE_KEY = '([^']+)'/.exec(read('../productCache.ts'))?.[1]; assert.ok(key, 'productCache.ts no longer declares STORE_KEY as a literal'); assert.match(read('../wipe.ts'), new RegExp(`'${key}'`), `wipe.ts does not clear ${key}`); }); 

Two things make this less bad than it looks. The first assertion is about the test's own assumptions: if the key stops being a string literal, the test says so instead of quietly matching nothing. The second compares two files that are supposed to agree, which is the actual invariant, rather than asserting a key name that both files could be wrong about together.

The fix in the list itself is one line, plus the only comment that will survive contact with the next version bump:

'munchable-products-v2', // productCache (pre-v3 key, still cleared) 'munchable-products-v3', // productCache (current key: bump this list with it) 

It is still a grep, and greps have a reach. This one pins one pair of files. Here is what writes to device storage in this app:

store/profile.ts name: 'munchable-profile' store/history.ts HISTORY_STORAGE_KEY = 'munchable-history' lib/productCache.ts STORE_KEY = 'munchable-products-v3' <- pinned lib/taxonomy.ts OVERLAY_KEY = 'taxonomy:overlay' lib/taxonomy.ts META_KEY = 'taxonomy:meta' 

Five keys from four modules, four of them declared as named constants, one of them checked. The remaining three are the same bug waiting for the same trigger, and the version suffix is what makes the cache the likely one to drift again. The next step is obvious: assert over every exported key constant rather than one, and fail when a module declares a key that the wipe list does not mention.

The broader shape of this, for anyone whose app keeps user data locally: the deletion path is the least exercised code you ship. It runs once per user, at the moment they stop being a user, and nothing about its failure is visible to them or to you. If it works by enumerating keys, something has to check the enumeration, and the check does not have to be elegant. It has to exist.

The product side of this is at munchable.app/privacy, which describes what is on the device and what deleting an account removes, and munchable.app/licenses, which covers the contributed labels the sentinel above is protecting.