Cool BeansCool Beans
Sign inStart free

TypeScript SDK

One open() call for Node, Electron, Tauri and the browser, with offline verification built in.

Licence checks for Node, Electron, Tauri and the browser. No service secret in the client, the key is the credential. Zero dependencies; Ed25519 verification uses WebCrypto.

Installing

@coolbeans/sdk is not on npm yet (publishing is tracked in #123). Until it lands, don’t run npm i @coolbeans/sdk, it won’t resolve. Use it from the repo instead, either way works:

In a pnpm workspace, add the package as a workspace dependency:

{
  "dependencies": {
    "@coolbeans/sdk": "workspace:*"
  }
}

Or vendor it. Copy packages/sdk into your project and build it. It has zero runtime dependencies, so there’s nothing to bring along with it:

cd packages/sdk && pnpm build

The whole integration

import { CoolBeans } from '@coolbeans/sdk';

const cb = new CoolBeans({
  publicKeys: { '1': 'BASE64_PUBLIC_KEY' }, // bundle these in your app
  // product: 'clementine',  // required only if you sell more than one product, see below
});

// On launch, and again whenever the user pastes a key. This is the whole integration.
const state = await cb.open(licenseKey, {
  onChange: (next) => setLicensed(next.decision === 'allow'),
});
if (state.decision === 'deny') lockOut(state);

// On sign-out, to give the seat back
await cb.release();

// On shutdown
cb.stop();

Do you need product?

Only if you sell more than one product from one Cool Beans account. Without it the first licence an install activates binds the app to that product, and every later key is checked against it. But that first key is the one nobody checked, so a customer holding a licence for your other app could paste it into a fresh install and unlock this one. Pass the slug and a licence for anything else is refused outright.

A wrong slug in your build can’t lock anybody out, it just never unlocks: while the app is running, an answer about another product stays inconclusive.

The verdict

{ decision: 'allow', reason: 'online' | 'cached' | 'grace' | 'clock_rollback',
  license: LicenseObject | null, expiresAt: string | null }
{ decision: 'deny',  reason: 'revoked' | 'expired' | 'uninitialized',
  license: LicenseObject | null }

Branch on decision. Nothing else. reason is for what you say to the user: grace means nudge them online, uninitialized means ask for a key, revoked means the licence is gone.

It’s a union rather than a boolean on purpose. “We have never established an entitlement” and “you were revoked” both mean locked, but they’re different screens, and a boolean loses that.

license is the frozen §9 object, read off the cached token, so showing “Pro monthly, renews 12 Aug” costs no extra call. It’s display only, never gate a feature on plan or kind. expiresAt is the same value as license.expires_at, lifted out for convenience.

Gating features: state.entitlements

When a vendor prices capabilities, they arrive here, and this is the only thing to gate on:

if (state.entitlements?.export_4k) enableExport4k();
const batchLimit = Number(state.entitlements?.batch_limit ?? 1);

The field is absent when a licence has none, so keep the ?.. Values are booleans, numbers or strings, never nested, so Number(x ?? 1) and === true are safe.

entitlements is top-level, not inside license. It’s present on a deny too, since it comes off the same token, but gate on it only when the decision is allow, or you’re handing capabilities to somebody whose licence just ended.

These are server-authored and signed into the token, which is what makes them safe in client code. license.plan is a label a vendor types and license.kind is our lifecycle bookkeeping: both are display only. if (plan === 'Pro') breaks the day somebody renames a tier.

The capability names are the vendor’s own invention. There’s no catalog, and an absent name simply reads as off with nothing anywhere reporting it, so don’t guess one. Your product’s /v1/integration/<slug> brief lists the names its prices actually grant.

What open() does for you

Activates on first run, validates after that. No instance id to hold, no branch to get wrong.

Refreshes on its own, at a third of the token’s lifetime, jittered so every install of your app doesn’t wake on the same tick and stampede one server. A changed verdict arrives via onChange; it doesn’t fire while the answer stays the same, so it’s safe to re-render from it.

Holds a floating seat itself. It heartbeats once, reads the lease window off the response, and keeps to about a third of it, so one dropped request doesn’t cost the user their seat. A node-locked product returns no lease and nothing more is scheduled. Your app doesn’t know or care which kind it is.

Never locks out on an inconclusive answer. Offline, a 5xx, a timeout, an unknown key: all of it keeps the last known-good state. Only a fetched disabled or a signed expiry in the past denies.

Cannot be extended by moving the clock back. A wall-clock floor is persisted alongside the token and expiry is judged against it. A successful validation resets the floor, so a briefly wrong clock isn’t a life sentence.

Doesn’t keep a CLI alive. The background timers are unref’d, so a tool that opens, prints and exits, exits.

Anti-patterns

Don’t check on every feature use or window focus. That’s what the cached token is for, and it turns a momentary network blip into visible flakiness.

Don’t treat a failed check as a reason to do anything abrupt. A failure is the inconclusive case. open() already resolves it to the last good state, and nothing in the background throws into your app.

Don’t reach for verify / verifyOffline first. They still work, and open() is built from them, but every lockout bug we’ve seen came from an app wiring those two together itself.

Don’t build a seat policy. Seats are enforced on the server and are deliberately not on the verdict: running out reaches you as a deny you already handle. Capabilities are the one thing that varies per licence, and they come from state.entitlements.

Storage

Required outside the browser, and the SDK throws at construction without it. In-memory storage mints a new device id every launch, so every launch takes another activation and a node-locked licence is spent in a handful of restarts, on a customer who paid. The browser gets localStorage.

const cb = new CoolBeans({ product: 'clementine', storage: myFileBackedStore });

Two synchronous methods, getItem(key) and setItem(key, value). Back it with a file in the user’s profile, electron-store, the Tauri store plugin, the Keychain, anything that survives a restart. allowEphemeralStorage: true opts out, for tests and throwaway scripts only.

A file-backed store for Node, Electron or Tauri is about ten lines:

import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';

function fileStorage(path: string) {
  mkdirSync(dirname(path), { recursive: true });
  const read = (): Record<string, string> => {
    try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return {}; }
  };
  return {
    getItem: (k: string) => read()[k] ?? null,
    setItem: (k: string, v: string) => {
      writeFileSync(path, JSON.stringify({ ...read(), [k]: v }), { mode: 0o600 });
    },
  };
}

Offline behaviour

Three rules are worth knowing because they carry product decisions:

A network failure never locks anyone out. Grace past the token TTL is unbounded for paid tiers. That’s the offline-tolerant contract, and it’s why an app on a plane keeps working.

A signed expires_at in the past ends access, for every tier. The token states the licence ended, so honouring it is reading the credential rather than guessing from a failed request. The server issues that date with a buffer, so a subscriber who renews while offline has room to reconnect.

Trials get no grace at all, or blocking the endpoint would be an unlimited trial.

open() already applies all three. If you want to see them directly, offlineState() is network-free and returns valid (inside the token’s TTL), grace (past it, licence still good, and still unlocked) or expired. verifyOffline() is the boolean form. Neither is the path to build on. See Offline verification for the full model.

The surface

Four calls, and you’ll use two of them:

There are lower-level calls (verify, verifyOffline, offlineState, heartbeat, deactivate, fingerprint, instanceId). You don’t need them, and every lockout bug we’ve seen came from wiring them together by hand.

Public keys

publicKeys: {} is fine. The SDK fetches the signing keys by licence key on the first open() and caches them, and offline-before-that is uninitialized, never a wrong unlock. Bundling them just means the first offline check needs no network call. Keys fetched later are merged with the embedded ones, so a server-side rotation doesn’t need an app release.

Errors

Server errors throw CoolBeansError carrying the HTTP status and the machine-readable code, so you can branch on unknown_key versus license_disabled rather than parsing prose.