Notifio is a desktop app with a one time licence, plus a one time upgrade that unlocks auto reply. The desktop app therefore has to ask a server a question it cannot answer itself: is this licence real, and has this extra been bought.
The bug worth writing about is that the first version of that check could only produce two answers, and the question has three.
Two answers where three were needed
The client called POST /api/validate and treated the call the way you treat most fetches: got a good response, read the body; got anything else, treat as a failure, and a failed licence check meant an invalid licence.
The endpoint is rate limited. So it can return 429. And a 429 means the server never looked your licence up at all.
if (!res.ok) {
// Only a considered "no" from the server counts as one.
//
// A 429 used to land here and be recorded as an invalid licence, which
// it plainly is not: the endpoint is rate limited per IP, so a burst of
// checks (or a shared office address) could switch someone's working
// licence off. Anything that is not the server having looked the licence
// up and refused is "we do not know".
if (res.status >= 500 || res.status === 408 || res.status === 429) return null;
return { valid: false, autoReply: false };
}
null is the third answer. Not valid, not invalid, unknown. Three states, three behaviours: valid unlocks, invalid locks, unknown changes nothing and leaves the last known answer standing.
Notice which codes are in that list. 500 and above, because the server fell over. 408 and 429, because the request never reached the lookup. A 400 or a 404 is not there, because those mean the server did read the request and did decide. The dividing line is not "is this an error", it is "did a considered decision happen".
The same class of mistake had already bitten the other side of this endpoint, where the per IP limit counted a whole shared student house as one user: A student house is one IP address, so our rate limit counts licences. The limiter was the cause both times. The client fix is the one above, and it matters independently: any rate limited endpoint will eventually 429 a legitimate caller, and a client that reads that as a denial has built a self inflicted outage.
A cache that survives being offline
The answer is cached on disk and refreshed every six hours:
const cached = readCache();
const age = cached ? Date.now() - new Date(cached.fetchedAt).getTime() : Infinity;
if (!options.force && cached && age < REFRESH_AFTER_MS) {
return { ...cached, stale: false };
}
const fresh = await fetchFromServer(email, token);
if (fresh) return { ...fresh, stale: false };
// Server unreachable: a purchase already made does not lapse because we
// could not phone home.
if (cached) return { ...cached, stale: true };
return { valid: false, autoReply: false, stale: true };
The stale flag is the part I would keep in any rewrite. It does not change what the app does, it changes what the app is able to say. The UI can show the feature working and note that the last confirmation is a few hours old, rather than pretending to certainty it does not have or hiding the situation entirely.
The upgrade being a one time purchase makes this easy in a way a subscription would not. There is no period end, so there is no date after which a cached "yes" becomes a guess. The only thing that revokes it is deactivating the licence, which clears the file locally as part of the same action.
Two smaller details in the same file. Reads fail closed on anything that is not literally true:
// Anything other than an explicit `true` fails closed.
_memory = { ...parsed, autoReply: parsed.autoReply === true };
And writes go through a temp file and a rename, so a crash mid write cannot leave a truncated JSON file that reads as no entitlement at all.
Saying out loud that it is not DRM
The header comment on the module is the part I am most glad I wrote:
/**
* Note this is a speed bump, not DRM. A local Electron app cannot keep a secret
* from its owner, and this file is plain JSON. Anything that must not be forged
* has to be enforced server-side.
*/
The entitlement cache is a plain JSON file in the app's data directory. Someone can open it and flip a boolean. Obfuscating it would buy an afternoon of someone else's time and cost me a class of support problem I cannot debug, because every honest bug in a hidden file looks like tampering.
What the comment does is draw the line for the next feature. The local cache decides what the UI offers. Anything that actually costs money or touches a real account is decided on the server, where the licence row lives: the checkout session, the Stripe webhook that grants the upgrade, and the resolve step behind this endpoint. A forged local file gets you a button that does not work.
There is one more line in this file that follows from the same instinct:
/**
* Start a Stripe Checkout session for the one-time auto-reply upgrade. Returns
* the URL to open in the user's real browser (never in an app window, so they
* can see the address bar and Stripe's own domain).
*/
An Electron BrowserWindow showing a payment page is indistinguishable, to the user, from an Electron window showing anything else. Handing the URL to the system browser gives them an address bar, their own password manager and a domain they can check. It costs one line and removes the only part of the purchase flow where a user would be right to be nervous.
The upgrade is described at notifio.app/pricing, what it does is in notifio.app/help, and the app itself is at notifio.app/download.
Top comments (0)