Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ A professional, production-ready SDK for integrating with the Keymint API in Nod
## Features
- **Async/await**: All API calls are asynchronous.
- **TypeScript-first**: Strongly typed request and response models for all endpoints.
- **Consistent error handling**: All API errors are returned as structured objects.
- **Consistent error handling**: Methods reject with a structured `KeyMintApiError` (`{ message, code, errorCode?, status? }`); branch on `errorCode`. See [Error Handling](#error-handling).
- **Machine Identity**: Built-in utilities for hardware fingerprinting and stable installation IDs.

## Installation
Expand Down Expand Up @@ -202,7 +202,7 @@ try {

## Idempotency

All mutating SDK calls (such as creating keys, activations, customer management, etc.) support idempotency keys to safely retry requests in case of network drops. Pass a unique key (recommended: UUID v4) as the optional request options:
Mutating SDK calls (such as creating keys, activations, customer management, etc.) support idempotency keys to safely retry requests in case of network drops. Pass a unique key (recommended: UUID v4) as the optional request options:

```typescript
const result = await sdk.createKey({
Expand All @@ -212,7 +212,9 @@ const result = await sdk.createKey({
});
```

Use a fresh random UUID for each operation and reuse it only to retry that same operation. Never derive it from the license key or host ID: a replay returns the original response, including floating-session secrets.
Use a fresh random UUID for each operation and reuse it only to retry that same operation. Never derive it from the license key or host ID: a replay returns the original response.

The floating endpoints (`floatingCheckout`, `floatingHeartbeat`, `floatingCheckin`) ignore `Idempotency-Key`: each call is processed as a new request, and no response is replayed.

## License
MIT
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"scripts": {
"build": "tsc",
"lint": "eslint . --ext .ts",
"test": "npm run build && node --test tests/contracts.test.cjs tests/offline.test.cjs",
"test": "npm run build && npm run typecheck:tests && node --test tests/contracts.test.cjs tests/offline.test.cjs",
"typecheck:tests": "tsc -p tests/types",
"test:live": "npm run build && node --test tests/live.test.cjs",
"prepublishOnly": "npm run build"
},
Expand Down
15 changes: 10 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,23 @@ export class KeyMint {
}

/**
* Returns a guaranteed-unique, guaranteed-stable installation identifier.
* Returns a unique installation identifier that stays stable once saved.
* On first call, it generates a UUIDv4 seeded with whatever hardware info
* is available and persists it to disk. Every subsequent call returns the
* same value — even across reboots, app updates, and hardware upgrades.
*
* This is the **recommended** value to pass as `hostId` when activating
* a license key.
*
* If the file cannot be written (e.g. a read-only filesystem such as
* Vercel or Lambda), the write error is swallowed and the freshly generated
* ID is returned without being saved, so the next call returns a different
* ID. Pass a writable `storagePath` in those environments.
*
* @param storagePath - Optional custom path for the persistence file.
* Defaults to `~/.keymint/installation-id`.
* @returns A SHA-256 hashed 64-character hex string.
* @throws If the file cannot be read or written (e.g. read-only filesystem).
* @throws If an existing file cannot be read (e.g. a permissions error).
*/
static getOrCreateInstallationId(storagePath?: string): string {
const filePath = storagePath || join(homedir(), '.keymint', 'installation-id');
Expand Down Expand Up @@ -360,7 +365,7 @@ export class KeyMint {
/**
* Checks out a floating license seat.
* @param params - Parameters for checking out the license.
* @param options - Custom request options (e.g. idempotency keys)
* @param options - Custom request options. The floating endpoints ignore `Idempotency-Key`.
* @returns A promise that resolves with the checkout response.
*/
async floatingCheckout(params: FloatingCheckoutParams, options?: RequestOptions): Promise<FloatingCheckoutResponse> {
Expand All @@ -370,7 +375,7 @@ export class KeyMint {
/**
* Sends a heartbeat to keep a floating license session alive.
* @param params - Parameters for the heartbeat.
* @param options - Custom request options (e.g. idempotency keys)
* @param options - Custom request options. The floating endpoints ignore `Idempotency-Key`.
* @returns A promise that resolves with the heartbeat response.
*/
async floatingHeartbeat(params: FloatingHeartbeatParams, options?: RequestOptions): Promise<FloatingHeartbeatResponse> {
Expand All @@ -380,7 +385,7 @@ export class KeyMint {
/**
* Checks in a floating license session, releasing the seat.
* @param params - Parameters for checking in the license.
* @param options - Custom request options (e.g. idempotency keys)
* @param options - Custom request options. The floating endpoints ignore `Idempotency-Key`.
* @returns A promise that resolves with the checkin response.
*/
async floatingCheckin(params: FloatingCheckinParams, options?: RequestOptions): Promise<FloatingCheckinResponse> {
Expand Down
10 changes: 5 additions & 5 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export interface KeyFormat {
*/
export interface CreateKeyParams {
productId: string; // Required: The unique identifier of the product.
maxActivations?: string; // Optional: The maximum number of times the key can be activated.
maxActivations?: number | string | null; // Optional: A whole number 0-1,000,000 (number or digit string) or "unlimited". Omitted or null means unlimited.
expiryDate?: string; // Optional: The expiration date of the key in ISO 8601 format.
customerId?: string; // Optional: The ID of an existing customer to associate with the key.
versionId?: string; // Optional: The ID of a specific product version to associate with the key.
Expand Down Expand Up @@ -228,8 +228,8 @@ export interface DeleteKeyResponse {
* Parameters for the createCustomer API endpoint.
*/
export interface CreateCustomerParams {
name: string; // Required: Customer name
email: string; // Required: Customer email
name: string; // Required: Customer name (3-255 characters)
email?: string; // Optional: Customer email
}

/**
Expand Down Expand Up @@ -477,8 +477,8 @@ export interface FloatingCheckinResponse {
export interface UpdateKeyParams {
productId: string; // Required: The unique identifier of the product.
licenseKey: string; // Required: The license key to update.
maxActivations?: string | number; // Optional: New max activations count
expiryDate?: string; // Optional: New expiration date in ISO 8601 format
maxActivations?: number | string | null; // Optional: A whole number 0-1,000,000 (number or digit string) or "unlimited". null or "unlimited" means unlimited; omit it to keep the current limit.
expiryDate?: string | null; // Optional: New expiration date in ISO 8601 format. `null` (or `""`) removes the expiry; omit it to keep the current one.
customerId?: string; // Optional: New customer ID to associate
newCustomer?: NewCustomer; // Optional: Create and associate a new customer
metadata?: Record<string, any>; // Optional: Updated custom metadata
Expand Down
74 changes: 74 additions & 0 deletions tests/contracts.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,77 @@ test('non-JSON API errors have no errorCode', async () => {
);
});
});

test('updateKey sends expiryDate: null to remove the expiry', async () => {
await withServer(async (request, response) => {
assert.equal(request.method, 'PATCH');
assert.equal(request.url, '/key');
const { raw, body } = await readJson(request);
assert.match(raw, /"expiryDate":null/);
assert.deepEqual(body, { productId: 'product_123', licenseKey: 'license_123', expiryDate: null });
response.setHeader('content-type', 'application/json');
response.end(JSON.stringify({ message: 'Key updated', code: 0 }));
}, async baseUrl => {
const result = await new KeyMint('admin_test', baseUrl).updateKey({
productId: 'product_123', licenseKey: 'license_123', expiryDate: null
});
assert.deepEqual(result, { message: 'Key updated', code: 0 });
});
});

test('updateKey omits expiryDate when it is not set, keeping the current expiry', async () => {
await withServer(async (request, response) => {
const { body } = await readJson(request);
assert.equal('expiryDate' in body, false);
response.setHeader('content-type', 'application/json');
response.end(JSON.stringify({ message: 'Key updated', code: 0 }));
}, async baseUrl => {
await new KeyMint('admin_test', baseUrl).updateKey({
productId: 'product_123', licenseKey: 'license_123', maxActivations: 5
});
});
});

test('createCustomer sends a customer without an email', async () => {
await withServer(async (request, response) => {
assert.equal(request.method, 'POST');
assert.equal(request.url, '/customer');
const { body } = await readJson(request);
assert.deepEqual(body, { name: 'Ada Lovelace' });
response.setHeader('content-type', 'application/json');
response.end(JSON.stringify({ action: 'createCustomer', status: true, code: 0, message: 'Customer created' }));
}, async baseUrl => {
const result = await new KeyMint('admin_test', baseUrl).createCustomer({ name: 'Ada Lovelace' });
assert.equal(result.status, true);
});
});

test('createKey sends maxActivations: null (unlimited)', async () => {
await withServer(async (request, response) => {
assert.equal(request.method, 'POST');
assert.equal(request.url, '/key');
const { raw, body } = await readJson(request);
assert.match(raw, /"maxActivations":null/);
assert.deepEqual(body, { productId: 'product_123', maxActivations: null });
response.setHeader('content-type', 'application/json');
response.end(JSON.stringify({ code: 0, key: 'license_123' }));
}, async baseUrl => {
await new KeyMint('admin_test', baseUrl).createKey({ productId: 'product_123', maxActivations: null });
});
});

test('updateKey sends maxActivations: null (unlimited)', async () => {
await withServer(async (request, response) => {
assert.equal(request.method, 'PATCH');
assert.equal(request.url, '/key');
const { raw, body } = await readJson(request);
assert.match(raw, /"maxActivations":null/);
assert.deepEqual(body, { productId: 'product_123', licenseKey: 'license_123', maxActivations: null });
response.setHeader('content-type', 'application/json');
response.end(JSON.stringify({ message: 'Key updated', code: 0 }));
}, async baseUrl => {
await new KeyMint('admin_test', baseUrl).updateKey({
productId: 'product_123', licenseKey: 'license_123', maxActivations: null
});
});
});
31 changes: 31 additions & 0 deletions tests/types/params.check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Type-level checks, compiled by `npm run typecheck:tests` (nothing is emitted).
import type { CreateCustomerParams, CreateKeyParams, UpdateKeyParams } from '../../src';

// maxActivations: a whole number 0-1,000,000 (number or digit string) or "unlimited"; null means unlimited.
export const createLimitNumber: CreateKeyParams = { productId: 'p', maxActivations: 5 };
export const createLimitDigits: CreateKeyParams = { productId: 'p', maxActivations: '5' };
export const createUnlimited: CreateKeyParams = { productId: 'p', maxActivations: 'unlimited' };
export const createUnlimitedNull: CreateKeyParams = { productId: 'p', maxActivations: null };
export const createDefault: CreateKeyParams = { productId: 'p' };
// @ts-expect-error maxActivations is a number, string or null
export const createLimitBoolean: CreateKeyParams = { productId: 'p', maxActivations: true };
export const updateLimitNumber: UpdateKeyParams = { productId: 'p', licenseKey: 'k', maxActivations: 0 };
export const updateLimitDigits: UpdateKeyParams = { productId: 'p', licenseKey: 'k', maxActivations: '1000000' };
export const updateUnlimited: UpdateKeyParams = { productId: 'p', licenseKey: 'k', maxActivations: 'unlimited' };
export const updateUnlimitedNull: UpdateKeyParams = { productId: 'p', licenseKey: 'k', maxActivations: null };
// @ts-expect-error maxActivations is a number, string or null
export const updateLimitBoolean: UpdateKeyParams = { productId: 'p', licenseKey: 'k', maxActivations: true };

// UpdateKeyParams.expiryDate: a date sets it, null or "" removes it, omitting it keeps it.
export const setExpiry: UpdateKeyParams = { productId: 'p', licenseKey: 'k', expiryDate: '2030-01-01T00:00:00.000Z' };
export const clearExpiry: UpdateKeyParams = { productId: 'p', licenseKey: 'k', expiryDate: null };
export const clearExpiryEmpty: UpdateKeyParams = { productId: 'p', licenseKey: 'k', expiryDate: '' };
export const keepExpiry: UpdateKeyParams = { productId: 'p', licenseKey: 'k' };
// @ts-expect-error expiryDate is a string or null
export const numericExpiry: UpdateKeyParams = { productId: 'p', licenseKey: 'k', expiryDate: 0 };

// CreateCustomerParams.email is optional; name is required.
export const withoutEmail: CreateCustomerParams = { name: 'Ada Lovelace' };
export const withEmail: CreateCustomerParams = { name: 'Ada Lovelace', email: 'ada@example.com' };
// @ts-expect-error name is required
export const withoutName: CreateCustomerParams = { email: 'ada@example.com' };
8 changes: 8 additions & 0 deletions tests/types/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "../.."
},
"include": ["./*.ts"]
}
Loading