8aaa405843
The keysat-client-ts repo is private. Previous builds were succeeding
purely because Docker layer caching reused a node_modules from when
the repo had been accessible — once anything invalidated the
server/package.json or server/package-lock.json hash (the rename did),
npm in a fresh container hit github with no credentials and 404'd.
Fix: copy the built dist/ from server/node_modules/@keysat/licensing-
client/ into vendor/keysat-licensing-client/, strip the prepare/build
scripts (we already have the compiled output), and switch the server
package.json dep to a file: path:
"@keysat/licensing-client": "file:../vendor/keysat-licensing-client"
Dockerfile now COPY's vendor/ before npm ci. No git, no SSH, no
credentials needed in the build container — and the npm step is
pure-local so it's deterministic.
Side cleanup: dropped the apt-install-git + url.insteadOf gymnastics
that existed solely to work around the now-removed git+https resolution.
The image is slightly smaller (no git in the builder stage). Switched
the npm flag to the modern --omit=dev (the legacy --production printed
a warning).
If keysat-client-ts updates, regenerate vendor/ by:
cp -r server/node_modules/@keysat/licensing-client/{dist,package.json,LICENSE,README.md} \
vendor/keysat-licensing-client/
# then strip prepare/build scripts and devDeps from the copied package.json
# (or just hand-edit if the upstream package.json hasn't changed)
307 lines
12 KiB
TypeScript
307 lines
12 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
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>;
|
|
/**
|
|
* 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 PurchaseSession, type RedeemFreeOptions, type RedeemFreeResponse, type StartPurchaseOptions, type ValidateOptions, type ValidateResponse, Verifier, type VerifyOk, hasEntitlement, hashFingerprint, isExpiredAt, parseLicenseKey };
|