@classytic/arc-license
v0.1.1
Published
Offline-first Ed25519 licensing for @classytic/arc apps — signed license tokens, derived-state enforcement (entitlements, seat/branch caps, grace + read-only degradation), a hot-swappable activation surface, and a vendor-side issuer CLI. Vendor-agnostic:
Readme
@classytic/arc-license
Offline-first Ed25519 licensing for
@classytic/arcapps — signed license tokens, derived-state enforcement, hot-swappable activation, and a vendor-side issuer CLI.
Vendor-agnostic: the host supplies the public keyring, so any product built on arc (Spine, a hotel vertical, a third-party app) can license their deployment with it.
How it works
- A license is a signed token:
LICv1.<base64url payload>.<base64url Ed25519 sig>. Verification needs only the vendor's public key (safe to embed) — apps boot and enforce fully offline. Only the vendor's private key can mint. - Expiry is state, not invalidity. Verification checks integrity + product binding;
the lifecycle (
active → warn → grace → restricted) is derived from the clock against precomputed thresholds in a frozen snapshot. No background job has to flip states; nothing drifts if a cron misses. - Enforcement is two comparisons. One global write guard (installed once) checks
Date.now() > restrictAfteron writes. Past grace → writes get402 LICENSE_RESTRICTED; reads always pass (read-only degradation — data is never hostage). - Activation is an atomic ref swap. Paste a renewed token → verify → persist → swap. No restart, no hook churn; in-flight requests finish under the old snapshot.
Host usage
import { createLicenseModule, createRepositoryLicenseStore } from "@classytic/arc-license";
const licenseModule = createLicenseModule({
keyring: [{ kid: "spine-2026", publicKeyPem }], // vendor public key(s)
product: "spine",
store: createRepositoryLicenseStore(licenseTokenRepository), // any repo-core kit repo
seedToken: process.env.SPINE_LICENSE_KEY, // first-boot bootstrap
onMissing: "open", // explicit: "open" (dev) | "restricted" (shipped)
permissions: { view: requireAuth(), activate: requireRoles("admin") },
guard: { exemptPrefixes: ["/health", "/api/auth"] },
});
createApp({ modules: [licenseModule, ...] });
// fastify.arc.modules.license → { status, entitled, assertEntitled, checkBranchLimit, activate }Routes: GET /license/status, POST /license/activate { token }.
Gating module composition (before boot — modules compose before any bootstrap):
import { verifyLicenseToken } from "@classytic/arc-license";
const v = verifyLicenseToken(token, keyring, { product: "spine" });
const entitled = new Set(v.ok ? v.payload.entitlements : []);
if (entitled.has("bd-tax")) modules.push(bdTaxModule);Seat/branch cap — call at the single place branches are created:
const { allowed, max } = license.checkBranchLimit(activeBranchCount);
if (!allowed) throw new Error(`License allows ${max} branches`);Renewal cron (host schedules; enforcement never depends on it):
import { createRevalidateHandler } from "@classytic/arc-license";
cron.daily(createRevalidateHandler(license, { onPhase: logPhase /*, refresh: fetchRenewal */ }));Vendor usage (issuing)
arc-license keygen --kid spine-2026 --out ./keys --passphrase <strong>
arc-license issue --key keys/spine-2026.private.pem --passphrase <strong> \
--kid spine-2026 --lic-id lic_ACME_001 --customer "ACME Corp" --product spine \
--entitlements bd-tax,multi-branch --max-branches 5 --months 12
arc-license verify <token> --pub keys/spine-2026.public.pem --product spineOr programmatically via @classytic/arc-license/issuer. The private key is the only
secret: keep it passphrase-encrypted and out of every repo. Rotate by minting a new
kid and appending its public key to shipped keyrings — old licenses keep verifying.
Issuing console (optional UI)
Prefer point-and-click over the CLI? examples/license-console.mjs
is a zero-dependency local web console (issue form + verify + an issued-licenses table
with active/expiring/expired status). It is a vendor back-office tool — it reads your
private key to sign, so it binds to 127.0.0.1 only and is deliberately not part of
the published package (it never lands in a customer's node_modules). Copy it into your
private ops folder, edit the PRODUCTS/KEYS_DIR config at the top, and:
node license-console.mjs # → http://127.0.0.1:4747License
MIT © Classytic
