Munchable 免費方案每月限掃描五次,由手機計數以免伺服器追蹤個人掃描次數
Our free tier is five scans a month and the phone counts them, because the server is not allowed to know how many you did
Munchable 將每月五次的免費掃描配額記錄在用戶手機上,避免伺服器保存與個人帳戶連結的掃描紀錄。健康資料、掃描歷史和食物日誌只存於裝置,產品查詢則只向伺服器傳送條碼。伺服器按月份統計各條碼的查詢次數,不記錄用戶、裝置或工作階段資料。
Munchable reads a food label and tells you whether a product suits a digestive condition you have. To do that it needs to know your conditions, which means it holds a list of your diagnoses, your sensitivities, every product you have checked, and in the food log, how you felt afterwards.
Under UK and EU data protection law that is special category data, and the privacy notice says what we concluded about it:
The simplest way to protect it is not to hold it, so that is the architecture we built.
That sentence is easy to write and expensive to mean. This post is what meaning it cost: three features with no endpoint, a paid quota counted by the client, a server-side counter deliberately built so it cannot be joined to anybody, and a data export whose most interesting property is what it does not contain.
What stays on the device
The profile, the history and the food log are device state, persisted to local storage and never sent anywhere. The history store says so where somebody will read it:
/**
* The scan history and food/symptom log. 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.
*/
The engine that turns an ingredient list into an answer runs on the device too, which is the load-bearing half. If the verdict were computed server-side, the request would have to carry the condition list, and at that point the data is on my servers no matter what the retention policy says.
Three features that have no endpoint, and never may
The recipe library is the clearest case. It is a filtered list, which is the canonical shape of thing you would serve from an API:
* There is no recipes endpoint and there must never be one: generating
* or filtering meals server side would mean sending the condition list, which
* is the linkage the whole architecture exists to prevent.
* The happy consequence is that the whole feature works offline and costs
* nothing to serve.
The same applies to the scan history, the food log and the shopping filter where a reader picks additives they would rather not buy. None has a route. None can have one. The pleasant side effects (offline support, nothing to scale, no hosting cost for four features) are genuine, and they are side effects. The reason is the linkage.
What the server does handle is the product lookup, and the privacy notice describes its shape exactly:
Scanning a product sends its barcode to our servers to fetch the ingredient list. The request carries the barcode and nothing about you or your health, and the verdict is worked out afterwards on your device, so the lookup never sees it.
A barcode in, a product out. No condition, no profile, no answer.
So the quota is counted on the phone
Munchable's free tier is five scans a month. Metering is the classic case for server-side state: you count usage per account, server-side, because the client is the party with an incentive to lie.
Except that counting scans per account server-side means keeping a per-user log of scans, which is the exact record this architecture refuses to keep. One row per user per scan, with a timestamp, is a health diary with extra steps. So the counter lives in the same device store as the profile:
// Monetization: free tier = FREE_SCAN_LIMIT scans/month.
isPremium: boolean;
scanPeriod: string;
scansThisPeriod: number;
countScan: () => {
const period = currentPeriod();
const used = get().scanPeriod === period ? get().scansThisPeriod : 0;
set({ scanPeriod: period, scansThisPeriod: used + 1 });
},
remainingScans: () => {
if (get().isPremium) return Infinity;
const used = get().scanPeriod === currentPeriod() ? get().scansThisPeriod : 0;
return Math.max(0, FREE_SCAN_LIMIT - used);
},
currentPeriod() is a YYYY-MM string, and the rollover is free: the stored period not matching the current one means the stored count is irrelevant, so it reads as zero rather than needing a reset job on the first of the month. Comparing against a stored period instead of storing a reset date is the trick that makes a monthly quota have no scheduled work in it at all.
Two things about when it is called are worth lifting out. First, the counter is not called by the thing that does the scan:
* Records one scan against this month's quota. Call only once a product is
* found and a verdict shown; a not found scan costs nothing.
A barcode we have nothing for is not a product of value delivered, so it does not spend anything. That rule is repeated in the scan path, where the module that decides what a camera frame means is explicit that it decides nothing about money: "None of it counts a scan, spends quota or navigates. Those are decisions for" the caller. Separating "I recognised a barcode" from "I am charging you for this" is what lets the camera be as trigger-happy as it likes.
Second, and obviously: a determined user can reset the count by clearing the app's storage. I know. That is the trade, and I would make it again. The alternative buys stricter metering with a per-user scan log, which is a worse product and a worse liability, to protect revenue from the subset of people who will dig through storage to avoid a small subscription and who were never going to pay.
The counter that does exist, built so it cannot be joined
The server does count something, because deciding which products to improve next needs to know what people actually scan. Its header states the rule before the code:
// PRIVACY (hard rule): a barcode and a number, nothing else. No user id, no
// device, no session, no timestamp finer than the calendar month. A sorted set
// per month (`scans:m:2026-09`, score = lookups) is the whole record; it cannot
// be turned back into anyone's history, and it expires on its own. Lookups are
// still never persisted per user anywhere.
The implementation is a dozen lines, and two details are worth copying:
export async function countScan(barcode: string): Promise<void> {
if (!redis || !barcode) return;
try {
const key = monthKey();
const n = await redis.zincrby(key, 1, barcode);
// ZINCRBY returns the member's new score, so 1 means this barcode's first
// lookup this month: that sets the month key's expiry on first use and
// refreshes it on each later first-time barcode.
if (n === 1) await redis.expire(key, KEY_TTL_S);
} catch {
// Counting is advisory. A scan that could not be counted is still a scan.
}
}
The expiry comes free out of the increment's return value, so there is no separate bookkeeping and no key that outlives its window because somebody forgot to set a TTL on creation. And the whole thing is best effort, scheduled with after() so it runs once the response has gone out: a telemetry write must never be on the path of the thing being measured.
The structural result is the part I like. The server knows how often barcode 5000112637922 was looked up in September. Your phone knows you did four scans this month. There is no row anywhere that joins those two facts, because the join key was never written down. The privacy notice gets to say the strong version of this, which is only sayable because of how the data is shaped:
It cannot be turned back into anybody's scan history, including yours, because the information needed to do that was never recorded.
The export that is honest about being thin
Data rights are where local-first gets tested, because a right you can take yourself is worth more than a right you can ask for. Both of ours are self service, and the download says plainly what is not in it:
You get a machine readable file containing every field we hold against your account. Your conditions and scan history are not in it, because they are on your device and were never sent to us.
That sentence is the one I expect to get email about, and it is the correct sentence. An export that quietly contained nothing interesting would be worse. An export that invented a health profile to look complete would be absurd.
Deletion has the mirror-image problem: erasure has to reach the device, which means something has to know every storage key anything has ever written:
await AsyncStorage.multiRemove([
'munchable-profile', // health profile
'munchable-history', // scan history + food log
'munchable-products-v1', // product cache (pre-v2 key, still cleared)
'munchable-products-v2',
'hints:sent', // legacy: no longer written
'taxonomy:overlay',
'taxonomy:meta',
]);
Seven keys, two of which nothing writes any more and both of which are still cleared, because an install that has been through two upgrades may still hold them. On a server, a deleted row is gone. On a device, "delete everything" is a hand-maintained list, and the only thing standing between it and a stale entry is remembering to add the key at the same time as the feature.
The legal consequence nobody plans for
One paragraph of the privacy notice exists purely because of where the computation happens:
Verdicts are produced by a deterministic rules engine on your device, from rules we have written down, and not by a model forming a view about you. There is no profiling of you, no scoring of you, and no decision with a legal or similarly significant effect being made about you automatically, so the additional rights in Article 22 of the UK GDPR do not arise.
An engineering decision made for one reason turned out to settle a compliance question that was never the motivation. It also means the honest answer to "what do you do with my health data" is a description of the architecture rather than a list of promises, and an architecture is harder to quietly change than a policy is.
What is still wrong with this
- The quota is client-trusted. Stated above, accepted, and worth saying twice because it is a real revenue hole and not a theoretical one.
- You cannot get your history back. No sync, by construction. New phone, no history. The notice says it out loud ("we cannot recover it for you if you lose your device") and that does not make it pleasant for somebody who has been logging symptoms for six months.
- The wipe list is maintained by hand. Nothing fails if a new feature writes a key and forgets to register it. The test that would catch it would have to enumerate storage keys, which nothing does.
- The server counter degrades silently. No Redis means no scan counts, and the code says a scan that could not be counted is still a scan. That is the right call for the user and it means the data that decides what gets improved next can quietly thin out.
- Processors still see the edges. The privacy notice names seven of them, each holding what its job requires: the hosting, the authentication and database, the rate limiting counters, the payment, the transactional email, the app build service, and the model that reads the text off a label photograph. None of them receives a condition, and "none" is a claim that holds only as long as nobody attaches a helpful diagnostic field to a request that already exists.
Read the notice itself
All of the above is written out for a non-technical reader in Munchable's privacy notice, which is worth a look if only as an example of what a privacy page can say when the architecture lets it be specific: the short version at the top, "What a barcode lookup reveals" for the counter, "Automated decisions" for the Article 22 paragraph, and the rights section for the two self-service buttons. The companion pages are the terms and the about page, which says the same thing in one paragraph.
The public pages that exist precisely because the app cannot phone home are the condition guides and the answers index: somebody who wants to know what Munchable checks, without installing anything or telling anybody what is wrong with them, reads those.
Related: our cookie policy names four cookies and three storage keys, and I counted what a browser really holds after one visit, which is the web half of the same audit, and the permission dialog is product copy, and app.json is where a privacy promise becomes enforceable.
來源:DEV Community · dev.to