@flarecart/licensing
v1.0.0
Published
Licence-key seats for software sellers: validate a key (and one installation of it) with a reason a client can show, claim a seat race-free so parallel activations can't overrun the limit, and release it by an instance id the server issued. In-memory and
Maintainers
Readme
@flarecart/licensing
Licence-key seats for software sellers: validate a key (and one installation of it) with a reason a client can show, claim a seat race-free so parallel activations can't overrun the limit, and release it by an instance id the server issued. Storage behind one interface, with an in-memory store and a Cloudflare D1 store included.
Why
A licence server looks like three endpoints and a counter. The holes are all in the details:
- A key for somebody else's product. A key is a string anybody can buy, from any seller, in test mode, for nothing. Unless the check names the product, a key bought for one thing unlocks another.
- A client-chosen instance id. Clients name themselves by site URL or machine name, which are guessable, and a guessable id lets anybody release somebody else's seat.
- A race on the last seat. Count, then insert, as two statements: ten parallel activations of a one-seat key each count zero and each take one. On D1 there's no transaction to put them in.
- Checking only the key. A key that's fine but whose seat was released on another machine is exactly what a client checking only the key never sees.
- A bool instead of a reason. "Invalid" makes the seller's support team find out what happened; "expired" lets their software say "renew here".
Features
- ✅
validate(): the key, optionally one installation, optionally the product; precedence not found → revoked → expired → installation. - 💺
claim(): race-free without a transaction (one conditional statement); instance ids issued here (128 random bits); a fingerprint only as an idempotency hint, so a reinstall gets its seat back. - 🔓
release(): by the issued instance id only, scoped to the key; the seat is kept, inactive, so a key's history stays visible. - 🗣️ Verdicts with words: stable wire values, a sentence for each refusal, and
changedso check-ins on startup aren't announced. - 🗄️ Bring your own storage, or use the in-memory store or the Cloudflare D1 store (schema included, your own columns welcome).
- 🔑 Readable keys: 150 bits of Crockford base32, read back however they were typed.
Install
npm install @flarecart/licensingNode 22+, Cloudflare Workers, Deno and Bun: anything with Web Crypto. No dependencies.
Usage
Create the tables in a migration, with your own columns after them:
import { d1Schema } from '@flarecart/licensing';
console.log(d1Schema());
// then, in the same migration:
// ALTER TABLE licenses ADD COLUMN order_id TEXT;
// ALTER TABLE licenses ADD COLUMN subscription_id TEXT;Issue a licence when an order is paid:
import { d1Store } from '@flarecart/licensing';
const store = d1Store(env.DB, { ip: (address) => hashIp(address) });
const license = await store.issue({
product: 'prod_ABC', // what it unlocks
seats: 3, // installations at once
expiresAt: null, // or a subscription's period end (unix ms)
extra: { order_id: 'ord_9f2c…' }, // your columns
});
license.key; // '7KQ2M-9XWPA-H3D8F-TV0RC-Z4N6B-E1JYS': show it to the buyerAnswer the seller's software:
import { createSeats } from '@flarecart/licensing';
const seats = createSeats({ store, requireProduct: true });
const taken = await seats.claim(key, { name: 'Studio Mac', fingerprint, ip, product: 'prod_ABC' });
taken.ok; // true
taken.reason; // 'activated', 'already_active', 'limit_reached'…
taken.seat.instance; // the client keeps this
taken.changed; // announce only when true
taken.message; // 'This licence key has reached its activation limit…' when refused
const check = await seats.validate(key, { instance, product: 'prod_ABC' });
check.valid; // false
check.verdict; // 'instance_deactivated'
await seats.release(key, instance); // the seat as it was, or nullRun them:
await store.update(license.id, { revoked: true }); // refunded
await store.update(license.id, { expiresAt: periodEnd }); // a subscription renewed
await store.releaseAll(license.id); // "reset activations"
await store.seats(license.id); // every installation, newest firstAPI
createSeats({ store, now?, requireProduct? })
Returns { validate, claim, release }.
| | |
|---|---|
| validate(key, { instance?, product? }) | A Check: { valid, verdict, message, license, inUse, seat }. Changes nothing. |
| claim(key, { name?, fingerprint?, ip?, product? }) | An Activation: { ok, reason, claim, verdict, message, changed, inUse, seats, seat }. |
| release(key, instance) | The Seat as it was, or null when nothing was released. |
Keys are passed through normaliseKey() first. requireProduct turns a check without a product into product_required: on for a public API, off for your own screens.
Verdicts and claims
| Verdict | |
|---|---|
| ok | |
| not_found | No such key, or a key for another product. |
| revoked | |
| expired | Past expiresAt (it still works at its last millisecond). |
| instance_not_found | That installation isn't on this licence. |
| instance_deactivated | It was, and was released. |
| limit_reached | No seat free. |
| product_required | requireProduct is on and none was sent. |
| unavailable | For an app to send when licensing is switched off. |
Claims: activated, reactivated (a released seat with the same fingerprint, taken again), already_active (nothing changed). verdictMessage(verdict) and claimChanged(claim) are exported for apps that build their own answers.
Stores
memoryStore({ ip?, now? }) and d1Store(db, { ip?, now?, licensesTable?, seatsTable? }) both implement:
SeatStore, what the rules need:license(key),seatsInUse(license),seat(license, instance),seatForFingerprint(license, fingerprint),addSeat(license, newSeat),reactivate(license, seat, name),release(license, instance).- Running licences:
issue(input),update(id, { revoked?, expiresAt?, seats? }),seats(licenseId),releaseAll(licenseId).
ip is what to keep of a client's address (a keyed hash, say); by default nothing is kept. d1Schema(options?) returns the CREATE TABLE SQL.
Writing your own store: addSeat and reactivate must take a seat only while fewer than license.seats are active, in one atomic step, and addSeat must refuse a second seat for a fingerprint the licence already has. On SQL that's one conditional statement:
INSERT OR IGNORE INTO license_seats (license_id, instance, …)
SELECT ?, ?, …
WHERE (SELECT count(*) FROM license_seats WHERE license_id = ? AND active = 1) < ?with a unique index on (license_id, fingerprint), and changes saying whether it took. Seats must be scoped to their licence, or one key could release another's.
Keys
| | |
|---|---|
| newLicenseKey() | 30 Crockford base32 characters (150 random bits) in six groups: 7KQ2M-9XWPA-H3D8F-TV0RC-Z4N6B-E1JYS. |
| normaliseKey(input) | Case, spaces, dashes and O / I / L forgiven; a key from another system comes back trimmed and upper-cased. |
| newInstanceId() | 128 random bits, hex. |
| newLicenseId() | lic_ and 64 random bits, hex. |
| KEY_ALPHABET | 0123456789ABCDEFGHJKMNPQRSTVWXYZ |
Notes and limits
- Keys are stored as issued, not hashed. A buyer needs to see their key again (on an account page, in a "resend" email), and a hash can't be shown. A key only ever unlocks seats of one licence, which a seller can revoke; keep the table as private as your orders.
- A key for another product reads as
not_found, not as something of its own: asking with your product reveals nothing about keys that aren't yours. - Fingerprints are hints, never authority. They're guessable, so they only give a reinstall its seat back. Releasing takes the issued instance id.
- Expiry is the app's to resolve. A licence that follows a subscription keeps its period end in
expiresAt; move it withupdate()when the subscription renews or ends. - Your columns: add them to the licences table in your migration, pass them to
issue()asextra, and read them back fromlicense.record. Names are checked as plain identifiers before they reach SQL. - History is kept. A released seat stays, inactive: "this key has moved between forty machines" is what separates somebody with several laptops from a key posted on a forum.
The JavaScript twin of SugarCommerce's PHP licensing. The difference is the store: the PHP one runs the claim in a transaction, which D1 can't hold open for a Worker, so this one takes a seat in a single conditional statement.
Testing
npm test67 tests: the PHP twin's suite against both stores (product scoping, every verdict and their precedence, expiry to the millisecond, an installation released elsewhere, another key's instance, issued instance ids, the last seat, fingerprints as idempotency, bad keys claiming nothing, release by instance only and only once, stable wire values), the races (ten activations of a one-seat key at once, one client activating twice at once, a reactivation and a new activation sharing the last seat), running licences, keys, and the D1 store's schema, table names and your own columns, on real SQLite.
License
MIT
