Notifio is a one-time purchase: you pay once at notifio.app/pricing, you get an activation token, and the licence never expires. There is an optional paid upgrade on top that unlocks the auto-reply feature.
Sometimes a licence needs to exist without a payment behind it. A beta tester, somebody who sent a good bug report, a friend, press. There is no admin dashboard for this, and there will not be one, because the entire volume of it is a handful of grants a month. There is a script:
pnpm grant someone@example.com
The whole design problem in a script like that is not the database write. It is that you will run it twice. You will run it, watch the email fail, and not know whether the licence was created. You will grant the base app and be asked a week later to add the upgrade. You will fat-finger an address, fix it, and run it again. So the property the script is built around is that running it twice is safe, and this post is about the five places that property had to be defended.
I wrote about the heavier version of this problem in an internal tool that edits entitlements has to write two records, not one. That one is a UI on a bigger product with an audit trail. This is the other end of the scale: 170 lines, no UI, no audit table, and the interface is a doc comment.
/**
* Grants a free Notifio licence, the app plus, by default, the AI auto-reply
* upgrade, and emails the recipient their activation token.
*
* Usage (from server/):
* pnpm grant <email> [flags]
*
* Flags:
* --base-only Grant the app only; leave auto-reply locked.
* --no-email Write the licence but don't send anything. Prints the token.
* --dry-run Show what would happen. No writes, no email.
* --site <url> Base URL used for the download link (default https://notifio.app).
*
* Safe to re-run for the same address: licences are keyed on email, so a repeat
* run tops up whatever is missing (reactivating, adding auto-reply) and reuses
* the existing token rather than issuing a second one. That matters because the
* token is what the recipient has already saved.
*/
1. The table has no idea free licences exist
export const licenses = pgTable("licenses", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
token: text("token").notNull().unique(),
stripeSessionId: text("stripe_session_id").notNull().unique(),
active: boolean("active").notNull().default(true),
autoReply: boolean("auto_reply").notNull().default(false),
autoReplySessionId: text("auto_reply_session_id").unique(),
...
});
stripe_session_id is NOT NULL and UNIQUE, because every licence that exists was created by a Stripe webhook and the uniqueness is what makes a webhook retry harmless. That is the right schema for the path that produces 100% of real licences. It also means a licence with no payment behind it cannot be inserted honestly.
The options were to relax the column to nullable, or to write something into it. I wrote something into it:
// NOT NULL and UNIQUE, but nothing was ever paid. A synthetic id keeps
// the constraint satisfied and makes comped licences obvious in the DB.
stripeSessionId: `free-grant_${createId()}`,
Relaxing the column would have cost more than it looks. NOT NULL on that column is the thing that guarantees every row in the table can be traced back to a payment event, and a nullable column would put that guarantee in application code instead, where it degrades. A synthetic value keeps the constraint doing its job and makes the exception greppable: where stripe_session_id like 'free-grant_%' is the list of every licence I have given away, which is a query I have actually wanted.
The general shape: when your schema models the paying path and an ops tool needs an exception, prefer a legible synthetic value over weakening the constraint. Make the lie searchable rather than making the truth optional.
2. The token belongs to the recipient now
The first version of this script generated a token unconditionally. That is correct exactly once. On a second run it would issue a new token, write it over the old one, and silently invalidate whatever the recipient had already pasted into the app or saved in their password manager.
if (existing) {
token = existing.token;
console.log(`• Existing licence found for ${opts.email} (id ${existing.id})`);
Reuse, never regenerate. The token is not the script's data, it is the recipient's. This is the single most important line in the file and it is an assignment.
3. A comp must not overwrite a purchase
This is the subtle one. The auto-reply upgrade is recorded by a session id, and that column is UNIQUE so a webhook retry cannot be read as a second purchase. Suppose somebody buys the upgrade, and later I grant them free access for an unrelated reason. Topping up their licence must not stamp a free-grant_ id over the record of money they actually paid:
if (opts.autoReply && !existing.autoReply) {
patch.autoReply = true;
patch.autoReplyPurchasedAt = new Date();
// Only stamp a session id if the column is free. It's UNIQUE, and a real
// Stripe id already there means they paid for the upgrade, don't clobber
// the record of that.
if (!existing.autoReplySessionId) {
patch.autoReplySessionId = `free-grant_${createId()}`;
}
changes.push("unlocked auto-reply");
}
Idempotence gets taught as "running it twice has the same effect as running it once". That is not quite the requirement here. The requirement is that running it twice never destroys information the first run did not create. Those come apart precisely where an ops tool touches a field that another system also writes.
4. Say which of the two steps happened
The script does two things that can fail independently: it writes a licence, then it sends an email. The write comes first, which is the right order, because a licence with no email is recoverable by a human and an email promising a licence that does not exist is not.
Which makes the failure message the interesting part:
if (error) {
// The licence is already granted at this point, so this is recoverable:
// re-run with --no-email and pass the token on manually.
console.error("✗ Email failed to send:", error);
console.error(` The licence IS granted. Token: ${token}`);
process.exitCode = 1;
}
Two details. It restates the token on the error path, so the transcript in my terminal contains everything needed to finish the job by hand. And it sets process.exitCode rather than calling process.exit, so the pending stdout actually flushes before the process leaves; process.exit in the middle of a console write is a good way to lose the one line you needed.
Every multi-step ops script should answer "which step got there" in its failure output. Mine tells me, in the same breath, that the thing I was worried about already succeeded.
5. Refuse before you connect
All of the validation happens before a database client exists:
if (!opts.email) die("Usage: grant-free-access.mjs <email> [--base-only] [--no-email] [--dry-run]");
opts.email = opts.email.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(opts.email)) die(`Not a valid email: ${opts.email}`);
if (!process.env.DATABASE_URL) {
die("DATABASE_URL is not set. Run via `pnpm grant` or `node --env-file=.env …`.");
}
if (opts.sendEmail && !opts.dryRun && !process.env.RESEND_API_KEY) {
die("RESEND_API_KEY is not set. Run via `pnpm grant`, or pass --no-email.");
}
The second check is conditional on the flags, which is the small thing that makes the tool usable. --no-email and --dry-run both work with no Resend key at all, so I can test the write path against a local database without any email credentials in the environment. An unconditional env check would have forced a fake API key into .env just to be allowed to do a dry run, and a fake key in .env is a thing that eventually gets used.
Lowercasing the address matters more than it looks, too: the email column is UNIQUE, so Someone@example.com and someone@example.com would be two licences for one person, and the second one would fail the token uniqueness check in a way that reads as a bug.
Also note --dry-run and --no-email are separate flags rather than one --safe. They answer different questions. --dry-run is "tell me what you would do". --no-email is "do it, but I will deliver the token myself", which is what I use when the recipient is in front of me.
The email had to be a different email
There was already a perfectly good activation email, sent by the Stripe webhook. Reusing it was the obvious move and the wrong one:
/**
* Email for a licence granted for free (press, beta testers, friends of the
* project) rather than bought.
*
* Deliberately not the activation email: that one opens with "thanks for
* purchasing", which is wrong for someone who was never charged. It also states
* outright that auto-reply is included, because a granted licence normally skips
* the upgrade purchase that would otherwise be how you learn you have it.
*/
The second sentence is the one I would have missed. On the paid path, you know you have the auto-reply upgrade because you bought it. On the granted path there is no purchase to remember, so the email has to list what is included or the person never finds out the feature is unlocked. The template takes the flag and builds the list from it, so the email cannot promise a feature the database did not grant.
The token itself is built to be read off a screen and typed into a desktop app by a human:
/**
* Generates a human-readable activation token: NTFIO-XXXX-XXXX-XXXX
* where X is an uppercase alphanumeric character (no ambiguous chars like O/0, I/1).
*/
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
No O, no 0, no I, no 1. It is a 32-character alphabet across twelve characters, so about 60 bits, which is plenty for a value that is looked up by an exact match on a unique column and is not a password. The support cost of somebody mistaking O for 0 is much higher than the marginal entropy of including both.
What the script actually prints
• Existing licence found for someone@example.com (id lic_2f9c…)
✓ Updated: unlocked auto-reply
• Token: NTFIO-K7QM-3XTP-9BWZ
✓ Email sent to someone@example.com (id 4f2e…)
Four lines, in the order the work happened, with the token in the middle whether or not the email worked. The recipient then goes to notifio.app/download, enters their address and that token, and is running with a lifetime licence. If anything about that goes wrong, the steps are written out in notifio.app/help.
The reason I am fond of this file is that it has no tests, no audit table and no UI, and it is still hard to hurt anybody with. Not because it is clever, but because every branch was written by asking "what happens when I run this again in five minutes, having forgotten what the first run did". That question is cheaper than a test suite and it catches the failures that actually happen to internal tools.
Top comments (0)