diff --git a/CHANGELOG.md b/CHANGELOG.md index e2016c8..9cba3cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - `verifyOfflineLicense(license, options)` (also `KeyMint.verifyOfflineLicense`): verifies a signed offline license (`.lic` file or bare token) locally with `node:crypto`, no new dependencies. Requires the trusted public keys, `productId` and `machineCode`; optional `now` and `clockSkewSeconds` (default 60, max 3600). Returns `OfflineLicense`; failures throw `OfflineLicenseError` with a `code` (`malformed`, `unsupported_algorithm`, `unknown_key`, `bad_signature`, `wrong_type`, `wrong_product`, `wrong_machine`, `expired`). Invalid options throw `TypeError`/`RangeError`. - Tests against the shared Keymint offline-license test vectors (34 cases). +- `deleteKey(params)`: permanently deletes a license key via `DELETE /key` (use `blockKey` to stop it reversibly). ## 1.6.0 - 2026-10-26 diff --git a/README.md b/README.md index 00bda98..7c439f9 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Keymint provides utilities to uniquely identify machines for node-locking: | `updateKey` | Updates an existing license key. | | `blockKey` | Blocks a license key. | | `unblockKey` | Unblocks a previously blocked license key. | +| `deleteKey` | Permanently deletes a license key (use `blockKey` to stop it reversibly). | | `signKey` | Signs a key for offline validation using an admin API key.| | `floatingCheckout` | Checks out a floating license seat. | | `floatingHeartbeat`| Sends a heartbeat to keep a session alive. | @@ -78,7 +79,7 @@ Keymint provides utilities to uniquely identify machines for node-locking: | `getCustomerWithKeys` | Gets a customer along with their license keys. | | `updateCustomer` | Updates an existing customer's information. | | `toggleCustomerStatus`| Toggles a customer's active status. | -| `deleteCustomer` | Permanently deletes a customer and their keys. | +| `deleteCustomer` | Deletes a customer. Their license keys are kept; use `deleteKey` to remove them. | ### Webhook Verification diff --git a/src/index.ts b/src/index.ts index e862221..fc345a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,8 @@ import { ActivateKeyParams, ActivateKeyResponse, DeactivateKeyParams, DeactivateKeyResponse, GetKeyParams, GetKeyResponse, BlockKeyParams, BlockKeyResponse, UnblockKeyParams, - UnblockKeyResponse, CreateCustomerParams, CreateCustomerResponse, + UnblockKeyResponse, DeleteKeyParams, DeleteKeyResponse, + CreateCustomerParams, CreateCustomerResponse, GetAllCustomersResponse, GetCustomerWithKeysParams, GetCustomerWithKeysResponse, UpdateCustomerParams, UpdateCustomerResponse, DeleteCustomerParams, DeleteCustomerResponse, ToggleCustomerStatusParams, ToggleCustomerStatusResponse, GetCustomerByIdParams, GetCustomerByIdResponse, @@ -273,12 +274,13 @@ export class KeyMint { * @param endpoint - API endpoint * @param params - Query parameters * @param options - Custom request options (e.g. idempotency keys) + * @param body - Optional JSON request body * @returns A promise that resolves with the API response */ - private async handleDeleteRequest(endpoint: string, params?: Record, options?: RequestOptions): Promise { + private async handleDeleteRequest(endpoint: string, params?: Record, options?: RequestOptions, body?: object): Promise { try { const headers = options?.idempotencyKey ? { "Idempotency-Key": options.idempotencyKey } : undefined; - const response = await this.apiClient.delete(endpoint, { params, headers }); + const response = await this.apiClient.delete(endpoint, { params, headers, data: body }); return response.data; } catch (error) { throw this.handleError(error as AxiosError); @@ -416,6 +418,19 @@ export class KeyMint { return this.handleRequest('/key/unblock', params, options); } + /** + * Permanently deletes a license key (use {@link blockKey} to stop it reversibly). + * @param params - Parameters for deleting the key. + * @param options - Custom request options (e.g. idempotency keys) + * @returns A promise that resolves with the deletion confirmation or rejects with an error. + */ + async deleteKey(params: DeleteKeyParams, options?: RequestOptions): Promise { + return this.handleDeleteRequest('/key', undefined, options, { + productId: params.productId, + licenseKey: params.licenseKey + }); + } + /** * Updates an existing license key. * @param params - Parameters for updating the key (productId + licenseKey required, all other fields optional). @@ -478,7 +493,7 @@ export class KeyMint { } /** - * Deletes a customer and all associated license keys permanently. + * Deletes a customer. Their license keys are kept; use {@link deleteKey} to remove them. * @param params - Parameters for deleting the customer. * @param options - Custom request options (e.g. idempotency keys) * @returns A promise that resolves with the deletion confirmation or rejects with an error. diff --git a/src/types.ts b/src/types.ts index c54f432..8285b35 100644 --- a/src/types.ts +++ b/src/types.ts @@ -207,6 +207,22 @@ export interface UnblockKeyResponse { code: number; // API response code (e.g., 0 for success) } +/** + * Parameters for the deleteKey API endpoint. + */ +export interface DeleteKeyParams { + productId: string; // Required: The unique identifier of the product. + licenseKey: string; // Required: The license key to delete permanently. +} + +/** + * Response structure for a successful deleteKey API call. + */ +export interface DeleteKeyResponse { + message: string; // Confirmation message (e.g., "Key deleted") + code: number; // API response code (e.g., 0 for success) +} + /** * Parameters for the createCustomer API endpoint. */ diff --git a/tests/contracts.test.cjs b/tests/contracts.test.cjs index a6b3a8d..feec43a 100644 --- a/tests/contracts.test.cjs +++ b/tests/contracts.test.cjs @@ -115,3 +115,49 @@ test('activateKey tolerates older servers that omit expirationDate', async () => assert.equal(result.expirationDate, undefined); }); }); + +function readJson(request) { + return new Promise((resolve, reject) => { + let raw = ''; + request.on('data', chunk => { raw += chunk; }); + request.on('end', () => { + try { resolve({ raw, body: JSON.parse(raw) }); } catch (err) { reject(err); } + }); + request.on('error', reject); + }); +} + +test('deleteKey sends DELETE /key with the license in the JSON body, never the URL', async () => { + const licenseKey = 'secret/license+key'; + await withServer(async (request, response) => { + assert.equal(request.method, 'DELETE'); + assert.equal(request.url, '/key'); + assert.ok(!decodeURIComponent(request.url).includes(licenseKey)); + assert.equal(request.headers.authorization, 'Bearer admin_test'); + assert.match(request.headers['content-type'], /^application\/json/); + const { body } = await readJson(request); + assert.deepEqual(body, { productId: 'product_123', licenseKey }); + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify({ message: 'Key deleted', code: 0 })); + }, async baseUrl => { + const result = await new KeyMint('admin_test', baseUrl).deleteKey({ productId: 'product_123', licenseKey }); + assert.deepEqual(result, { message: 'Key deleted', code: 0 }); + }); +}); + +test('deleteKey rejects an unknown key with the SDK error shape', async () => { + await withServer((request, response) => { + request.resume(); + response.statusCode = 404; + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify({ message: 'Invalid license key', code: 1 })); + }, async baseUrl => { + await assert.rejects( + () => new KeyMint('admin_test', baseUrl).deleteKey({ productId: 'product_123', licenseKey: 'missing' }), + error => { + assert.deepEqual(error, { message: 'Invalid license key', code: 1, status: 404 }); + return true; + } + ); + }); +}); diff --git a/tests/live.test.cjs b/tests/live.test.cjs index 2ba4255..ae5e37d 100644 --- a/tests/live.test.cjs +++ b/tests/live.test.cjs @@ -118,9 +118,20 @@ test('current license and customer workflows work end to end', async () => { assert.equal((await live(() => admin.toggleCustomerStatus({ customerId }))).status, true); assert.equal((await live(() => admin.deleteCustomer({ customerId }))).status, true); customerId = undefined; + + const deleted = await live(() => admin.deleteKey({ productId, licenseKey: nodeKey })); + assert.equal(deleted.code, 0); + const deletedKey = nodeKey; + nodeKey = undefined; + await assert.rejects( + () => live(() => readOnly.getKey({ productId, licenseKey: deletedKey })), + error => error.status >= 400 && error.status < 500 && error.status !== 429 + ); } finally { + // Delete (not just block) what this run created so the shared test workspace + // does not fill up its license limit. if (customerId) await live(() => admin.deleteCustomer({ customerId })).catch(() => {}); - if (nodeKey) await live(() => admin.blockKey({ productId, licenseKey: nodeKey })).catch(() => {}); - if (floatingKey) await live(() => admin.blockKey({ productId, licenseKey: floatingKey })).catch(() => {}); + if (nodeKey) await live(() => admin.deleteKey({ productId, licenseKey: nodeKey })).catch(() => {}); + if (floatingKey) await live(() => admin.deleteKey({ productId, licenseKey: floatingKey })).catch(() => {}); } });