DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Account deletion erases seven tables in one transaction, then eight string literals it cannot see

Munchable is a barcode scanner for people with gut conditions, and the thing it holds about you is the one thing you would not want leaked. So the delete button has to actually delete. The policy it implements is public, at munchable.app/privacy, and writing it down created a problem: the policy is one promise and the implementation is two programs that cannot share a transaction.

The server half

POST /api/delete-account runs in three stages.

Stage zero is a refusal. Erasing the identity itself needs the Supabase service-role key, and without it the route would delete the data and leave the login:

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

A half-delete that returns 200 is worse than a clean 501, because the caller writes "deleted" in a support reply either way.

Stage one cancels Stripe, best-effort, inside nested try/catch blocks that swallow the "already cancelled" and "already gone" cases. Erasure must not hang on a payment provider.

Stage two is one transaction: six deletes and one update across seven tables. The update is the interesting row. A label somebody contributed is a fact about a product on a shop shelf, not about them, so erasure re-points it at a sentinel rather than removing it:

const DELETED_CONTRIBUTOR = '00000000-0000-0000-0000-000000000000';
Enter fullscreen mode Exit fullscreen mode

Support history is in the delete list, and the comment above it says why: a ticket body is whatever the person typed, which makes it the most sensitive free text in the database. There are no foreign keys in this schema, so the messages go by subquery over their tickets rather than by cascade.

Stage three deletes the Supabase auth user. Only then does the route answer { ok: true }.

The half the server cannot reach

The conditions you tell Munchable about, and your scan history, are never sent anywhere. That is a deliberate architecture, not a feature of the delete route (the free tier is counted on the phone for the same reason): the store that holds the scan history and food log carries this comment,

Health data by any reading, so it lives here and nowhere else: never synced, never exported by the server's account export, cleared with the rest of the device on account deletion.

which means /api/delete-account has nothing to delete there, and /api/export-account has nothing to export. The export endpoint genuinely cannot produce your food log. The phone has to do its own erasure, after the server's, in the client:

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();
reset();
router.replace('/onboarding');
Enter fullscreen mode Exit fullscreen mode

The ordering is forced. Wipe first and a failing server call leaves somebody signed in to an account whose local state you just destroyed. Wipe second and a throwing wipe leaves an erased account with its data still on the phone, which is why the wipe is best-effort and documented as never throwing:

try {
  await AsyncStorage.multiRemove([...]);
} catch {
  // Best-effort, a storage error must not block deletion.
}
Enter fullscreen mode Exit fullscreen mode

Eight literals

That array is the whole on-device erasure, and every entry in it is a hand-typed string:

  • munchable-profile, which exists nowhere else as a constant: it is a literal inside a Zustand persist config.
  • munchable-history, which does have an exported constant, HISTORY_STORAGE_KEY, in the store that owns it. The wipe list does not import it.
  • munchable-products-v1, -v2, -v3. The current one is v3, and the older two are cleared on purpose so an upgrade from an old install does not leave orphans behind. The key that produces them is module-private, so no amount of good intent lets the wipe list import it. (The reason that key has a version at all was its own post: an absent field read as an answer.)
  • taxonomy:overlay and taxonomy:meta, both of which are exported constants in the module that writes them, re-typed here as literals.
  • hints:sent, which nothing in the app writes any more. It stays because some installs from before that feature was removed still have it.

Four of the eight have a canonical constant somewhere in the same repository. The list re-types all of them. That is not laziness so much as a reach problem: one of the keys is unexported by design, and a list where some entries are imported and some are literals reads worse than a list where they all are.

Why not just clear everything

AsyncStorage.clear() would be one line and no drift. It would also remove keys the app does not own, from Expo and from every SDK in the bundle, and "delete my account" is not a licence to reset the device's storage. The obvious middle, sweeping keys by prefix, does not work either: three of the eight do not start with munchable. The naming went munchable-thing for stores and thing:field for caches, nobody reconciled them, and a prefix filter would have silently missed the taxonomy pair and the legacy key.

So the list is explicit, which is correct, and hand-maintained, which is the bug.

The test greps

The failure that actually happened: the product cache moved to v3 and account deletion kept clearing v1 and v2. Two files, one number, no compiler relationship between them. The test that now holds them together does not import either module, because both pull in AsyncStorage and that needs a native host no test runner here provides. It reads them as text:

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}`);
});
Enter fullscreen mode Exit fullscreen mode

Two assertions, and the first one is the one I would have left out a year ago. A regex over source code fails open: refactor STORE_KEY into a template string or a computed name and the match returns undefined, the second assertion never runs, and the test passes while guarding nothing. So the first assertion's whole job is to fail when the test stops being able to do its job.

It is a grep in a trench coat. I would rather have a type, but the two files cannot see each other, and a regex that announces its own blindness is worth more than a comment asking the next person to remember.

The policy all of this implements is on munchable.app/privacy, including the part about what survives erasure and why, and the about page says who is on the other end of the delete button.

Top comments (1)

Collapse
 
beusebiu profile image
Eusebiu Balan •

One ordering trap from my own delete route, in Laravel: log the person out before the erase, not after. Logging out rotates the remember-me token, which is a save on the user model, and once the row is gone the ORM treats that save as a brand new row. The account you were asked to erase comes back as an insert.

Same family as your stage zero. A delete that says ok and isn't.