Notifio is a desktop app that watches rental search pages and emails you the moment a new listing appears. It is sold as a one-time licence, so the desktop app holds an email and an activation code, and notifio.app holds the row that says whether that pair is real.
Which means the app has to ask. And the interesting part of asking is not the happy path, it is that there are two different ways to not get a yes, and treating them as one thing is how you end up switching off something a customer has already paid for.
Three answers, not two
The client never returns a boolean:
/** * Ask the server directly. Returns null if the server could not be reached, so * callers can distinguish "not upgraded" from "we don't know". */ export async function fetchFromServer( email: string, token: string ): Promise<ValidateResult | null> { null is "we do not know". And the caller turns that into a third state by hand rather than collapsing it:
export async function checkLicense(email: string, token: string): Promise<LicenseCheck> { const result = await fetchFromServer(email, token); if (result === null) return { ok: false, reason: 'unreachable' }; if (result.valid !== true) return { ok: false, reason: 'rejected' }; return { ok: true, plan: result.plan }; } rejected stops the monitor and says so in the window. unreachable does not:
/** * Confirm the licence when due. Returns false only when monitoring must stop. * * Being unable to reach the server is not a reason to stop. The licence is * checked again on every alert by the server that sends it, so monitoring on * with an unverified licence cannot get anyone alerts they have not paid for; * it just means a laptop that woke up before its wifi did keeps looking for * rooms instead of shutting down and blaming the user's licence. */ Two constants set the rhythm: a confirmed licence is trusted for six hours, and an unreachable server is retried in five minutes while monitoring carries on with a warning notice in the window.
const LICENSE_TRUST_MS = 6 * 60 * 60 * 1000; const LICENSE_RETRY_MS = 5 * 60 * 1000; The reason this is safe has nothing to do with the client being careful. Every alert email is sent by POST /api/notify on the server, which checks the licence itself before it sends anything. The client-side cache cannot authorise a single email. It can only decide whether the app keeps looking at pages on your own machine, which is not a thing that costs us anything. Put the enforcement where the money is spent and the offline behaviour stops being a security question and becomes a politeness question.
The 429 that used to mean "invalid"
Here is the branch that handles a response that arrived but was not a yes, comment and 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 }; } The trap is in the server's own response body. This is what the rate limiter returns:
const { success } = await ratelimit.limit(ip); if (!success) { return NextResponse.json( { valid: false, error: "Too many requests." }, { status: 429 } ); } valid: false. A client that parses the body and trusts the field has just been told, in its own vocabulary, that the licence is not valid, by a server that never looked at the licence. The status code is the only part of that response carrying the truth, and the body actively contradicts it.
That is not a hypothetical shape of bug for this product. The rate limit is per IP, and the people this app is for are frequently several to a building: a student house is one IP address, which is why the alert endpoint's limiter counts licences rather than addresses. Four flatmates all running Notifio and all restarting it after a wifi drop is the exact traffic pattern that trips a per-IP limiter, and the old behaviour turned that into four windows saying the licence was not recognised.
The same principle is written out one layer up, in the activation route in the app's own embedded server:
// Validate against server. A licence is only wrong if the server says so: // telling someone to check their activation email because their wifi was // down sends them looking for a problem that is not there. const check = await monitor.checkLicense(normalEmail, normalToken); if (!check.ok && check.reason === 'unreachable') { return res.status(503).json({ error: 'Could not reach notifio.app to check that licence. Check your connection and try again.', }); } Two sentences in two places, and the support mail you avoid is the one where somebody has read their activation email four times looking for a typo that was never there.
What the cache is allowed to keep
The desktop app has one paid add-on, an auto-reply upgrade, and that is a second one-time purchase on the same licence. Its entitlement is cached on disk:
/** * Auto-reply entitlement: whether this licence has been upgraded. * * The server is the authority (`POST /api/validate` returns a boolean). We * cache the answer on disk so a flaky connection or an offline laptop does not * switch the feature off mid-search. */ Because the upgrade is a one-time purchase it never expires, so there is no period end to enforce and no reason to stop trusting a cached yes. The only thing that takes it away is deactivating the licence, which clears the cache locally anyway.
A subscription would need an expiry here, and that expiry would be the thing that fails on a plane. A one-time purchase has no such date, so the cache is refreshed opportunistically every six hours and otherwise simply believed. When the server is unreachable the cached answer comes back flagged rather than fabricated, because a purchase already made does not lapse just because the app could not phone home:
if (cached) return { ...cached, stale: true }; return { valid: false, autoReply: false, stale: true }; Three smaller decisions sit around that:
The read fails closed. Anything in the file that is not literally true becomes false:
_memory = { ...parsed, autoReply: parsed.autoReply === true }; The hot path never awaits. The reply engine checks the gate before every reply, so it reads the cache synchronously and accepts that it may be up to six hours out of date:
/** * Synchronous best-effort read, for hot paths that must not await a fetch. * This is the gate the reply engine checks before every reply. */ export function autoReplyUnlocked(): boolean { return readCache()?.autoReply === true; } Deactivating clears it. Otherwise a different licence activated on the same laptop would inherit the previous one's upgrade:
// Drop the cached upgrade state too, so a different licence activated on // this machine can't inherit the previous one's entitlement. entitlement.clearCache(); And the file itself is plain JSON in the user's own data directory, which the module says out loud:
* 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. I have argued that case at length before, and the thing I would add here is that the honesty is load-bearing rather than decorative. Once you accept that the cache is forgeable, you stop designing around it, and you put the only check that matters at the point where the server spends money on your behalf.
One more thing the validate call carries
The same response hands the app a price:
/** * The upgrade price in the user's currency, as the server will charge it. The * server decides the currency from the request's country, which the app cannot * see, so the app shows `label` and sends `currency` back with the checkout * request; the server refuses to charge a currency other than the one shown. */ export interface UpgradePrice { currency: string; label: string; } The app cannot work out which currency it should quote, because the thing that knows is the IP address of the request, and that is visible to the server and not to the laptop. So the server resolves it, the app displays the string it was given, and sends the currency code back at checkout so the two cannot disagree. That round trip has its own post, including what happens when the app has no string to show.
upgradePrice is also the one optional field on the interface, with a comment explaining why:
/** Absent from servers and caches that predate local prices. */ upgradePrice?: UpgradePrice; An on-disk cache written by an older version of the app is a schema you have to keep reading. The optional marker is the migration.
If you want to see the shape of this from the buyer's side: the pricing page is the one-time licence, the activation code arrives by email and goes into the app from the download page, and what happens if you lose it is answered on the help page. The per-site setup guides are at notifio.app/alerts, including Kamernet and Idealista.
