diff --git a/CHANGELOG.md b/CHANGELOG.md index dc34cec..1c2045d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Added + +- `expirationDate` on `ActivateKeyResponse` and `FloatingCheckoutResponse`: the license expiry as an ISO 8601 string, or `null` when the license never expires. Typed as optional (`expirationDate?: string | null`) for compatibility with older servers. On checkout it is separate from `expiresAt`, the floating seat lease. + ## 1.5.0 - 2026-09-09 ### Fixed diff --git a/README.md b/README.md index 8b131da..4f2e4d1 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,27 @@ Keymint provides utilities to uniquely identify machines for node-locking: |-------------------------|--------------------------------------------------| | `verifyWebhookSignature`| Verifies the signature of a webhook request payload. | +## License Expiry + +`activateKey` and `floatingCheckout` return `expirationDate`: when the license expires, as an ISO 8601 string, or `null` if it never expires. On `floatingCheckout` it is separate from `expiresAt`, which is the floating seat lease. The field is optional in the types because older API servers may omit it. + +```typescript +const res = await sdk.activateKey({ productId, licenseKey, hostId }); + +if (res.expirationDate) { + const daysLeft = Math.max(0, Math.ceil((new Date(res.expirationDate).getTime() - Date.now()) / 86_400_000)); + console.log(`License expires on ${res.expirationDate} (${daysLeft} days left)`); +} else if (res.expirationDate === null) { + console.log('License never expires'); +} + +// Store the last value you received (e.g. in your app's settings). +// `undefined` means an older server didn't send the field, so keep the previous value. +if (res.expirationDate !== undefined) saveLastExpirationDate(res.expirationDate); +``` + +Store the last `expirationDate` your app received. An expired license fails activation with the same generic error as an invalid key, so the stored date is what lets your app tell the user their license expired on that date instead of calling the key invalid. + ## 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: diff --git a/src/types.ts b/src/types.ts index 07209b2..c54f432 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,6 +82,11 @@ export interface ActivateKeyResponse { message: string; // Activation status message (e.g., "License valid") licenseeName?: string; // Optional: Name of the licensee (updated field name) licenseeEmail?: string; // Optional: Email of the licensee (updated field name) + /** + * When the license expires, as an ISO 8601 date string, or `null` if it never expires. + * Optional because older API servers may omit it. + */ + expirationDate?: string | null; metadata?: Record | null; // Optional: Custom dictionary attached to the license key. versionId?: string | null; // Optional: Associated product version ID. version?: { // Optional: Detailed product version information. @@ -393,9 +398,14 @@ export interface FloatingCheckoutResponse { sessionId: string; sessionSecret: string; nextNonce: string; - expiresAt: string; + expiresAt: string; // Floating seat lease expiry (not the license expiry). heartbeatInterval: number; metadata?: Record; + /** + * When the license itself expires, as an ISO 8601 date string, or `null` if it never expires. + * Separate from `expiresAt`, which is the floating seat lease. Optional because older API servers may omit it. + */ + expirationDate?: string | null; currentSessions?: number; maxSessions?: number | null; licenseeName?: string; diff --git a/tests/contracts.test.cjs b/tests/contracts.test.cjs index 1997ec8..a6b3a8d 100644 --- a/tests/contracts.test.cjs +++ b/tests/contracts.test.cjs @@ -59,3 +59,59 @@ test('nested API errors preserve the actionable server message', async () => { ); }); }); + +function jsonResponder(path, body) { + return (request, response) => { + assert.equal(request.method, 'POST'); + assert.equal(request.url, path); + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify(body)); + }; +} + +for (const expirationDate of ['2027-03-01T00:00:00.000Z', null]) { + test(`activateKey passes through expirationDate (${expirationDate})`, async () => { + await withServer(jsonResponder('/key/activate', { + code: 0, message: 'License valid', expirationDate, metadata: null + }), async baseUrl => { + const result = await new KeyMint('client_test', baseUrl).activateKey({ + productId: 'product_123', licenseKey: 'license_123', hostId: 'host_123' + }); + assert.equal(result.code, 0); + assert.ok('expirationDate' in result); + assert.equal(result.expirationDate, expirationDate); + }); + }); + + test(`floatingCheckout passes through expirationDate separately from expiresAt (${expirationDate})`, async () => { + await withServer(jsonResponder('/key/checkout', { + code: 0, + message: 'Session checked out', + sessionId: 'session_1234567890123', + sessionSecret: 'secret', + nextNonce: 'nonce', + expiresAt: '2026-10-26T12:30:00.000Z', + heartbeatInterval: 300, + metadata: {}, + expirationDate, + currentSessions: 1, + maxSessions: 5 + }), async baseUrl => { + const result = await new KeyMint('client_test', baseUrl).floatingCheckout({ + productId: 'product_123', licenseKey: 'license_123', hostId: 'host_123' + }); + assert.equal(result.expiresAt, '2026-10-26T12:30:00.000Z'); + assert.ok('expirationDate' in result); + assert.equal(result.expirationDate, expirationDate); + }); + }); +} + +test('activateKey tolerates older servers that omit expirationDate', async () => { + await withServer(jsonResponder('/key/activate', { code: 0, message: 'License valid' }), async baseUrl => { + const result = await new KeyMint('client_test', baseUrl).activateKey({ + productId: 'product_123', licenseKey: 'license_123' + }); + assert.equal(result.expirationDate, undefined); + }); +});