initial relay scaffold
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Keysat
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
# @keysat/licensing-client
|
||||
|
||||
TypeScript / JavaScript client for [`Keysat`](https://github.com/keysat-xyz/keysat) — a self-hosted Bitcoin-paid software licensing server that runs on Start9.
|
||||
|
||||
Works in modern browsers and Node 18+. No native dependencies; signature verification is done in pure JS via [`@noble/ed25519`](https://github.com/paulmillr/noble-ed25519).
|
||||
|
||||
## What you get
|
||||
|
||||
- **Offline verification**: check a license key with just the issuing server's public key. No network.
|
||||
- **Online validation**: live revocation check and fingerprint binding via the service's `/v1/validate` endpoint.
|
||||
- **Purchase flow**: kick off a BTCPay checkout and poll for the issued key.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @keysat/licensing-client
|
||||
```
|
||||
|
||||
## 5-line offline check
|
||||
|
||||
```ts
|
||||
import { Verifier, PublicKey } from '@keysat/licensing-client'
|
||||
|
||||
const verifier = new Verifier(PublicKey.fromPem(ISSUER_PUBKEY_PEM))
|
||||
const ok = verifier.verify(keyFromUser)
|
||||
console.log('licensed for product', ok.productId)
|
||||
```
|
||||
|
||||
That's the whole integration. Embed your public key as a string at build time (e.g. Vite's `?raw` import, webpack raw-loader, or just a `const`). If the verifier returns without throwing, the key is real and was issued by you.
|
||||
|
||||
## 10-line online check (with revocation + fingerprint)
|
||||
|
||||
```ts
|
||||
import { Client } from '@keysat/licensing-client'
|
||||
|
||||
const client = new Client('https://license.example.com')
|
||||
const result = await client.validate(keyFromUser, 'my-product', machineFingerprint)
|
||||
if (!result.ok) {
|
||||
console.error('rejected:', result.reason)
|
||||
process.exit(1)
|
||||
}
|
||||
```
|
||||
|
||||
The server enforces revocation live and does trust-on-first-use fingerprint binding, so the same key used from a second machine gets rejected.
|
||||
|
||||
## Purchase flow
|
||||
|
||||
```ts
|
||||
const session = await client.startPurchase('my-product')
|
||||
console.log('pay at:', session.checkoutUrl)
|
||||
const key = await client.waitForLicense(session.invoiceId)
|
||||
console.log('got license:', key)
|
||||
```
|
||||
|
||||
`waitForLicense` polls until the BTCPay invoice settles and the service issues a key. It throws if the invoice expires or becomes invalid.
|
||||
|
||||
## Browser usage
|
||||
|
||||
Everything here works in the browser too. Drop the library into your React/Svelte/Vue app and run offline verification client-side — no server call needed for the common case.
|
||||
|
||||
```ts
|
||||
// Vite: import the PEM as a raw string at build time
|
||||
import issuerPem from './issuer.pub?raw'
|
||||
import { Verifier, PublicKey } from '@keysat/licensing-client'
|
||||
|
||||
const verifier = new Verifier(PublicKey.fromPem(issuerPem))
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
+594
@@ -0,0 +1,594 @@
|
||||
"use strict";
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
|
||||
// src/index.ts
|
||||
var index_exports = {};
|
||||
__export(index_exports, {
|
||||
Client: () => Client,
|
||||
FLAG_FINGERPRINT_BOUND: () => FLAG_FINGERPRINT_BOUND,
|
||||
FLAG_TRIAL: () => FLAG_TRIAL,
|
||||
KEY_PREFIX: () => KEY_PREFIX,
|
||||
KEY_VERSION: () => KEY_VERSION,
|
||||
KEY_VERSION_V1: () => KEY_VERSION_V1,
|
||||
KEY_VERSION_V2: () => KEY_VERSION_V2,
|
||||
LicensingError: () => LicensingError,
|
||||
PublicKey: () => PublicKey,
|
||||
Verifier: () => Verifier,
|
||||
hasEntitlement: () => hasEntitlement,
|
||||
hashFingerprint: () => hashFingerprint,
|
||||
isExpiredAt: () => isExpiredAt,
|
||||
parseLicenseKey: () => parseLicenseKey
|
||||
});
|
||||
module.exports = __toCommonJS(index_exports);
|
||||
|
||||
// src/verify.ts
|
||||
var ed = __toESM(require("@noble/ed25519"), 1);
|
||||
var import_sha512 = require("@noble/hashes/sha512");
|
||||
|
||||
// src/errors.ts
|
||||
var LicensingError = class extends Error {
|
||||
/**
|
||||
* Machine-readable reason code. Common values:
|
||||
* `"bad_format"`, `"bad_encoding"`, `"bad_version"`, `"bad_signature"`,
|
||||
* `"expired"`, `"server_error"`, `"http_error"`, `"other"`.
|
||||
*/
|
||||
code;
|
||||
constructor(code, message) {
|
||||
super(message);
|
||||
this.name = "LicensingError";
|
||||
this.code = code;
|
||||
}
|
||||
};
|
||||
|
||||
// src/base32.ts
|
||||
var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
var DECODE_TABLE = (() => {
|
||||
const t = {};
|
||||
for (let i = 0; i < ALPHABET.length; i++) t[ALPHABET[i]] = i;
|
||||
return t;
|
||||
})();
|
||||
function decodeBase32NoPad(input) {
|
||||
const up = input.toUpperCase();
|
||||
const out = new Uint8Array(Math.floor(up.length * 5 / 8));
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let outPos = 0;
|
||||
for (let i = 0; i < up.length; i++) {
|
||||
const ch = up[i];
|
||||
const v = DECODE_TABLE[ch];
|
||||
if (v === void 0) {
|
||||
throw new LicensingError("bad_encoding", `invalid base32 character '${ch}'`);
|
||||
}
|
||||
value = value << 5 | v;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out[outPos++] = value >> bits & 255;
|
||||
}
|
||||
}
|
||||
return out.subarray(0, outPos);
|
||||
}
|
||||
|
||||
// src/key.ts
|
||||
var KEY_PREFIX = "LIC1";
|
||||
var KEY_VERSION_V1 = 1;
|
||||
var KEY_VERSION_V2 = 2;
|
||||
var KEY_VERSION = KEY_VERSION_V2;
|
||||
var FLAG_FINGERPRINT_BOUND = 1;
|
||||
var FLAG_TRIAL = 2;
|
||||
var PAYLOAD_V1_LEN = 74;
|
||||
var PAYLOAD_V2_HEAD_LEN = 83;
|
||||
var SIGNATURE_LEN = 64;
|
||||
function isExpiredAt(payload, nowUnixSeconds) {
|
||||
return payload.expiresAt !== 0 && nowUnixSeconds >= payload.expiresAt;
|
||||
}
|
||||
function hasEntitlement(payload, slug) {
|
||||
return payload.entitlements.includes(slug);
|
||||
}
|
||||
function parseLicenseKey(raw) {
|
||||
const trimmed = raw.trim();
|
||||
const firstDash = trimmed.indexOf("-");
|
||||
if (firstDash < 0) throw new LicensingError("bad_format", "key is missing prefix delimiter");
|
||||
const prefix = trimmed.slice(0, firstDash);
|
||||
if (prefix !== KEY_PREFIX) throw new LicensingError("bad_format", `unknown key prefix '${prefix}'`);
|
||||
const body = trimmed.slice(firstDash + 1);
|
||||
const lastDash = body.lastIndexOf("-");
|
||||
if (lastDash < 0) throw new LicensingError("bad_format", "key is missing signature delimiter");
|
||||
const payloadB32 = body.slice(0, lastDash);
|
||||
const signatureB32 = body.slice(lastDash + 1);
|
||||
const payloadBytes = decodeBase32NoPad(payloadB32);
|
||||
const signature = decodeBase32NoPad(signatureB32);
|
||||
if (signature.length !== SIGNATURE_LEN) {
|
||||
throw new LicensingError(
|
||||
"bad_format",
|
||||
`signature is ${signature.length} bytes; expected ${SIGNATURE_LEN}`
|
||||
);
|
||||
}
|
||||
if (payloadBytes.length < 1) {
|
||||
throw new LicensingError("bad_format", "empty payload");
|
||||
}
|
||||
const version = payloadBytes[0];
|
||||
let payload;
|
||||
switch (version) {
|
||||
case KEY_VERSION_V1:
|
||||
payload = parseV1(payloadBytes);
|
||||
break;
|
||||
case KEY_VERSION_V2:
|
||||
payload = parseV2(payloadBytes);
|
||||
break;
|
||||
default:
|
||||
throw new LicensingError("bad_version", `unsupported key version ${version}`);
|
||||
}
|
||||
return {
|
||||
payload,
|
||||
signedBytes: payloadBytes,
|
||||
signature
|
||||
};
|
||||
}
|
||||
function parseV1(payloadBytes) {
|
||||
if (payloadBytes.length !== PAYLOAD_V1_LEN) {
|
||||
throw new LicensingError(
|
||||
"bad_format",
|
||||
`v1 payload is ${payloadBytes.length} bytes; expected ${PAYLOAD_V1_LEN}`
|
||||
);
|
||||
}
|
||||
const flags = payloadBytes[1];
|
||||
const productId = payloadBytes.slice(2, 18);
|
||||
const licenseId = payloadBytes.slice(18, 34);
|
||||
const issuedAt = readBigEndianI64(payloadBytes, 34);
|
||||
const fingerprintHash = payloadBytes.slice(42, 74);
|
||||
return {
|
||||
version: KEY_VERSION_V1,
|
||||
flags,
|
||||
productId,
|
||||
licenseId,
|
||||
issuedAt,
|
||||
expiresAt: 0,
|
||||
fingerprintHash,
|
||||
entitlements: [],
|
||||
productUuid: uuidString(productId),
|
||||
licenseUuid: uuidString(licenseId),
|
||||
isFingerprintBound: (flags & FLAG_FINGERPRINT_BOUND) !== 0,
|
||||
isTrial: (flags & FLAG_TRIAL) !== 0
|
||||
};
|
||||
}
|
||||
function parseV2(payloadBytes) {
|
||||
if (payloadBytes.length < PAYLOAD_V2_HEAD_LEN) {
|
||||
throw new LicensingError(
|
||||
"bad_format",
|
||||
`v2 payload is ${payloadBytes.length} bytes; expected >= ${PAYLOAD_V2_HEAD_LEN}`
|
||||
);
|
||||
}
|
||||
const flags = payloadBytes[1];
|
||||
const productId = payloadBytes.slice(2, 18);
|
||||
const licenseId = payloadBytes.slice(18, 34);
|
||||
const issuedAt = readBigEndianI64(payloadBytes, 34);
|
||||
const expiresAt = readBigEndianI64(payloadBytes, 42);
|
||||
const fingerprintHash = payloadBytes.slice(50, 82);
|
||||
const numEntitlements = payloadBytes[82];
|
||||
const entitlements = [];
|
||||
let cursor = PAYLOAD_V2_HEAD_LEN;
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
for (let i = 0; i < numEntitlements; i++) {
|
||||
if (cursor >= payloadBytes.length) {
|
||||
throw new LicensingError("bad_format", "truncated entitlement list");
|
||||
}
|
||||
const len = payloadBytes[cursor];
|
||||
cursor += 1;
|
||||
if (cursor + len > payloadBytes.length) {
|
||||
throw new LicensingError("bad_format", "truncated entitlement");
|
||||
}
|
||||
try {
|
||||
entitlements.push(decoder.decode(payloadBytes.slice(cursor, cursor + len)));
|
||||
} catch {
|
||||
throw new LicensingError("bad_format", "entitlement not utf-8");
|
||||
}
|
||||
cursor += len;
|
||||
}
|
||||
if (cursor !== payloadBytes.length) {
|
||||
throw new LicensingError("bad_format", "trailing bytes in payload");
|
||||
}
|
||||
return {
|
||||
version: KEY_VERSION_V2,
|
||||
flags,
|
||||
productId,
|
||||
licenseId,
|
||||
issuedAt,
|
||||
expiresAt,
|
||||
fingerprintHash,
|
||||
entitlements,
|
||||
productUuid: uuidString(productId),
|
||||
licenseUuid: uuidString(licenseId),
|
||||
isFingerprintBound: (flags & FLAG_FINGERPRINT_BOUND) !== 0,
|
||||
isTrial: (flags & FLAG_TRIAL) !== 0
|
||||
};
|
||||
}
|
||||
function readBigEndianI64(buf, offset) {
|
||||
const view = new DataView(buf.buffer, buf.byteOffset + offset, 8);
|
||||
const hi = view.getInt32(0, false);
|
||||
const lo = view.getUint32(4, false);
|
||||
return hi * 2 ** 32 + lo;
|
||||
}
|
||||
function uuidString(b) {
|
||||
const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||
}
|
||||
|
||||
// src/fingerprint.ts
|
||||
var import_sha256 = require("@noble/hashes/sha256");
|
||||
function hashFingerprint(raw) {
|
||||
return (0, import_sha256.sha256)(new TextEncoder().encode(raw));
|
||||
}
|
||||
|
||||
// src/verify.ts
|
||||
ed.etc.sha512Sync = (...m) => (0, import_sha512.sha512)(ed.etc.concatBytes(...m));
|
||||
var Verifier = class {
|
||||
pubkey;
|
||||
constructor(pubkey) {
|
||||
this.pubkey = pubkey;
|
||||
}
|
||||
/** Verify a license key string. Throws on any failure. */
|
||||
verify(keyStr) {
|
||||
const key = parseLicenseKey(keyStr);
|
||||
const ok = ed.verify(key.signature, key.signedBytes, this.pubkey.raw);
|
||||
if (!ok) throw new LicensingError("bad_signature", "signature did not verify");
|
||||
return {
|
||||
payload: key.payload,
|
||||
licenseId: key.payload.licenseUuid,
|
||||
productId: key.payload.productUuid
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Verify AND enforce that, if the key is fingerprint-bound, the given
|
||||
* fingerprint matches. If the key is not bound, the fingerprint is
|
||||
* ignored. Throws on any failure.
|
||||
*/
|
||||
verifyWithFingerprint(keyStr, fingerprint) {
|
||||
const result = this.verify(keyStr);
|
||||
if (result.payload.isFingerprintBound) {
|
||||
const expected = hashFingerprint(fingerprint);
|
||||
const stored = result.payload.fingerprintHash;
|
||||
if (!equalBytes(expected, stored)) {
|
||||
throw new LicensingError("bad_signature", "fingerprint does not match bound key");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Verify a key and additionally reject it with an `expired` error if
|
||||
* `nowUnixSeconds` is at or past its `expiresAt`. Perpetual keys
|
||||
* (`expiresAt === 0`) are accepted regardless of `nowUnixSeconds`. This is
|
||||
* offline-only — no grace window logic; use `Client.validate` for that.
|
||||
*/
|
||||
verifyWithTime(keyStr, nowUnixSeconds) {
|
||||
const result = this.verify(keyStr);
|
||||
if (isExpiredAt(result.payload, nowUnixSeconds)) {
|
||||
throw new LicensingError("expired", "license has expired");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
function equalBytes(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
// src/online.ts
|
||||
var Client = class {
|
||||
base;
|
||||
constructor(baseUrl) {
|
||||
this.base = baseUrl.replace(/\/+$/, "");
|
||||
}
|
||||
/** The normalized base URL this client is pinned to. */
|
||||
baseUrl() {
|
||||
return this.base;
|
||||
}
|
||||
/** Fetch the server's PEM-encoded public key. */
|
||||
async fetchPubkeyPem() {
|
||||
const data = await this.get("/v1/pubkey");
|
||||
return data.public_key_pem;
|
||||
}
|
||||
/**
|
||||
* Server-authoritative validation. Returns the full response including
|
||||
* expiry / entitlements / seat fields introduced in v2.
|
||||
*
|
||||
* Two-argument form kept for call-site compatibility with earlier SDK
|
||||
* versions; pass an options object for the full set of fields.
|
||||
*/
|
||||
async validate(key, productSlugOrOptions, fingerprint) {
|
||||
const opts = typeof productSlugOrOptions === "string" ? { productSlug: productSlugOrOptions, fingerprint } : productSlugOrOptions ?? {};
|
||||
const raw = await this.post("/v1/validate", {
|
||||
key,
|
||||
product_slug: opts.productSlug,
|
||||
fingerprint: opts.fingerprint,
|
||||
hostname: opts.hostname,
|
||||
platform: opts.platform
|
||||
});
|
||||
return this.toValidateResponse(raw);
|
||||
}
|
||||
/** Lightweight heartbeat. Server updates `last_heartbeat_at`. */
|
||||
async heartbeat(key, fingerprint) {
|
||||
const raw = await this.post("/v1/machines/heartbeat", {
|
||||
key,
|
||||
fingerprint
|
||||
});
|
||||
return this.toMachineResponse(raw);
|
||||
}
|
||||
/** Explicitly activate a seat for the given fingerprint. */
|
||||
async activate(key, fingerprint, opts = {}) {
|
||||
const raw = await this.post("/v1/machines/activate", {
|
||||
key,
|
||||
fingerprint,
|
||||
hostname: opts.hostname,
|
||||
platform: opts.platform
|
||||
});
|
||||
return this.toMachineResponse(raw);
|
||||
}
|
||||
/** Free a seat held by the given fingerprint. */
|
||||
async deactivate(key, fingerprint, reason) {
|
||||
const raw = await this.post("/v1/machines/deactivate", {
|
||||
key,
|
||||
fingerprint,
|
||||
reason
|
||||
});
|
||||
return this.toMachineResponse(raw);
|
||||
}
|
||||
/** Start a purchase. Returns the checkout URL and invoice id. */
|
||||
async startPurchase(productSlug, opts = {}) {
|
||||
const raw = await this.post("/v1/purchase", {
|
||||
product: productSlug,
|
||||
buyer_email: opts.buyerEmail,
|
||||
buyer_note: opts.buyerNote,
|
||||
redirect_url: opts.redirectUrl,
|
||||
code: opts.code,
|
||||
policy_slug: opts.policySlug
|
||||
});
|
||||
return {
|
||||
invoiceId: raw.invoice_id,
|
||||
btcpayInvoiceId: raw.btcpay_invoice_id,
|
||||
checkoutUrl: raw.checkout_url,
|
||||
amountSats: raw.amount_sats,
|
||||
pollUrl: raw.poll_url
|
||||
};
|
||||
}
|
||||
/**
|
||||
* List public, buyer-visible policies (tiers) for a product. No
|
||||
* auth — same data the licensing service's `/buy/<slug>` page
|
||||
* uses server-side. Use this to render an in-app tier picker
|
||||
* that stays in sync with the operator's admin-side tier setup.
|
||||
*
|
||||
* Returns each policy's slug, display name, price (in the
|
||||
* product's listed currency's smallest unit — sats or cents),
|
||||
* entitlements, recurring/trial flags. Internal fields (id,
|
||||
* tip recipients, raw metadata) are deliberately omitted.
|
||||
*/
|
||||
async listPublicPolicies(productSlug) {
|
||||
const raw = await this.get(
|
||||
`/v1/products/${encodeURIComponent(productSlug)}/policies`
|
||||
);
|
||||
const product = raw.product;
|
||||
const policies = raw.policies ?? [];
|
||||
return {
|
||||
product: {
|
||||
slug: product.slug,
|
||||
name: product.name,
|
||||
description: product.description ?? "",
|
||||
basePriceSats: product.base_price_sats
|
||||
},
|
||||
policies: policies.map((p) => ({
|
||||
slug: p.slug,
|
||||
name: p.name,
|
||||
description: p.description ?? "",
|
||||
priceSats: p.price_sats,
|
||||
durationSeconds: p.duration_seconds ?? 0,
|
||||
maxMachines: p.max_machines ?? 1,
|
||||
isTrial: !!p.is_trial,
|
||||
entitlements: p.entitlements ?? [],
|
||||
highlighted: !!p.highlighted,
|
||||
isRecurring: !!p.is_recurring,
|
||||
renewalPeriodDays: p.renewal_period_days ?? 0,
|
||||
trialDays: p.trial_days ?? 0
|
||||
}))
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Redeem a `free_license` code: bypass BTCPay entirely and receive the
|
||||
* signed license key directly. Throws if the code is unknown / disabled
|
||||
* / expired / wrong product / not a free_license code, or if the cap
|
||||
* has been reached.
|
||||
*/
|
||||
async redeemFreeLicense(productSlug, code, opts = {}) {
|
||||
const raw = await this.post("/v1/redeem", {
|
||||
product: productSlug,
|
||||
code,
|
||||
buyer_email: opts.buyerEmail,
|
||||
buyer_note: opts.buyerNote
|
||||
});
|
||||
return {
|
||||
licenseId: raw.license_id,
|
||||
licenseKey: raw.license_key,
|
||||
invoiceId: raw.invoice_id,
|
||||
redemptionId: raw.redemption_id
|
||||
};
|
||||
}
|
||||
/** Poll a purchase by its invoice id. */
|
||||
async pollPurchase(invoiceId) {
|
||||
const raw = await this.get(
|
||||
`/v1/purchase/${encodeURIComponent(invoiceId)}`
|
||||
);
|
||||
return {
|
||||
invoiceId: raw.invoice_id,
|
||||
status: raw.status,
|
||||
productId: raw.product_id,
|
||||
amountSats: raw.amount_sats,
|
||||
licenseKey: raw.license_key ?? void 0,
|
||||
licenseId: raw.license_id ?? void 0
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Convenience: open the checkout, poll until a license key is issued,
|
||||
* then return it. Suitable for CLI usage or for an app UI that shows a
|
||||
* spinner while the buyer pays.
|
||||
*/
|
||||
async waitForLicense(invoiceId, options = {}) {
|
||||
const interval = options.intervalMs ?? 5e3;
|
||||
const deadline = options.timeoutMs ? Date.now() + options.timeoutMs : Infinity;
|
||||
while (true) {
|
||||
const poll = await this.pollPurchase(invoiceId);
|
||||
if (poll.licenseKey) return poll.licenseKey;
|
||||
if (poll.status === "expired" || poll.status === "invalid") {
|
||||
throw new LicensingError("server_error", `invoice ended in status ${poll.status}`);
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
throw new LicensingError("server_error", "timed out waiting for license issuance");
|
||||
}
|
||||
await sleep(interval);
|
||||
}
|
||||
}
|
||||
// --- internals ---
|
||||
toValidateResponse(raw) {
|
||||
const entitlements = Array.isArray(raw.entitlements) ? raw.entitlements.filter((x) => typeof x === "string") : void 0;
|
||||
return {
|
||||
ok: !!raw.ok,
|
||||
reason: raw.reason,
|
||||
licenseId: raw.license_id,
|
||||
productId: raw.product_id,
|
||||
productSlug: raw.product_slug,
|
||||
issuedAt: raw.issued_at,
|
||||
expiresAt: raw.expires_at,
|
||||
graceUntil: raw.grace_until,
|
||||
inGracePeriod: raw.in_grace_period,
|
||||
isTrial: raw.is_trial,
|
||||
entitlements,
|
||||
status: raw.status,
|
||||
machineId: raw.machine_id,
|
||||
maxMachines: raw.max_machines
|
||||
};
|
||||
}
|
||||
toMachineResponse(raw) {
|
||||
return {
|
||||
ok: !!raw.ok,
|
||||
reason: raw.reason,
|
||||
machineId: raw.machine_id,
|
||||
activeCount: raw.active_count,
|
||||
maxMachines: raw.max_machines
|
||||
};
|
||||
}
|
||||
async get(path) {
|
||||
return this.request(path, { method: "GET" });
|
||||
}
|
||||
async post(path, body) {
|
||||
return this.request(path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
async request(path, init) {
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(`${this.base}${path}`, init);
|
||||
} catch (e) {
|
||||
throw new LicensingError("http_error", e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) {
|
||||
throw new LicensingError("server_error", `HTTP ${resp.status}: ${text}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new LicensingError("server_error", `non-JSON response: ${text}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
function sleep(ms) {
|
||||
return new Promise((res) => setTimeout(res, ms));
|
||||
}
|
||||
|
||||
// src/pubkey.ts
|
||||
var PublicKey = class _PublicKey {
|
||||
/** Raw 32-byte Ed25519 public key material. */
|
||||
raw;
|
||||
constructor(raw) {
|
||||
if (raw.length !== 32) {
|
||||
throw new LicensingError(
|
||||
"bad_format",
|
||||
`public key must be 32 bytes; got ${raw.length}`
|
||||
);
|
||||
}
|
||||
this.raw = raw;
|
||||
}
|
||||
/** Parse a PEM blob as emitted by the service. */
|
||||
static fromPem(pem) {
|
||||
const stripped = pem.replace(/-----BEGIN [^-]+-----/g, "").replace(/-----END [^-]+-----/g, "").replace(/\s+/g, "");
|
||||
if (!stripped) {
|
||||
throw new LicensingError("bad_format", "empty PEM input");
|
||||
}
|
||||
const der = base64Decode(stripped);
|
||||
if (der.length < 32) {
|
||||
throw new LicensingError("bad_format", "PEM body too short to contain a public key");
|
||||
}
|
||||
const raw = der.slice(der.length - 32);
|
||||
return new _PublicKey(raw);
|
||||
}
|
||||
/** Construct from raw bytes (no PEM envelope). */
|
||||
static fromBytes(bytes) {
|
||||
return new _PublicKey(bytes);
|
||||
}
|
||||
};
|
||||
function base64Decode(b64) {
|
||||
const nodeBuffer = globalThis.Buffer;
|
||||
if (nodeBuffer) {
|
||||
return new Uint8Array(nodeBuffer.from(b64, "base64"));
|
||||
}
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
Client,
|
||||
FLAG_FINGERPRINT_BOUND,
|
||||
FLAG_TRIAL,
|
||||
KEY_PREFIX,
|
||||
KEY_VERSION,
|
||||
KEY_VERSION_V1,
|
||||
KEY_VERSION_V2,
|
||||
LicensingError,
|
||||
PublicKey,
|
||||
Verifier,
|
||||
hasEntitlement,
|
||||
hashFingerprint,
|
||||
isExpiredAt,
|
||||
parseLicenseKey
|
||||
});
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* License-key parsing. Matches the service's wire format exactly.
|
||||
*
|
||||
* ## Wire format
|
||||
*
|
||||
* A key string looks like `LIC1-<payload_b32>-<signature_b32>`. Both halves
|
||||
* are Crockford base32 (no padding) of the raw bytes.
|
||||
*
|
||||
* ### v1 payload (74 bytes, fixed)
|
||||
*
|
||||
* ```text
|
||||
* offset size field
|
||||
* 0 1 version = 1
|
||||
* 1 1 flags
|
||||
* 2 16 product_id (UUID bytes)
|
||||
* 18 16 license_id (UUID bytes)
|
||||
* 34 8 issued_at (i64 unix seconds, big-endian)
|
||||
* 42 32 fingerprint_hash (SHA-256, or all-zero)
|
||||
* ```
|
||||
*
|
||||
* ### v2 payload (83 bytes + variable entitlements)
|
||||
*
|
||||
* ```text
|
||||
* offset size field
|
||||
* 0 1 version = 2
|
||||
* 1 1 flags
|
||||
* 2 16 product_id
|
||||
* 18 16 license_id
|
||||
* 34 8 issued_at
|
||||
* 42 8 expires_at (i64, 0 = perpetual)
|
||||
* 50 32 fingerprint_hash
|
||||
* 82 1 num_entitlements (u8)
|
||||
* 83 * entitlements — each: [u8 len][len utf-8 bytes]
|
||||
* ```
|
||||
*
|
||||
* Clients verifying a v1 key treat `expiresAt` as 0 and `entitlements` as
|
||||
* empty, so application code can branch on flags / fields uniformly.
|
||||
*/
|
||||
declare const KEY_PREFIX = "LIC1";
|
||||
/** v1 format identifier. */
|
||||
declare const KEY_VERSION_V1 = 1;
|
||||
/** v2 format identifier. */
|
||||
declare const KEY_VERSION_V2 = 2;
|
||||
/** Highest format version this client understands. */
|
||||
declare const KEY_VERSION = 2;
|
||||
/** Set when the key is bound to a specific machine fingerprint hash. */
|
||||
declare const FLAG_FINGERPRINT_BOUND = 1;
|
||||
/** Set on trial keys. */
|
||||
declare const FLAG_TRIAL = 2;
|
||||
/** Decoded fields of the signed payload. */
|
||||
interface LicensePayload {
|
||||
/** Format version (1 or 2). */
|
||||
version: number;
|
||||
/** Feature flags. */
|
||||
flags: number;
|
||||
/** Raw 16-byte product id (UUID). */
|
||||
productId: Uint8Array;
|
||||
/** Raw 16-byte license id (UUID). */
|
||||
licenseId: Uint8Array;
|
||||
/** Unix seconds issued. */
|
||||
issuedAt: number;
|
||||
/** Unix seconds expiry; `0` for perpetual. Always `0` on v1 keys. */
|
||||
expiresAt: number;
|
||||
/** SHA-256 hash of the bound machine fingerprint, or all-zero. */
|
||||
fingerprintHash: Uint8Array;
|
||||
/** Entitlement slugs granted by this license. Empty on v1 keys. */
|
||||
entitlements: string[];
|
||||
/** Product UUID in canonical string form. */
|
||||
productUuid: string;
|
||||
/** License UUID in canonical string form. */
|
||||
licenseUuid: string;
|
||||
/** True if the key is fingerprint-bound. */
|
||||
isFingerprintBound: boolean;
|
||||
/** True if the key is flagged as a trial. */
|
||||
isTrial: boolean;
|
||||
}
|
||||
/** A parsed (not yet verified) license key. */
|
||||
interface LicenseKey {
|
||||
payload: LicensePayload;
|
||||
/**
|
||||
* Raw payload bytes (what the server signed over). Length is 74 on v1,
|
||||
* `>= 83` on v2.
|
||||
*/
|
||||
signedBytes: Uint8Array;
|
||||
/** Raw 64-byte signature. */
|
||||
signature: Uint8Array;
|
||||
}
|
||||
/** True if `nowUnixSeconds` is at or after the key's `expiresAt`. */
|
||||
declare function isExpiredAt(payload: LicensePayload, nowUnixSeconds: number): boolean;
|
||||
/** True if the license grants the given entitlement slug. */
|
||||
declare function hasEntitlement(payload: LicensePayload, slug: string): boolean;
|
||||
/** Parse a `LIC1-...-...` string. Does NOT verify. */
|
||||
declare function parseLicenseKey(raw: string): LicenseKey;
|
||||
|
||||
/**
|
||||
* Issuer public key. Accepts either raw 32-byte Ed25519 key material or a
|
||||
* PEM-encoded SubjectPublicKeyInfo blob (which is what the service returns
|
||||
* from `/v1/pubkey`).
|
||||
*/
|
||||
/** Parsed Ed25519 public key, ready for signature verification. */
|
||||
declare class PublicKey {
|
||||
/** Raw 32-byte Ed25519 public key material. */
|
||||
readonly raw: Uint8Array;
|
||||
constructor(raw: Uint8Array);
|
||||
/** Parse a PEM blob as emitted by the service. */
|
||||
static fromPem(pem: string): PublicKey;
|
||||
/** Construct from raw bytes (no PEM envelope). */
|
||||
static fromBytes(bytes: Uint8Array): PublicKey;
|
||||
}
|
||||
|
||||
/** Offline Ed25519 signature verification. */
|
||||
|
||||
interface VerifyOk {
|
||||
/** Parsed payload fields. */
|
||||
payload: LicensePayload;
|
||||
/** License UUID as a canonical string. */
|
||||
licenseId: string;
|
||||
/** Product UUID as a canonical string. */
|
||||
productId: string;
|
||||
}
|
||||
/** Verifies license keys against a single issuing server's public key. */
|
||||
declare class Verifier {
|
||||
private pubkey;
|
||||
constructor(pubkey: PublicKey);
|
||||
/** Verify a license key string. Throws on any failure. */
|
||||
verify(keyStr: string): VerifyOk;
|
||||
/**
|
||||
* Verify AND enforce that, if the key is fingerprint-bound, the given
|
||||
* fingerprint matches. If the key is not bound, the fingerprint is
|
||||
* ignored. Throws on any failure.
|
||||
*/
|
||||
verifyWithFingerprint(keyStr: string, fingerprint: string): VerifyOk;
|
||||
/**
|
||||
* Verify a key and additionally reject it with an `expired` error if
|
||||
* `nowUnixSeconds` is at or past its `expiresAt`. Perpetual keys
|
||||
* (`expiresAt === 0`) are accepted regardless of `nowUnixSeconds`. This is
|
||||
* offline-only — no grace window logic; use `Client.validate` for that.
|
||||
*/
|
||||
verifyWithTime(keyStr: string, nowUnixSeconds: number): VerifyOk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Online operations against a running `licensing-service` instance.
|
||||
*
|
||||
* All methods use the global `fetch` available in Node 18+ and every modern
|
||||
* browser. No additional runtime required.
|
||||
*/
|
||||
interface ValidateResponse {
|
||||
ok: boolean;
|
||||
/**
|
||||
* Machine-readable reason on failure. One of:
|
||||
* `bad_format`, `bad_signature`, `not_found`, `revoked`, `suspended`,
|
||||
* `expired`, `product_mismatch`, `fingerprint_mismatch`,
|
||||
* `too_many_machines`, `rate_limited`, `invalid_state`.
|
||||
*/
|
||||
reason?: string;
|
||||
licenseId?: string;
|
||||
productId?: string;
|
||||
productSlug?: string;
|
||||
issuedAt?: string;
|
||||
/** Expiry timestamp (RFC 3339) if the license has one. */
|
||||
expiresAt?: string;
|
||||
/** End of the grace window (RFC 3339) when in a grace period. */
|
||||
graceUntil?: string;
|
||||
/** True when the key is past `expiresAt` but still inside the grace window. */
|
||||
inGracePeriod?: boolean;
|
||||
/** True if this license is flagged as a trial. */
|
||||
isTrial?: boolean;
|
||||
/** Entitlement slugs granted by the license. */
|
||||
entitlements?: string[];
|
||||
/** License status string: `active`, `suspended`, `revoked`. */
|
||||
status?: string;
|
||||
/** Machine id created or matched by this call (when fingerprint was sent). */
|
||||
machineId?: string;
|
||||
/** Seat cap: `0` unlimited, `1` single-seat, `n` n-seat. */
|
||||
maxMachines?: number;
|
||||
}
|
||||
interface ValidateOptions {
|
||||
/** Product slug the caller expects the key to cover. */
|
||||
productSlug?: string;
|
||||
/** Raw machine fingerprint; enables seat binding / cap enforcement. */
|
||||
fingerprint?: string;
|
||||
/** Client-supplied hostname, stored against the machine row on activation. */
|
||||
hostname?: string;
|
||||
/** Client-supplied platform descriptor, e.g. `'linux-x86_64'`. */
|
||||
platform?: string;
|
||||
}
|
||||
interface MachineResponse {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
machineId?: string;
|
||||
activeCount?: number;
|
||||
maxMachines?: number;
|
||||
}
|
||||
interface PurchaseSession {
|
||||
/** Our internal invoice id — use with `pollPurchase`. */
|
||||
invoiceId: string;
|
||||
/** BTCPay's invoice id (opaque). */
|
||||
btcpayInvoiceId: string;
|
||||
/** URL to open in the buyer's browser to pay. */
|
||||
checkoutUrl: string;
|
||||
/** Price in satoshis. */
|
||||
amountSats: number;
|
||||
/** Where the service recommends polling. */
|
||||
pollUrl: string;
|
||||
}
|
||||
interface PollResponse {
|
||||
invoiceId: string;
|
||||
/** `pending | settled | expired | invalid`. */
|
||||
status: string;
|
||||
productId: string;
|
||||
amountSats: number;
|
||||
/** Populated once the license has been issued. */
|
||||
licenseKey?: string;
|
||||
licenseId?: string;
|
||||
}
|
||||
interface StartPurchaseOptions {
|
||||
/** Optional email for the receipt. */
|
||||
buyerEmail?: string;
|
||||
/** Optional URL the buyer should be returned to after payment. */
|
||||
redirectUrl?: string;
|
||||
/** Optional discount / referral code. */
|
||||
code?: string;
|
||||
/** Optional buyer note recorded on the invoice (admin-visible). */
|
||||
buyerNote?: string;
|
||||
/**
|
||||
* Optional tier slug (the policy the buyer chose). When set, the
|
||||
* licensing service prices the invoice at the policy's
|
||||
* `price_sats_override` and remembers the chosen policy on the
|
||||
* invoice so the issued license carries that policy's
|
||||
* entitlements / duration / max_machines / trial flag.
|
||||
*
|
||||
* When omitted, the service falls back to the product's default
|
||||
* policy (the policy slugged "default", or the first active one).
|
||||
*
|
||||
* To list available tiers for a product without auth, see
|
||||
* {@link Client.listPublicPolicies}.
|
||||
*/
|
||||
policySlug?: string;
|
||||
}
|
||||
/**
|
||||
* One tier on the buyer-facing tier picker. Returned by
|
||||
* {@link Client.listPublicPolicies}. The shape mirrors what the
|
||||
* licensing service's `/buy/<slug>` page reads server-side, so an
|
||||
* in-app tier picker can render identical text and pricing without
|
||||
* the buyer ever leaving the app.
|
||||
*/
|
||||
interface PublicPolicy {
|
||||
slug: string;
|
||||
name: string;
|
||||
/** Free-form per-tier blurb (operator-set in admin UI). May be empty. */
|
||||
description: string;
|
||||
/**
|
||||
* Effective price in the smallest unit of the product's listed
|
||||
* currency: sats for SAT-priced products, cents for USD/EUR-priced
|
||||
* products. The product-level currency is on the parent
|
||||
* {@link PublicPoliciesResponse.product.basePriceSats} (sats only) and
|
||||
* via the daemon's `/v1/products/<slug>` endpoint for the full
|
||||
* currency-typed view.
|
||||
*/
|
||||
priceSats: number;
|
||||
/** 0 = perpetual; otherwise license lifetime in seconds. */
|
||||
durationSeconds: number;
|
||||
/** Seat cap. 0 = unlimited, 1 = single-seat, n = n-seat. */
|
||||
maxMachines: number;
|
||||
isTrial: boolean;
|
||||
entitlements: string[];
|
||||
/** True if the operator marked this tier as "Most popular". */
|
||||
highlighted: boolean;
|
||||
/** True if the policy is a recurring subscription. */
|
||||
isRecurring: boolean;
|
||||
/** Renewal cadence in days (0 for non-recurring). */
|
||||
renewalPeriodDays: number;
|
||||
/** First-cycle free-trial length (0 for none). */
|
||||
trialDays: number;
|
||||
}
|
||||
interface PublicPoliciesResponse {
|
||||
product: {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
basePriceSats: number;
|
||||
};
|
||||
policies: PublicPolicy[];
|
||||
}
|
||||
interface RedeemFreeOptions {
|
||||
/** Optional email recorded on the synthetic invoice + license. */
|
||||
buyerEmail?: string;
|
||||
/** Optional buyer note. */
|
||||
buyerNote?: string;
|
||||
}
|
||||
interface RedeemFreeResponse {
|
||||
licenseId: string;
|
||||
/** The fully-signed license key, ready for offline verification. */
|
||||
licenseKey: string;
|
||||
invoiceId: string;
|
||||
redemptionId: string;
|
||||
}
|
||||
/** An HTTP client pinned to one licensing-service base URL. */
|
||||
declare class Client {
|
||||
private base;
|
||||
constructor(baseUrl: string);
|
||||
/** The normalized base URL this client is pinned to. */
|
||||
baseUrl(): string;
|
||||
/** Fetch the server's PEM-encoded public key. */
|
||||
fetchPubkeyPem(): Promise<string>;
|
||||
/**
|
||||
* Server-authoritative validation. Returns the full response including
|
||||
* expiry / entitlements / seat fields introduced in v2.
|
||||
*
|
||||
* Two-argument form kept for call-site compatibility with earlier SDK
|
||||
* versions; pass an options object for the full set of fields.
|
||||
*/
|
||||
validate(key: string, productSlugOrOptions?: string | ValidateOptions, fingerprint?: string): Promise<ValidateResponse>;
|
||||
/** Lightweight heartbeat. Server updates `last_heartbeat_at`. */
|
||||
heartbeat(key: string, fingerprint: string): Promise<MachineResponse>;
|
||||
/** Explicitly activate a seat for the given fingerprint. */
|
||||
activate(key: string, fingerprint: string, opts?: {
|
||||
hostname?: string;
|
||||
platform?: string;
|
||||
}): Promise<MachineResponse>;
|
||||
/** Free a seat held by the given fingerprint. */
|
||||
deactivate(key: string, fingerprint: string, reason?: string): Promise<MachineResponse>;
|
||||
/** Start a purchase. Returns the checkout URL and invoice id. */
|
||||
startPurchase(productSlug: string, opts?: StartPurchaseOptions): Promise<PurchaseSession>;
|
||||
/**
|
||||
* List public, buyer-visible policies (tiers) for a product. No
|
||||
* auth — same data the licensing service's `/buy/<slug>` page
|
||||
* uses server-side. Use this to render an in-app tier picker
|
||||
* that stays in sync with the operator's admin-side tier setup.
|
||||
*
|
||||
* Returns each policy's slug, display name, price (in the
|
||||
* product's listed currency's smallest unit — sats or cents),
|
||||
* entitlements, recurring/trial flags. Internal fields (id,
|
||||
* tip recipients, raw metadata) are deliberately omitted.
|
||||
*/
|
||||
listPublicPolicies(productSlug: string): Promise<PublicPoliciesResponse>;
|
||||
/**
|
||||
* Redeem a `free_license` code: bypass BTCPay entirely and receive the
|
||||
* signed license key directly. Throws if the code is unknown / disabled
|
||||
* / expired / wrong product / not a free_license code, or if the cap
|
||||
* has been reached.
|
||||
*/
|
||||
redeemFreeLicense(productSlug: string, code: string, opts?: RedeemFreeOptions): Promise<RedeemFreeResponse>;
|
||||
/** Poll a purchase by its invoice id. */
|
||||
pollPurchase(invoiceId: string): Promise<PollResponse>;
|
||||
/**
|
||||
* Convenience: open the checkout, poll until a license key is issued,
|
||||
* then return it. Suitable for CLI usage or for an app UI that shows a
|
||||
* spinner while the buyer pays.
|
||||
*/
|
||||
waitForLicense(invoiceId: string, options?: {
|
||||
intervalMs?: number;
|
||||
timeoutMs?: number;
|
||||
}): Promise<string>;
|
||||
private toValidateResponse;
|
||||
private toMachineResponse;
|
||||
private get;
|
||||
private post;
|
||||
private request;
|
||||
}
|
||||
|
||||
/** Hash a raw fingerprint string to the 32-byte form embedded in keys. */
|
||||
declare function hashFingerprint(raw: string): Uint8Array;
|
||||
|
||||
/** All errors thrown by this library inherit from `LicensingError`. */
|
||||
declare class LicensingError extends Error {
|
||||
/**
|
||||
* Machine-readable reason code. Common values:
|
||||
* `"bad_format"`, `"bad_encoding"`, `"bad_version"`, `"bad_signature"`,
|
||||
* `"expired"`, `"server_error"`, `"http_error"`, `"other"`.
|
||||
*/
|
||||
readonly code: string;
|
||||
constructor(code: string, message: string);
|
||||
}
|
||||
|
||||
export { Client, FLAG_FINGERPRINT_BOUND, FLAG_TRIAL, KEY_PREFIX, KEY_VERSION, KEY_VERSION_V1, KEY_VERSION_V2, type LicenseKey, type LicensePayload, LicensingError, type MachineResponse, type PollResponse, PublicKey, type PublicPoliciesResponse, type PublicPolicy, type PurchaseSession, type RedeemFreeOptions, type RedeemFreeResponse, type StartPurchaseOptions, type ValidateOptions, type ValidateResponse, Verifier, type VerifyOk, hasEntitlement, hashFingerprint, isExpiredAt, parseLicenseKey };
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* License-key parsing. Matches the service's wire format exactly.
|
||||
*
|
||||
* ## Wire format
|
||||
*
|
||||
* A key string looks like `LIC1-<payload_b32>-<signature_b32>`. Both halves
|
||||
* are Crockford base32 (no padding) of the raw bytes.
|
||||
*
|
||||
* ### v1 payload (74 bytes, fixed)
|
||||
*
|
||||
* ```text
|
||||
* offset size field
|
||||
* 0 1 version = 1
|
||||
* 1 1 flags
|
||||
* 2 16 product_id (UUID bytes)
|
||||
* 18 16 license_id (UUID bytes)
|
||||
* 34 8 issued_at (i64 unix seconds, big-endian)
|
||||
* 42 32 fingerprint_hash (SHA-256, or all-zero)
|
||||
* ```
|
||||
*
|
||||
* ### v2 payload (83 bytes + variable entitlements)
|
||||
*
|
||||
* ```text
|
||||
* offset size field
|
||||
* 0 1 version = 2
|
||||
* 1 1 flags
|
||||
* 2 16 product_id
|
||||
* 18 16 license_id
|
||||
* 34 8 issued_at
|
||||
* 42 8 expires_at (i64, 0 = perpetual)
|
||||
* 50 32 fingerprint_hash
|
||||
* 82 1 num_entitlements (u8)
|
||||
* 83 * entitlements — each: [u8 len][len utf-8 bytes]
|
||||
* ```
|
||||
*
|
||||
* Clients verifying a v1 key treat `expiresAt` as 0 and `entitlements` as
|
||||
* empty, so application code can branch on flags / fields uniformly.
|
||||
*/
|
||||
declare const KEY_PREFIX = "LIC1";
|
||||
/** v1 format identifier. */
|
||||
declare const KEY_VERSION_V1 = 1;
|
||||
/** v2 format identifier. */
|
||||
declare const KEY_VERSION_V2 = 2;
|
||||
/** Highest format version this client understands. */
|
||||
declare const KEY_VERSION = 2;
|
||||
/** Set when the key is bound to a specific machine fingerprint hash. */
|
||||
declare const FLAG_FINGERPRINT_BOUND = 1;
|
||||
/** Set on trial keys. */
|
||||
declare const FLAG_TRIAL = 2;
|
||||
/** Decoded fields of the signed payload. */
|
||||
interface LicensePayload {
|
||||
/** Format version (1 or 2). */
|
||||
version: number;
|
||||
/** Feature flags. */
|
||||
flags: number;
|
||||
/** Raw 16-byte product id (UUID). */
|
||||
productId: Uint8Array;
|
||||
/** Raw 16-byte license id (UUID). */
|
||||
licenseId: Uint8Array;
|
||||
/** Unix seconds issued. */
|
||||
issuedAt: number;
|
||||
/** Unix seconds expiry; `0` for perpetual. Always `0` on v1 keys. */
|
||||
expiresAt: number;
|
||||
/** SHA-256 hash of the bound machine fingerprint, or all-zero. */
|
||||
fingerprintHash: Uint8Array;
|
||||
/** Entitlement slugs granted by this license. Empty on v1 keys. */
|
||||
entitlements: string[];
|
||||
/** Product UUID in canonical string form. */
|
||||
productUuid: string;
|
||||
/** License UUID in canonical string form. */
|
||||
licenseUuid: string;
|
||||
/** True if the key is fingerprint-bound. */
|
||||
isFingerprintBound: boolean;
|
||||
/** True if the key is flagged as a trial. */
|
||||
isTrial: boolean;
|
||||
}
|
||||
/** A parsed (not yet verified) license key. */
|
||||
interface LicenseKey {
|
||||
payload: LicensePayload;
|
||||
/**
|
||||
* Raw payload bytes (what the server signed over). Length is 74 on v1,
|
||||
* `>= 83` on v2.
|
||||
*/
|
||||
signedBytes: Uint8Array;
|
||||
/** Raw 64-byte signature. */
|
||||
signature: Uint8Array;
|
||||
}
|
||||
/** True if `nowUnixSeconds` is at or after the key's `expiresAt`. */
|
||||
declare function isExpiredAt(payload: LicensePayload, nowUnixSeconds: number): boolean;
|
||||
/** True if the license grants the given entitlement slug. */
|
||||
declare function hasEntitlement(payload: LicensePayload, slug: string): boolean;
|
||||
/** Parse a `LIC1-...-...` string. Does NOT verify. */
|
||||
declare function parseLicenseKey(raw: string): LicenseKey;
|
||||
|
||||
/**
|
||||
* Issuer public key. Accepts either raw 32-byte Ed25519 key material or a
|
||||
* PEM-encoded SubjectPublicKeyInfo blob (which is what the service returns
|
||||
* from `/v1/pubkey`).
|
||||
*/
|
||||
/** Parsed Ed25519 public key, ready for signature verification. */
|
||||
declare class PublicKey {
|
||||
/** Raw 32-byte Ed25519 public key material. */
|
||||
readonly raw: Uint8Array;
|
||||
constructor(raw: Uint8Array);
|
||||
/** Parse a PEM blob as emitted by the service. */
|
||||
static fromPem(pem: string): PublicKey;
|
||||
/** Construct from raw bytes (no PEM envelope). */
|
||||
static fromBytes(bytes: Uint8Array): PublicKey;
|
||||
}
|
||||
|
||||
/** Offline Ed25519 signature verification. */
|
||||
|
||||
interface VerifyOk {
|
||||
/** Parsed payload fields. */
|
||||
payload: LicensePayload;
|
||||
/** License UUID as a canonical string. */
|
||||
licenseId: string;
|
||||
/** Product UUID as a canonical string. */
|
||||
productId: string;
|
||||
}
|
||||
/** Verifies license keys against a single issuing server's public key. */
|
||||
declare class Verifier {
|
||||
private pubkey;
|
||||
constructor(pubkey: PublicKey);
|
||||
/** Verify a license key string. Throws on any failure. */
|
||||
verify(keyStr: string): VerifyOk;
|
||||
/**
|
||||
* Verify AND enforce that, if the key is fingerprint-bound, the given
|
||||
* fingerprint matches. If the key is not bound, the fingerprint is
|
||||
* ignored. Throws on any failure.
|
||||
*/
|
||||
verifyWithFingerprint(keyStr: string, fingerprint: string): VerifyOk;
|
||||
/**
|
||||
* Verify a key and additionally reject it with an `expired` error if
|
||||
* `nowUnixSeconds` is at or past its `expiresAt`. Perpetual keys
|
||||
* (`expiresAt === 0`) are accepted regardless of `nowUnixSeconds`. This is
|
||||
* offline-only — no grace window logic; use `Client.validate` for that.
|
||||
*/
|
||||
verifyWithTime(keyStr: string, nowUnixSeconds: number): VerifyOk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Online operations against a running `licensing-service` instance.
|
||||
*
|
||||
* All methods use the global `fetch` available in Node 18+ and every modern
|
||||
* browser. No additional runtime required.
|
||||
*/
|
||||
interface ValidateResponse {
|
||||
ok: boolean;
|
||||
/**
|
||||
* Machine-readable reason on failure. One of:
|
||||
* `bad_format`, `bad_signature`, `not_found`, `revoked`, `suspended`,
|
||||
* `expired`, `product_mismatch`, `fingerprint_mismatch`,
|
||||
* `too_many_machines`, `rate_limited`, `invalid_state`.
|
||||
*/
|
||||
reason?: string;
|
||||
licenseId?: string;
|
||||
productId?: string;
|
||||
productSlug?: string;
|
||||
issuedAt?: string;
|
||||
/** Expiry timestamp (RFC 3339) if the license has one. */
|
||||
expiresAt?: string;
|
||||
/** End of the grace window (RFC 3339) when in a grace period. */
|
||||
graceUntil?: string;
|
||||
/** True when the key is past `expiresAt` but still inside the grace window. */
|
||||
inGracePeriod?: boolean;
|
||||
/** True if this license is flagged as a trial. */
|
||||
isTrial?: boolean;
|
||||
/** Entitlement slugs granted by the license. */
|
||||
entitlements?: string[];
|
||||
/** License status string: `active`, `suspended`, `revoked`. */
|
||||
status?: string;
|
||||
/** Machine id created or matched by this call (when fingerprint was sent). */
|
||||
machineId?: string;
|
||||
/** Seat cap: `0` unlimited, `1` single-seat, `n` n-seat. */
|
||||
maxMachines?: number;
|
||||
}
|
||||
interface ValidateOptions {
|
||||
/** Product slug the caller expects the key to cover. */
|
||||
productSlug?: string;
|
||||
/** Raw machine fingerprint; enables seat binding / cap enforcement. */
|
||||
fingerprint?: string;
|
||||
/** Client-supplied hostname, stored against the machine row on activation. */
|
||||
hostname?: string;
|
||||
/** Client-supplied platform descriptor, e.g. `'linux-x86_64'`. */
|
||||
platform?: string;
|
||||
}
|
||||
interface MachineResponse {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
machineId?: string;
|
||||
activeCount?: number;
|
||||
maxMachines?: number;
|
||||
}
|
||||
interface PurchaseSession {
|
||||
/** Our internal invoice id — use with `pollPurchase`. */
|
||||
invoiceId: string;
|
||||
/** BTCPay's invoice id (opaque). */
|
||||
btcpayInvoiceId: string;
|
||||
/** URL to open in the buyer's browser to pay. */
|
||||
checkoutUrl: string;
|
||||
/** Price in satoshis. */
|
||||
amountSats: number;
|
||||
/** Where the service recommends polling. */
|
||||
pollUrl: string;
|
||||
}
|
||||
interface PollResponse {
|
||||
invoiceId: string;
|
||||
/** `pending | settled | expired | invalid`. */
|
||||
status: string;
|
||||
productId: string;
|
||||
amountSats: number;
|
||||
/** Populated once the license has been issued. */
|
||||
licenseKey?: string;
|
||||
licenseId?: string;
|
||||
}
|
||||
interface StartPurchaseOptions {
|
||||
/** Optional email for the receipt. */
|
||||
buyerEmail?: string;
|
||||
/** Optional URL the buyer should be returned to after payment. */
|
||||
redirectUrl?: string;
|
||||
/** Optional discount / referral code. */
|
||||
code?: string;
|
||||
/** Optional buyer note recorded on the invoice (admin-visible). */
|
||||
buyerNote?: string;
|
||||
/**
|
||||
* Optional tier slug (the policy the buyer chose). When set, the
|
||||
* licensing service prices the invoice at the policy's
|
||||
* `price_sats_override` and remembers the chosen policy on the
|
||||
* invoice so the issued license carries that policy's
|
||||
* entitlements / duration / max_machines / trial flag.
|
||||
*
|
||||
* When omitted, the service falls back to the product's default
|
||||
* policy (the policy slugged "default", or the first active one).
|
||||
*
|
||||
* To list available tiers for a product without auth, see
|
||||
* {@link Client.listPublicPolicies}.
|
||||
*/
|
||||
policySlug?: string;
|
||||
}
|
||||
/**
|
||||
* One tier on the buyer-facing tier picker. Returned by
|
||||
* {@link Client.listPublicPolicies}. The shape mirrors what the
|
||||
* licensing service's `/buy/<slug>` page reads server-side, so an
|
||||
* in-app tier picker can render identical text and pricing without
|
||||
* the buyer ever leaving the app.
|
||||
*/
|
||||
interface PublicPolicy {
|
||||
slug: string;
|
||||
name: string;
|
||||
/** Free-form per-tier blurb (operator-set in admin UI). May be empty. */
|
||||
description: string;
|
||||
/**
|
||||
* Effective price in the smallest unit of the product's listed
|
||||
* currency: sats for SAT-priced products, cents for USD/EUR-priced
|
||||
* products. The product-level currency is on the parent
|
||||
* {@link PublicPoliciesResponse.product.basePriceSats} (sats only) and
|
||||
* via the daemon's `/v1/products/<slug>` endpoint for the full
|
||||
* currency-typed view.
|
||||
*/
|
||||
priceSats: number;
|
||||
/** 0 = perpetual; otherwise license lifetime in seconds. */
|
||||
durationSeconds: number;
|
||||
/** Seat cap. 0 = unlimited, 1 = single-seat, n = n-seat. */
|
||||
maxMachines: number;
|
||||
isTrial: boolean;
|
||||
entitlements: string[];
|
||||
/** True if the operator marked this tier as "Most popular". */
|
||||
highlighted: boolean;
|
||||
/** True if the policy is a recurring subscription. */
|
||||
isRecurring: boolean;
|
||||
/** Renewal cadence in days (0 for non-recurring). */
|
||||
renewalPeriodDays: number;
|
||||
/** First-cycle free-trial length (0 for none). */
|
||||
trialDays: number;
|
||||
}
|
||||
interface PublicPoliciesResponse {
|
||||
product: {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
basePriceSats: number;
|
||||
};
|
||||
policies: PublicPolicy[];
|
||||
}
|
||||
interface RedeemFreeOptions {
|
||||
/** Optional email recorded on the synthetic invoice + license. */
|
||||
buyerEmail?: string;
|
||||
/** Optional buyer note. */
|
||||
buyerNote?: string;
|
||||
}
|
||||
interface RedeemFreeResponse {
|
||||
licenseId: string;
|
||||
/** The fully-signed license key, ready for offline verification. */
|
||||
licenseKey: string;
|
||||
invoiceId: string;
|
||||
redemptionId: string;
|
||||
}
|
||||
/** An HTTP client pinned to one licensing-service base URL. */
|
||||
declare class Client {
|
||||
private base;
|
||||
constructor(baseUrl: string);
|
||||
/** The normalized base URL this client is pinned to. */
|
||||
baseUrl(): string;
|
||||
/** Fetch the server's PEM-encoded public key. */
|
||||
fetchPubkeyPem(): Promise<string>;
|
||||
/**
|
||||
* Server-authoritative validation. Returns the full response including
|
||||
* expiry / entitlements / seat fields introduced in v2.
|
||||
*
|
||||
* Two-argument form kept for call-site compatibility with earlier SDK
|
||||
* versions; pass an options object for the full set of fields.
|
||||
*/
|
||||
validate(key: string, productSlugOrOptions?: string | ValidateOptions, fingerprint?: string): Promise<ValidateResponse>;
|
||||
/** Lightweight heartbeat. Server updates `last_heartbeat_at`. */
|
||||
heartbeat(key: string, fingerprint: string): Promise<MachineResponse>;
|
||||
/** Explicitly activate a seat for the given fingerprint. */
|
||||
activate(key: string, fingerprint: string, opts?: {
|
||||
hostname?: string;
|
||||
platform?: string;
|
||||
}): Promise<MachineResponse>;
|
||||
/** Free a seat held by the given fingerprint. */
|
||||
deactivate(key: string, fingerprint: string, reason?: string): Promise<MachineResponse>;
|
||||
/** Start a purchase. Returns the checkout URL and invoice id. */
|
||||
startPurchase(productSlug: string, opts?: StartPurchaseOptions): Promise<PurchaseSession>;
|
||||
/**
|
||||
* List public, buyer-visible policies (tiers) for a product. No
|
||||
* auth — same data the licensing service's `/buy/<slug>` page
|
||||
* uses server-side. Use this to render an in-app tier picker
|
||||
* that stays in sync with the operator's admin-side tier setup.
|
||||
*
|
||||
* Returns each policy's slug, display name, price (in the
|
||||
* product's listed currency's smallest unit — sats or cents),
|
||||
* entitlements, recurring/trial flags. Internal fields (id,
|
||||
* tip recipients, raw metadata) are deliberately omitted.
|
||||
*/
|
||||
listPublicPolicies(productSlug: string): Promise<PublicPoliciesResponse>;
|
||||
/**
|
||||
* Redeem a `free_license` code: bypass BTCPay entirely and receive the
|
||||
* signed license key directly. Throws if the code is unknown / disabled
|
||||
* / expired / wrong product / not a free_license code, or if the cap
|
||||
* has been reached.
|
||||
*/
|
||||
redeemFreeLicense(productSlug: string, code: string, opts?: RedeemFreeOptions): Promise<RedeemFreeResponse>;
|
||||
/** Poll a purchase by its invoice id. */
|
||||
pollPurchase(invoiceId: string): Promise<PollResponse>;
|
||||
/**
|
||||
* Convenience: open the checkout, poll until a license key is issued,
|
||||
* then return it. Suitable for CLI usage or for an app UI that shows a
|
||||
* spinner while the buyer pays.
|
||||
*/
|
||||
waitForLicense(invoiceId: string, options?: {
|
||||
intervalMs?: number;
|
||||
timeoutMs?: number;
|
||||
}): Promise<string>;
|
||||
private toValidateResponse;
|
||||
private toMachineResponse;
|
||||
private get;
|
||||
private post;
|
||||
private request;
|
||||
}
|
||||
|
||||
/** Hash a raw fingerprint string to the 32-byte form embedded in keys. */
|
||||
declare function hashFingerprint(raw: string): Uint8Array;
|
||||
|
||||
/** All errors thrown by this library inherit from `LicensingError`. */
|
||||
declare class LicensingError extends Error {
|
||||
/**
|
||||
* Machine-readable reason code. Common values:
|
||||
* `"bad_format"`, `"bad_encoding"`, `"bad_version"`, `"bad_signature"`,
|
||||
* `"expired"`, `"server_error"`, `"http_error"`, `"other"`.
|
||||
*/
|
||||
readonly code: string;
|
||||
constructor(code: string, message: string);
|
||||
}
|
||||
|
||||
export { Client, FLAG_FINGERPRINT_BOUND, FLAG_TRIAL, KEY_PREFIX, KEY_VERSION, KEY_VERSION_V1, KEY_VERSION_V2, type LicenseKey, type LicensePayload, LicensingError, type MachineResponse, type PollResponse, PublicKey, type PublicPoliciesResponse, type PublicPolicy, type PurchaseSession, type RedeemFreeOptions, type RedeemFreeResponse, type StartPurchaseOptions, type ValidateOptions, type ValidateResponse, Verifier, type VerifyOk, hasEntitlement, hashFingerprint, isExpiredAt, parseLicenseKey };
|
||||
+544
@@ -0,0 +1,544 @@
|
||||
// src/verify.ts
|
||||
import * as ed from "@noble/ed25519";
|
||||
import { sha512 } from "@noble/hashes/sha512";
|
||||
|
||||
// src/errors.ts
|
||||
var LicensingError = class extends Error {
|
||||
/**
|
||||
* Machine-readable reason code. Common values:
|
||||
* `"bad_format"`, `"bad_encoding"`, `"bad_version"`, `"bad_signature"`,
|
||||
* `"expired"`, `"server_error"`, `"http_error"`, `"other"`.
|
||||
*/
|
||||
code;
|
||||
constructor(code, message) {
|
||||
super(message);
|
||||
this.name = "LicensingError";
|
||||
this.code = code;
|
||||
}
|
||||
};
|
||||
|
||||
// src/base32.ts
|
||||
var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
var DECODE_TABLE = (() => {
|
||||
const t = {};
|
||||
for (let i = 0; i < ALPHABET.length; i++) t[ALPHABET[i]] = i;
|
||||
return t;
|
||||
})();
|
||||
function decodeBase32NoPad(input) {
|
||||
const up = input.toUpperCase();
|
||||
const out = new Uint8Array(Math.floor(up.length * 5 / 8));
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let outPos = 0;
|
||||
for (let i = 0; i < up.length; i++) {
|
||||
const ch = up[i];
|
||||
const v = DECODE_TABLE[ch];
|
||||
if (v === void 0) {
|
||||
throw new LicensingError("bad_encoding", `invalid base32 character '${ch}'`);
|
||||
}
|
||||
value = value << 5 | v;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out[outPos++] = value >> bits & 255;
|
||||
}
|
||||
}
|
||||
return out.subarray(0, outPos);
|
||||
}
|
||||
|
||||
// src/key.ts
|
||||
var KEY_PREFIX = "LIC1";
|
||||
var KEY_VERSION_V1 = 1;
|
||||
var KEY_VERSION_V2 = 2;
|
||||
var KEY_VERSION = KEY_VERSION_V2;
|
||||
var FLAG_FINGERPRINT_BOUND = 1;
|
||||
var FLAG_TRIAL = 2;
|
||||
var PAYLOAD_V1_LEN = 74;
|
||||
var PAYLOAD_V2_HEAD_LEN = 83;
|
||||
var SIGNATURE_LEN = 64;
|
||||
function isExpiredAt(payload, nowUnixSeconds) {
|
||||
return payload.expiresAt !== 0 && nowUnixSeconds >= payload.expiresAt;
|
||||
}
|
||||
function hasEntitlement(payload, slug) {
|
||||
return payload.entitlements.includes(slug);
|
||||
}
|
||||
function parseLicenseKey(raw) {
|
||||
const trimmed = raw.trim();
|
||||
const firstDash = trimmed.indexOf("-");
|
||||
if (firstDash < 0) throw new LicensingError("bad_format", "key is missing prefix delimiter");
|
||||
const prefix = trimmed.slice(0, firstDash);
|
||||
if (prefix !== KEY_PREFIX) throw new LicensingError("bad_format", `unknown key prefix '${prefix}'`);
|
||||
const body = trimmed.slice(firstDash + 1);
|
||||
const lastDash = body.lastIndexOf("-");
|
||||
if (lastDash < 0) throw new LicensingError("bad_format", "key is missing signature delimiter");
|
||||
const payloadB32 = body.slice(0, lastDash);
|
||||
const signatureB32 = body.slice(lastDash + 1);
|
||||
const payloadBytes = decodeBase32NoPad(payloadB32);
|
||||
const signature = decodeBase32NoPad(signatureB32);
|
||||
if (signature.length !== SIGNATURE_LEN) {
|
||||
throw new LicensingError(
|
||||
"bad_format",
|
||||
`signature is ${signature.length} bytes; expected ${SIGNATURE_LEN}`
|
||||
);
|
||||
}
|
||||
if (payloadBytes.length < 1) {
|
||||
throw new LicensingError("bad_format", "empty payload");
|
||||
}
|
||||
const version = payloadBytes[0];
|
||||
let payload;
|
||||
switch (version) {
|
||||
case KEY_VERSION_V1:
|
||||
payload = parseV1(payloadBytes);
|
||||
break;
|
||||
case KEY_VERSION_V2:
|
||||
payload = parseV2(payloadBytes);
|
||||
break;
|
||||
default:
|
||||
throw new LicensingError("bad_version", `unsupported key version ${version}`);
|
||||
}
|
||||
return {
|
||||
payload,
|
||||
signedBytes: payloadBytes,
|
||||
signature
|
||||
};
|
||||
}
|
||||
function parseV1(payloadBytes) {
|
||||
if (payloadBytes.length !== PAYLOAD_V1_LEN) {
|
||||
throw new LicensingError(
|
||||
"bad_format",
|
||||
`v1 payload is ${payloadBytes.length} bytes; expected ${PAYLOAD_V1_LEN}`
|
||||
);
|
||||
}
|
||||
const flags = payloadBytes[1];
|
||||
const productId = payloadBytes.slice(2, 18);
|
||||
const licenseId = payloadBytes.slice(18, 34);
|
||||
const issuedAt = readBigEndianI64(payloadBytes, 34);
|
||||
const fingerprintHash = payloadBytes.slice(42, 74);
|
||||
return {
|
||||
version: KEY_VERSION_V1,
|
||||
flags,
|
||||
productId,
|
||||
licenseId,
|
||||
issuedAt,
|
||||
expiresAt: 0,
|
||||
fingerprintHash,
|
||||
entitlements: [],
|
||||
productUuid: uuidString(productId),
|
||||
licenseUuid: uuidString(licenseId),
|
||||
isFingerprintBound: (flags & FLAG_FINGERPRINT_BOUND) !== 0,
|
||||
isTrial: (flags & FLAG_TRIAL) !== 0
|
||||
};
|
||||
}
|
||||
function parseV2(payloadBytes) {
|
||||
if (payloadBytes.length < PAYLOAD_V2_HEAD_LEN) {
|
||||
throw new LicensingError(
|
||||
"bad_format",
|
||||
`v2 payload is ${payloadBytes.length} bytes; expected >= ${PAYLOAD_V2_HEAD_LEN}`
|
||||
);
|
||||
}
|
||||
const flags = payloadBytes[1];
|
||||
const productId = payloadBytes.slice(2, 18);
|
||||
const licenseId = payloadBytes.slice(18, 34);
|
||||
const issuedAt = readBigEndianI64(payloadBytes, 34);
|
||||
const expiresAt = readBigEndianI64(payloadBytes, 42);
|
||||
const fingerprintHash = payloadBytes.slice(50, 82);
|
||||
const numEntitlements = payloadBytes[82];
|
||||
const entitlements = [];
|
||||
let cursor = PAYLOAD_V2_HEAD_LEN;
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
for (let i = 0; i < numEntitlements; i++) {
|
||||
if (cursor >= payloadBytes.length) {
|
||||
throw new LicensingError("bad_format", "truncated entitlement list");
|
||||
}
|
||||
const len = payloadBytes[cursor];
|
||||
cursor += 1;
|
||||
if (cursor + len > payloadBytes.length) {
|
||||
throw new LicensingError("bad_format", "truncated entitlement");
|
||||
}
|
||||
try {
|
||||
entitlements.push(decoder.decode(payloadBytes.slice(cursor, cursor + len)));
|
||||
} catch {
|
||||
throw new LicensingError("bad_format", "entitlement not utf-8");
|
||||
}
|
||||
cursor += len;
|
||||
}
|
||||
if (cursor !== payloadBytes.length) {
|
||||
throw new LicensingError("bad_format", "trailing bytes in payload");
|
||||
}
|
||||
return {
|
||||
version: KEY_VERSION_V2,
|
||||
flags,
|
||||
productId,
|
||||
licenseId,
|
||||
issuedAt,
|
||||
expiresAt,
|
||||
fingerprintHash,
|
||||
entitlements,
|
||||
productUuid: uuidString(productId),
|
||||
licenseUuid: uuidString(licenseId),
|
||||
isFingerprintBound: (flags & FLAG_FINGERPRINT_BOUND) !== 0,
|
||||
isTrial: (flags & FLAG_TRIAL) !== 0
|
||||
};
|
||||
}
|
||||
function readBigEndianI64(buf, offset) {
|
||||
const view = new DataView(buf.buffer, buf.byteOffset + offset, 8);
|
||||
const hi = view.getInt32(0, false);
|
||||
const lo = view.getUint32(4, false);
|
||||
return hi * 2 ** 32 + lo;
|
||||
}
|
||||
function uuidString(b) {
|
||||
const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||
}
|
||||
|
||||
// src/fingerprint.ts
|
||||
import { sha256 } from "@noble/hashes/sha256";
|
||||
function hashFingerprint(raw) {
|
||||
return sha256(new TextEncoder().encode(raw));
|
||||
}
|
||||
|
||||
// src/verify.ts
|
||||
ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m));
|
||||
var Verifier = class {
|
||||
pubkey;
|
||||
constructor(pubkey) {
|
||||
this.pubkey = pubkey;
|
||||
}
|
||||
/** Verify a license key string. Throws on any failure. */
|
||||
verify(keyStr) {
|
||||
const key = parseLicenseKey(keyStr);
|
||||
const ok = ed.verify(key.signature, key.signedBytes, this.pubkey.raw);
|
||||
if (!ok) throw new LicensingError("bad_signature", "signature did not verify");
|
||||
return {
|
||||
payload: key.payload,
|
||||
licenseId: key.payload.licenseUuid,
|
||||
productId: key.payload.productUuid
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Verify AND enforce that, if the key is fingerprint-bound, the given
|
||||
* fingerprint matches. If the key is not bound, the fingerprint is
|
||||
* ignored. Throws on any failure.
|
||||
*/
|
||||
verifyWithFingerprint(keyStr, fingerprint) {
|
||||
const result = this.verify(keyStr);
|
||||
if (result.payload.isFingerprintBound) {
|
||||
const expected = hashFingerprint(fingerprint);
|
||||
const stored = result.payload.fingerprintHash;
|
||||
if (!equalBytes(expected, stored)) {
|
||||
throw new LicensingError("bad_signature", "fingerprint does not match bound key");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Verify a key and additionally reject it with an `expired` error if
|
||||
* `nowUnixSeconds` is at or past its `expiresAt`. Perpetual keys
|
||||
* (`expiresAt === 0`) are accepted regardless of `nowUnixSeconds`. This is
|
||||
* offline-only — no grace window logic; use `Client.validate` for that.
|
||||
*/
|
||||
verifyWithTime(keyStr, nowUnixSeconds) {
|
||||
const result = this.verify(keyStr);
|
||||
if (isExpiredAt(result.payload, nowUnixSeconds)) {
|
||||
throw new LicensingError("expired", "license has expired");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
function equalBytes(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
// src/online.ts
|
||||
var Client = class {
|
||||
base;
|
||||
constructor(baseUrl) {
|
||||
this.base = baseUrl.replace(/\/+$/, "");
|
||||
}
|
||||
/** The normalized base URL this client is pinned to. */
|
||||
baseUrl() {
|
||||
return this.base;
|
||||
}
|
||||
/** Fetch the server's PEM-encoded public key. */
|
||||
async fetchPubkeyPem() {
|
||||
const data = await this.get("/v1/pubkey");
|
||||
return data.public_key_pem;
|
||||
}
|
||||
/**
|
||||
* Server-authoritative validation. Returns the full response including
|
||||
* expiry / entitlements / seat fields introduced in v2.
|
||||
*
|
||||
* Two-argument form kept for call-site compatibility with earlier SDK
|
||||
* versions; pass an options object for the full set of fields.
|
||||
*/
|
||||
async validate(key, productSlugOrOptions, fingerprint) {
|
||||
const opts = typeof productSlugOrOptions === "string" ? { productSlug: productSlugOrOptions, fingerprint } : productSlugOrOptions ?? {};
|
||||
const raw = await this.post("/v1/validate", {
|
||||
key,
|
||||
product_slug: opts.productSlug,
|
||||
fingerprint: opts.fingerprint,
|
||||
hostname: opts.hostname,
|
||||
platform: opts.platform
|
||||
});
|
||||
return this.toValidateResponse(raw);
|
||||
}
|
||||
/** Lightweight heartbeat. Server updates `last_heartbeat_at`. */
|
||||
async heartbeat(key, fingerprint) {
|
||||
const raw = await this.post("/v1/machines/heartbeat", {
|
||||
key,
|
||||
fingerprint
|
||||
});
|
||||
return this.toMachineResponse(raw);
|
||||
}
|
||||
/** Explicitly activate a seat for the given fingerprint. */
|
||||
async activate(key, fingerprint, opts = {}) {
|
||||
const raw = await this.post("/v1/machines/activate", {
|
||||
key,
|
||||
fingerprint,
|
||||
hostname: opts.hostname,
|
||||
platform: opts.platform
|
||||
});
|
||||
return this.toMachineResponse(raw);
|
||||
}
|
||||
/** Free a seat held by the given fingerprint. */
|
||||
async deactivate(key, fingerprint, reason) {
|
||||
const raw = await this.post("/v1/machines/deactivate", {
|
||||
key,
|
||||
fingerprint,
|
||||
reason
|
||||
});
|
||||
return this.toMachineResponse(raw);
|
||||
}
|
||||
/** Start a purchase. Returns the checkout URL and invoice id. */
|
||||
async startPurchase(productSlug, opts = {}) {
|
||||
const raw = await this.post("/v1/purchase", {
|
||||
product: productSlug,
|
||||
buyer_email: opts.buyerEmail,
|
||||
buyer_note: opts.buyerNote,
|
||||
redirect_url: opts.redirectUrl,
|
||||
code: opts.code,
|
||||
policy_slug: opts.policySlug
|
||||
});
|
||||
return {
|
||||
invoiceId: raw.invoice_id,
|
||||
btcpayInvoiceId: raw.btcpay_invoice_id,
|
||||
checkoutUrl: raw.checkout_url,
|
||||
amountSats: raw.amount_sats,
|
||||
pollUrl: raw.poll_url
|
||||
};
|
||||
}
|
||||
/**
|
||||
* List public, buyer-visible policies (tiers) for a product. No
|
||||
* auth — same data the licensing service's `/buy/<slug>` page
|
||||
* uses server-side. Use this to render an in-app tier picker
|
||||
* that stays in sync with the operator's admin-side tier setup.
|
||||
*
|
||||
* Returns each policy's slug, display name, price (in the
|
||||
* product's listed currency's smallest unit — sats or cents),
|
||||
* entitlements, recurring/trial flags. Internal fields (id,
|
||||
* tip recipients, raw metadata) are deliberately omitted.
|
||||
*/
|
||||
async listPublicPolicies(productSlug) {
|
||||
const raw = await this.get(
|
||||
`/v1/products/${encodeURIComponent(productSlug)}/policies`
|
||||
);
|
||||
const product = raw.product;
|
||||
const policies = raw.policies ?? [];
|
||||
return {
|
||||
product: {
|
||||
slug: product.slug,
|
||||
name: product.name,
|
||||
description: product.description ?? "",
|
||||
basePriceSats: product.base_price_sats
|
||||
},
|
||||
policies: policies.map((p) => ({
|
||||
slug: p.slug,
|
||||
name: p.name,
|
||||
description: p.description ?? "",
|
||||
priceSats: p.price_sats,
|
||||
durationSeconds: p.duration_seconds ?? 0,
|
||||
maxMachines: p.max_machines ?? 1,
|
||||
isTrial: !!p.is_trial,
|
||||
entitlements: p.entitlements ?? [],
|
||||
highlighted: !!p.highlighted,
|
||||
isRecurring: !!p.is_recurring,
|
||||
renewalPeriodDays: p.renewal_period_days ?? 0,
|
||||
trialDays: p.trial_days ?? 0
|
||||
}))
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Redeem a `free_license` code: bypass BTCPay entirely and receive the
|
||||
* signed license key directly. Throws if the code is unknown / disabled
|
||||
* / expired / wrong product / not a free_license code, or if the cap
|
||||
* has been reached.
|
||||
*/
|
||||
async redeemFreeLicense(productSlug, code, opts = {}) {
|
||||
const raw = await this.post("/v1/redeem", {
|
||||
product: productSlug,
|
||||
code,
|
||||
buyer_email: opts.buyerEmail,
|
||||
buyer_note: opts.buyerNote
|
||||
});
|
||||
return {
|
||||
licenseId: raw.license_id,
|
||||
licenseKey: raw.license_key,
|
||||
invoiceId: raw.invoice_id,
|
||||
redemptionId: raw.redemption_id
|
||||
};
|
||||
}
|
||||
/** Poll a purchase by its invoice id. */
|
||||
async pollPurchase(invoiceId) {
|
||||
const raw = await this.get(
|
||||
`/v1/purchase/${encodeURIComponent(invoiceId)}`
|
||||
);
|
||||
return {
|
||||
invoiceId: raw.invoice_id,
|
||||
status: raw.status,
|
||||
productId: raw.product_id,
|
||||
amountSats: raw.amount_sats,
|
||||
licenseKey: raw.license_key ?? void 0,
|
||||
licenseId: raw.license_id ?? void 0
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Convenience: open the checkout, poll until a license key is issued,
|
||||
* then return it. Suitable for CLI usage or for an app UI that shows a
|
||||
* spinner while the buyer pays.
|
||||
*/
|
||||
async waitForLicense(invoiceId, options = {}) {
|
||||
const interval = options.intervalMs ?? 5e3;
|
||||
const deadline = options.timeoutMs ? Date.now() + options.timeoutMs : Infinity;
|
||||
while (true) {
|
||||
const poll = await this.pollPurchase(invoiceId);
|
||||
if (poll.licenseKey) return poll.licenseKey;
|
||||
if (poll.status === "expired" || poll.status === "invalid") {
|
||||
throw new LicensingError("server_error", `invoice ended in status ${poll.status}`);
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
throw new LicensingError("server_error", "timed out waiting for license issuance");
|
||||
}
|
||||
await sleep(interval);
|
||||
}
|
||||
}
|
||||
// --- internals ---
|
||||
toValidateResponse(raw) {
|
||||
const entitlements = Array.isArray(raw.entitlements) ? raw.entitlements.filter((x) => typeof x === "string") : void 0;
|
||||
return {
|
||||
ok: !!raw.ok,
|
||||
reason: raw.reason,
|
||||
licenseId: raw.license_id,
|
||||
productId: raw.product_id,
|
||||
productSlug: raw.product_slug,
|
||||
issuedAt: raw.issued_at,
|
||||
expiresAt: raw.expires_at,
|
||||
graceUntil: raw.grace_until,
|
||||
inGracePeriod: raw.in_grace_period,
|
||||
isTrial: raw.is_trial,
|
||||
entitlements,
|
||||
status: raw.status,
|
||||
machineId: raw.machine_id,
|
||||
maxMachines: raw.max_machines
|
||||
};
|
||||
}
|
||||
toMachineResponse(raw) {
|
||||
return {
|
||||
ok: !!raw.ok,
|
||||
reason: raw.reason,
|
||||
machineId: raw.machine_id,
|
||||
activeCount: raw.active_count,
|
||||
maxMachines: raw.max_machines
|
||||
};
|
||||
}
|
||||
async get(path) {
|
||||
return this.request(path, { method: "GET" });
|
||||
}
|
||||
async post(path, body) {
|
||||
return this.request(path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
async request(path, init) {
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(`${this.base}${path}`, init);
|
||||
} catch (e) {
|
||||
throw new LicensingError("http_error", e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) {
|
||||
throw new LicensingError("server_error", `HTTP ${resp.status}: ${text}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new LicensingError("server_error", `non-JSON response: ${text}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
function sleep(ms) {
|
||||
return new Promise((res) => setTimeout(res, ms));
|
||||
}
|
||||
|
||||
// src/pubkey.ts
|
||||
var PublicKey = class _PublicKey {
|
||||
/** Raw 32-byte Ed25519 public key material. */
|
||||
raw;
|
||||
constructor(raw) {
|
||||
if (raw.length !== 32) {
|
||||
throw new LicensingError(
|
||||
"bad_format",
|
||||
`public key must be 32 bytes; got ${raw.length}`
|
||||
);
|
||||
}
|
||||
this.raw = raw;
|
||||
}
|
||||
/** Parse a PEM blob as emitted by the service. */
|
||||
static fromPem(pem) {
|
||||
const stripped = pem.replace(/-----BEGIN [^-]+-----/g, "").replace(/-----END [^-]+-----/g, "").replace(/\s+/g, "");
|
||||
if (!stripped) {
|
||||
throw new LicensingError("bad_format", "empty PEM input");
|
||||
}
|
||||
const der = base64Decode(stripped);
|
||||
if (der.length < 32) {
|
||||
throw new LicensingError("bad_format", "PEM body too short to contain a public key");
|
||||
}
|
||||
const raw = der.slice(der.length - 32);
|
||||
return new _PublicKey(raw);
|
||||
}
|
||||
/** Construct from raw bytes (no PEM envelope). */
|
||||
static fromBytes(bytes) {
|
||||
return new _PublicKey(bytes);
|
||||
}
|
||||
};
|
||||
function base64Decode(b64) {
|
||||
const nodeBuffer = globalThis.Buffer;
|
||||
if (nodeBuffer) {
|
||||
return new Uint8Array(nodeBuffer.from(b64, "base64"));
|
||||
}
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
export {
|
||||
Client,
|
||||
FLAG_FINGERPRINT_BOUND,
|
||||
FLAG_TRIAL,
|
||||
KEY_PREFIX,
|
||||
KEY_VERSION,
|
||||
KEY_VERSION_V1,
|
||||
KEY_VERSION_V2,
|
||||
LicensingError,
|
||||
PublicKey,
|
||||
Verifier,
|
||||
hasEntitlement,
|
||||
hashFingerprint,
|
||||
isExpiredAt,
|
||||
parseLicenseKey
|
||||
};
|
||||
+2777
File diff suppressed because it is too large
Load Diff
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@keysat/licensing-client",
|
||||
"version": "0.2.0",
|
||||
"description": "Client library for Keysat. Verifies signed license keys offline and wraps the HTTP API for purchase and revocation checks.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"files": ["dist", "README.md", "LICENSE"],
|
||||
"scripts": {},
|
||||
"keywords": [
|
||||
"bitcoin",
|
||||
"licensing",
|
||||
"btcpay",
|
||||
"start9",
|
||||
"ed25519"
|
||||
],
|
||||
"repository": "https://github.com/keysat-xyz/keysat-client-ts",
|
||||
"license": "MIT",
|
||||
"engines": { "node": ">=18" },
|
||||
"dependencies": {
|
||||
"@noble/ed25519": "^2.0.0",
|
||||
"@noble/hashes": "^1.3.3"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
Reference in New Issue
Block a user