Munchable has two buttons that most products make you send an email for: download everything you hold about me, and delete my account. Both are self service, on the same reasoning our privacy page states plainly: a right you have to ask for is weaker than a right you can simply take. You can read the wording at munchable.app/privacy.
Each button is one API route, neither is long, and the interesting parts are four decisions that are not obvious until you write one.
One: the export has a note field, and it is not decoration
The export is a JSON download of every row we hold keyed to the account:
const payload = {
exportedAt: new Date().toISOString(),
account: { id: user.id, email: user.email ?? null },
note: 'Your conditions, sensitivities, and scan history are stored only on your device and are never sent to our servers, so they are not part of this export.',
entitlement: ent[0] ?? null,
consent: consent[0] ?? null,
contributionRewards: rewards,
productContributions: contributions,
productFeedback: feedback,
};
The health data genuinely is not on our servers. Conditions, sensitivities and scan history live on the phone and are never synced, which is a design choice I have written about before: erasure is easy when the sensitive data was never on your server.
But consider what that does to the export. Somebody exercising a right of access downloads the file precisely because they want to know what you know. They open it, and the most sensitive category they can think of is missing. There are exactly two available conclusions: the company does not hold it, or the export is incomplete. Silence picks the worse one for you.
So the absence is stated inside the artifact itself. Not in the download page, which they will not keep, and not in the privacy policy, which they are not reading at that moment. In the file, next to the data, where anyone auditing it later will find the claim attached to the evidence.
The rest of the shape is deliberately boring. Five queries in a Promise.all, no job queue, no "we will email you a link within 30 days", and a content-disposition header so a browser saves it:
return new NextResponse(JSON.stringify(payload, null, 2), {
status: 200,
headers: {
'content-type': 'application/json; charset=utf-8',
'content-disposition': `attachment; filename="munchable-data-${uid}.json"`,
},
});
JSON.stringify(payload, null, 2) rather than compact output, because portability means a human can open it. Two-space indentation is the difference between a right you can exercise and a right you technically have.
Two: erasure fails closed
The delete route needs a privileged key to remove the identity record from the auth provider. Without it, everything else still works: it could delete the database rows, cancel billing, and return a cheerful { ok: true } while the account still exists and can still sign in.
// Without the service-role key we cannot delete the identity itself, so this
// would not be a real erasure: fail closed rather than half-delete.
if (!hasServiceRole()) {
return NextResponse.json({ error: 'service_role_missing' }, { status: 501 });
}
A 501 with nothing deleted is an outage. A 200 with a surviving login is a lie told to somebody who asked to be forgotten, and it is the kind of lie that is discovered months later when they receive an email. Check capability before touching anything.
Three: contributions are re-pointed, not cascaded
Users can add products we do not have by photographing the label. Those contributions are in the catalogue that every other user's scans read from.
So what does erasure do with them? Deleting them would remove community data from thousands of unrelated users and break the rows that reference it. Keeping them as they are would keep a personal identifier attached to them, which is the one piece of personal data a revision carries.
Neither. The link is re-pointed at a sentinel:
// A fixed "deleted user" sentinel that contributed revisions are re-pointed to
// on erasure. Anonymizes the personal link (the only personal data on a
// revision) while keeping the community catalogue intact: the served
// product rows still reference the same revision ids.
const DELETED_CONTRIBUTOR = '00000000-0000-0000-0000-000000000000';
The all-zero UUID is a nice choice here for a reason beyond looking obvious in a query result: it is a valid UUID, so the column keeps its type and its shape, and every query that groups or joins on contributor keeps working with no special case. The alternative, a nullable column, would mean auditing every reader for null handling.
A real erasure question worth answering out loud: does anonymising count? For this field it does, because after the update the row cannot be linked back to a person by us or by anyone with the database, and what remains is a statement about a product rather than about a human. If the revision contained free text somebody wrote about themselves, the answer would be different, and the row would have to go.
Four: the order of operations
The route does three things, in this order, and the order is the design:
1. Cancel Stripe subscription and delete the customer (best effort)
2. Delete every server row in one transaction (must succeed)
3. Delete the identity in the auth provider (must succeed)
Billing first, and best effort. If the subscription is cancelled and the rest fails, the worst case is a user who is still registered and no longer being charged, which is a direction of error nobody complains about. Reverse it and you have deleted the account of somebody whose card is still being billed monthly with no account to cancel from. Both Stripe calls are individually wrapped, because "already cancelled" and "no such customer" are successes wearing an exception's clothes, and an erasure that hangs on a third-party outage is an erasure that does not happen.
Rows in one transaction. Support tickets are the most sensitive free text in the whole system, because a ticket body is whatever the person typed, and they have to go with the account. The schema has no foreign key constraints, so messages are deleted through a subquery on their tickets rather than by cascade, and all of it is inside one transaction so a failure halfway through does not leave a ticket whose messages are gone.
Identity last. As long as the identity exists, the user can sign in and retry. Deleting it first and then failing on the rows would leave orphaned data belonging to somebody with no way back in to ask about it. Making the irreversible step the final step is the same instinct as putting your git push after your tests.
Then the client wipes the device: the profile, the scan history, the product cache and the taxonomy overlay, from an explicit list of storage keys rather than a "clear everything", so that one mislabelled key cannot quietly survive an account deletion.
What this cost
About 130 lines across two routes, no background jobs, no admin tooling, and no support queue for data requests. The whole thing is affordable because of the decision upstream of it: health data never left the device, so the server-side dataset per user is small enough to export in one request and delete in one transaction.
Privacy engineering gets cheaper the less you collect. That is not a moral position, it is a line count.
- The rights, in plain words: munchable.app/privacy
- What the app actually does with a label, which is the reason it needs so little about you: munchable.app/conditions
- The app: munchable.app
If you have built a self-service export, I am curious whether you annotated the gaps in the file, or whether you also only thought of it when you opened your own.
Top comments (0)