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
25 changes: 25 additions & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ on:
pull_request:
branches: [ main, master ]
push:
branches: [ main, master ]
tags:
- 'v*'
workflow_dispatch:

jobs:
validate:
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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": [
Expand Down
20 changes: 12 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,9 +256,9 @@ export class KeyMint {
* @param params - Query parameters
* @returns A promise that resolves with the API response
*/
private async handleGetRequest<T>(endpoint: string, params?: Record<string, any>): Promise<T> {
private async handleGetRequest<T>(endpoint: string, params?: Record<string, any>, headers?: Record<string, string>): Promise<T> {
try {
const response = await this.apiClient.get<T>(endpoint, { params });
const response = await this.apiClient.get<T>(endpoint, { params, headers });
return response.data;
} catch (error) {
throw this.handleError(error as AxiosError<KeyMintApiError>);
Expand Down Expand Up @@ -389,9 +389,8 @@ export class KeyMint {
*/
async getKey(params: GetKeyParams): Promise<GetKeyResponse> {
return this.handleGetRequest<GetKeyResponse>('/key', {
productId: params.productId,
licenseKey: params.licenseKey
});
productId: params.productId
}, { 'x-license-key': params.licenseKey });
}

/**
Expand Down Expand Up @@ -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 {
Expand Down
20 changes: 13 additions & 7 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand All @@ -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 };
}

/**
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -232,7 +235,7 @@ export interface Customer {
active: boolean;
createdAt: string;
updatedAt: string;
createdBy: string;
createdBy?: string;
}

/**
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -481,8 +488,7 @@ export interface SignKeyParams {
* Response structure for a successful signKey API call.
*/
export interface SignKeyResponse {
code: number;
file: Record<string, any>; // Signed license file containing signedKey, keyId, publicKeyFingerprint
file: string; // Serialized signed license file returned by the API
}

/**
Expand Down
61 changes: 61 additions & 0 deletions tests/contracts.test.cjs
Original file line number Diff line number Diff line change
@@ -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
);
});
});
97 changes: 97 additions & 0 deletions tests/live.test.cjs
Original file line number Diff line number Diff line change
@@ -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(() => {});
}
});
Loading