diff --git a/README.md b/README.md index 7a7da7e..0c1acad 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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({ @@ -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 diff --git a/package.json b/package.json index b0f85a6..1d35e65 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/index.ts b/src/index.ts index 35c311b..c44fb45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,7 +66,7 @@ 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. @@ -74,10 +74,15 @@ export class KeyMint { * 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'); @@ -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 { @@ -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 { @@ -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 { diff --git a/src/types.ts b/src/types.ts index f7fc587..45ea308 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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. @@ -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 } /** @@ -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; // Optional: Updated custom metadata diff --git a/tests/contracts.test.cjs b/tests/contracts.test.cjs index 0654772..28c28e1 100644 --- a/tests/contracts.test.cjs +++ b/tests/contracts.test.cjs @@ -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 + }); + }); +}); diff --git a/tests/types/params.check.ts b/tests/types/params.check.ts new file mode 100644 index 0000000..4ee6b73 --- /dev/null +++ b/tests/types/params.check.ts @@ -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' }; diff --git a/tests/types/tsconfig.json b/tests/types/tsconfig.json new file mode 100644 index 0000000..3ee1cd8 --- /dev/null +++ b/tests/types/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "../.." + }, + "include": ["./*.ts"] +}