diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 57d56f3..a0d8ddb 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -4,8 +4,10 @@ on: pull_request: branches: [ main, master ] push: + branches: [ main, master ] tags: - 'v*' + workflow_dispatch: jobs: validate: @@ -30,6 +32,29 @@ jobs: - name: Verify Compilation run: npm run build + - name: Run Contract Tests + run: npm test + + live-api-validation: + name: Live API Validation + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'npm' + - run: npm ci + - name: Run live API tests + env: + KEYMINT_TEST_ADMIN_API_KEY: ${{ secrets.KEYMINT_TEST_ADMIN_API_KEY }} + KEYMINT_TEST_READONLY_API_KEY: ${{ secrets.KEYMINT_TEST_READONLY_API_KEY }} + KEYMINT_TEST_CLIENT_API_KEY: ${{ secrets.KEYMINT_TEST_CLIENT_API_KEY }} + KEYMINT_TEST_PRODUCT_ID: ${{ vars.KEYMINT_TEST_PRODUCT_ID }} + KEYMINT_TEST_BASE_URL: https://api.keymint.dev + run: npm run test:live + publish: name: Publish to npm needs: validate diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dc34cec --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## 1.5.0 - 2026-09-09 + +### Fixed + +- Match offline signing, activation, bulk-key, deactivation, floating renewal, and customer contracts to the current API. +- Send detailed license lookup credentials through `x-license-key` rather than request URLs. +- Preserve actionable messages from nested API error envelopes. + +### Added + +- Deterministic HTTP contract tests and live end-to-end API validation. diff --git a/README.md b/README.md index 77dc689..8b131da 100644 --- a/README.md +++ b/README.md @@ -62,7 +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. | -| `signKey` | Signs a key for offline (air-gapped) validation.| +| `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. | | `floatingCheckin` | Checks in a session, releasing the seat. | diff --git a/package-lock.json b/package-lock.json index b233979..c3b183d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "keymint", - "version": "1.4.2", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "keymint", - "version": "1.4.2", + "version": "1.5.0", "license": "MIT", "dependencies": { "axios": "^1.20.0" diff --git a/package.json b/package.json index 63f517c..2342927 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keymint", - "version": "1.4.2", + "version": "1.5.0", "description": "License key validation, activation, and management for Node.js. Supports node-locking, offline licensing, and hardware fingerprinting.", "repository": { "type": "git", @@ -11,6 +11,8 @@ "scripts": { "build": "tsc", "lint": "eslint . --ext .ts", + "test": "npm run build && node --test tests/contracts.test.cjs", + "test:live": "npm run build && node --test tests/live.test.cjs", "prepublishOnly": "npm run build" }, "keywords": [ diff --git a/src/index.ts b/src/index.ts index f2041bc..4b510a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -256,9 +256,9 @@ export class KeyMint { * @param params - Query parameters * @returns A promise that resolves with the API response */ - private async handleGetRequest(endpoint: string, params?: Record): Promise { + private async handleGetRequest(endpoint: string, params?: Record, headers?: Record): Promise { try { - const response = await this.apiClient.get(endpoint, { params }); + const response = await this.apiClient.get(endpoint, { params, headers }); return response.data; } catch (error) { throw this.handleError(error as AxiosError); @@ -389,9 +389,8 @@ export class KeyMint { */ async getKey(params: GetKeyParams): Promise { return this.handleGetRequest('/key', { - productId: params.productId, - licenseKey: params.licenseKey - }); + productId: params.productId + }, { 'x-license-key': params.licenseKey }); } /** @@ -518,10 +517,15 @@ export class KeyMint { const responseData = axiosError.response.data; // The API returned a structured error message - if (typeof responseData === 'object' && responseData !== null && 'message' in responseData) { + if (typeof responseData === 'object' && responseData !== null) { + const nested = 'error' in responseData && typeof responseData.error === 'object' && responseData.error !== null + ? responseData.error as { message?: string } + : undefined; return { - message: responseData.message || defaultApiErrorMessage, - code: typeof responseData.code === 'number' ? responseData.code : -1, + message: ('message' in responseData && typeof responseData.message === 'string' ? responseData.message : undefined) + || nested?.message + || defaultApiErrorMessage, + code: 'code' in responseData && typeof responseData.code === 'number' ? responseData.code : -1, status: axiosError.response.status }; } else { diff --git a/src/types.ts b/src/types.ts index ef36577..07209b2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,7 +45,8 @@ export interface CreateKeyParams { */ export interface CreateKeyResponse { code: number; // API response code (e.g., 0 for success) - key: string; // The generated license key + key?: string; // The generated license key + keys?: string[]; // Generated keys for bulk creation } /** @@ -55,6 +56,7 @@ export interface KeyMintApiError { message: string; // Descriptive error message code: number; // API specific error code status?: number; // HTTP status code, optional + error?: { code?: string; message?: string; details?: unknown }; } /** @@ -103,6 +105,7 @@ export interface DeactivateKeyParams { export interface DeactivateKeyResponse { message: string; // Confirmation message (e.g., "Device deactivated") code: number; // API response code (e.g., 0 for success) + devicesRemoved?: number; } /** @@ -232,7 +235,7 @@ export interface Customer { active: boolean; createdAt: string; updatedAt: string; - createdBy: string; + createdBy?: string; } /** @@ -325,10 +328,12 @@ export interface ToggleCustomerStatusParams { * Response structure for a successful toggleCustomerStatus API call. */ export interface ToggleCustomerStatusResponse { - action: string; // Action performed (e.g., "toggleActive") + action?: string; status: boolean; // Success status - message: string; // Status message (e.g., "Customer disabled") - code: number; // API response code + message?: string; + code?: number; + customerName?: string; + active?: boolean; } /** @@ -375,6 +380,8 @@ export interface FloatingCheckoutParams { deviceTag?: string; // Optional: Friendly name for the device. userIdentifier?: string; // Optional: User identifier. apiKey?: string; // Optional: API key override. + timestamp?: string | number; + signature?: string; } /** @@ -481,8 +488,7 @@ export interface SignKeyParams { * Response structure for a successful signKey API call. */ export interface SignKeyResponse { - code: number; - file: Record; // Signed license file containing signedKey, keyId, publicKeyFingerprint + file: string; // Serialized signed license file returned by the API } /** diff --git a/tests/contracts.test.cjs b/tests/contracts.test.cjs new file mode 100644 index 0000000..1997ec8 --- /dev/null +++ b/tests/contracts.test.cjs @@ -0,0 +1,61 @@ +const http = require('node:http'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { KeyMint } = require('../dist/index.js'); + +async function withServer(handler, run) { + const server = http.createServer(handler); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + try { + const { port } = server.address(); + await run(`http://127.0.0.1:${port}`); + } finally { + await new Promise(resolve => server.close(resolve)); + } +} + +test('getKey sends the license in x-license-key, never the URL', async () => { + await withServer((request, response) => { + assert.equal(request.headers['x-license-key'], 'secret/license+key'); + assert.equal(new URL(request.url, 'http://localhost').searchParams.get('licenseKey'), null); + assert.equal(new URL(request.url, 'http://localhost').searchParams.get('productId'), 'product 123'); + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify({ code: 0, data: { license: { productId: 'product 123' } } })); + }, async baseUrl => { + const result = await new KeyMint('readonly_test', baseUrl).getKey({ + productId: 'product 123', + licenseKey: 'secret/license+key' + }); + assert.equal(result.data.license.productId, 'product 123'); + }); +}); + +test('signKey accepts the current serialized file response', async () => { + await withServer((_request, response) => { + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify({ file: '{"signedKey":"signature","keyId":"key_123"}' })); + }, async baseUrl => { + const result = await new KeyMint('admin_test', baseUrl).signKey({ + productId: 'product_123', licenseKey: 'license_123', hostId: 'host_123' + }); + assert.equal(typeof result.file, 'string'); + assert.match(result.file, /signedKey/); + }); +}); + +test('nested API errors preserve the actionable server message', async () => { + await withServer((_request, response) => { + response.statusCode = 409; + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify({ + success: false, + error: { code: 'CUSTOMER_EMAIL_EXISTS', message: 'Customer email already exists in team' }, + code: 1 + })); + }, async baseUrl => { + await assert.rejects( + () => new KeyMint('admin_test', baseUrl).createKey({ productId: 'product_123' }), + error => error.message === 'Customer email already exists in team' && error.code === 1 + ); + }); +}); diff --git a/tests/live.test.cjs b/tests/live.test.cjs new file mode 100644 index 0000000..e140e9e --- /dev/null +++ b/tests/live.test.cjs @@ -0,0 +1,97 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { KeyMint } = require('../dist/index.js'); + +function required(name) { + const value = process.env[name]; + assert.ok(value, `Missing required environment variable: ${name}`); + return value; +} + +test('current license and customer workflows work end to end', async () => { + const productId = required('KEYMINT_TEST_PRODUCT_ID'); + const baseUrl = process.env.KEYMINT_TEST_BASE_URL || 'https://api.keymint.dev'; + const admin = new KeyMint(required('KEYMINT_TEST_ADMIN_API_KEY'), baseUrl); + const readOnly = new KeyMint(required('KEYMINT_TEST_READONLY_API_KEY'), baseUrl); + const client = new KeyMint(required('KEYMINT_TEST_CLIENT_API_KEY'), baseUrl); + const runId = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const nodeHost = `node-ci-${runId}`; + let nodeKey; + let floatingKey; + let customerId; + + try { + const created = await admin.createKey({ + productId, + maxActivations: '2', + metadata: { purpose: 'node-sdk-live-test', runId } + }); + nodeKey = created.key; + assert.ok(nodeKey); + + const lookup = await readOnly.getKey({ productId, licenseKey: nodeKey }); + assert.equal(lookup.data.license.productId, productId); + + const activation = await client.activateKey({ + productId, licenseKey: nodeKey, hostId: nodeHost, deviceTag: 'Node SDK CI' + }); + assert.equal(activation.metadata.hostId, nodeHost); + + const deactivated = await client.deactivateKey({ productId, licenseKey: nodeKey, hostId: nodeHost }); + assert.equal(deactivated.devicesRemoved, 1); + await admin.updateKey({ productId, licenseKey: nodeKey, maxActivations: 3 }); + await admin.blockKey({ productId, licenseKey: nodeKey }); + await admin.unblockKey({ productId, licenseKey: nodeKey }); + + const floating = await admin.createKey({ + productId, + licenseType: 'floating', + maxConcurrentSessions: 1, + heartbeatInterval: 60, + sessionLeaseDuration: 300, + metadata: { purpose: 'node-sdk-floating-live-test', runId } + }); + floatingKey = floating.key; + assert.ok(floatingKey); + + const checkout = await client.floatingCheckout({ + productId, licenseKey: floatingKey, hostId: `node-floating-${runId}` + }); + const heartbeat = await client.floatingHeartbeat({ + productId, + licenseKey: floatingKey, + sessionId: checkout.sessionId, + timestamp: checkout.nextNonce, + signature: KeyMint.generateSessionSignature(checkout.sessionId, checkout.nextNonce, checkout.sessionSecret) + }); + await client.floatingCheckin({ + productId, + licenseKey: floatingKey, + sessionId: checkout.sessionId, + timestamp: heartbeat.nextNonce, + signature: KeyMint.generateSessionSignature(checkout.sessionId, heartbeat.nextNonce, checkout.sessionSecret) + }); + + const signed = await admin.signKey({ productId, licenseKey: nodeKey, hostId: nodeHost, ttl: 300 }); + assert.equal(typeof signed.file, 'string'); + assert.match(signed.file, /signedKey/); + + const customer = await admin.createCustomer({ name: 'Node SDK CI', email: `node-ci-${runId}@example.com` }); + customerId = customer.data.id; + assert.ok(customerId); + const fetched = await admin.getCustomerById({ customerId }); + assert.ok(fetched.data.some(item => item.id === customerId)); + await admin.updateCustomer({ customerId, name: 'Node SDK CI Updated' }); + const customers = await admin.getAllCustomers(); + assert.ok(customers.data.some(item => item.id === customerId)); + assert.ok(Array.isArray(await admin.getCustomerWithKeys({ customerId }))); + assert.equal((await admin.toggleCustomerStatus({ customerId })).status, true); + assert.equal((await admin.toggleCustomerStatus({ customerId })).status, true); + assert.equal((await admin.deleteCustomer({ customerId })).status, true); + customerId = undefined; + } finally { + if (customerId) await admin.deleteCustomer({ customerId }).catch(() => {}); + if (nodeKey) await admin.blockKey({ productId, licenseKey: nodeKey }).catch(() => {}); + if (floatingKey) await admin.blockKey({ productId, licenseKey: floatingKey }).catch(() => {}); + } +});