npm package: @licensesos/node-sdk
Description: Official Node.js/TypeScript SDK for LicensesOS license management
Author: LicensesOS
Latest version: 1.0.0

# @licenseos/node-sdk

Official Node.js/TypeScript SDK for [LicensesOS](https://licenseos.com) -- software license management, validation, activation, and usage metering.

## Installation

```bash
npm install @licenseos/node-sdk
```

Requires Node.js 18 or higher. Zero runtime dependencies (uses native `fetch` and `crypto`).

## Quick Start

```typescript
import { LicensesOSClient } from '@licenseos/node-sdk';

const client = new LicensesOSClient('your-api-key');

const result = await client.validate('XXXX-XXXX-XXXX-XXXX');

if (result.isValid()) {
  console.log('License is valid');
} else {
  console.log('Invalid:', result.error?.message);
}
```

## Configuration

The client constructor accepts an API key (required) and an optional configuration object:

```typescript
const client = new LicensesOSClient('your-api-key', {
  baseUrl: 'https://api.licenseos.com', // default
  timeout: 30_000,                      // request timeout in ms, default 30s
});
```

| Option    | Type     | Default                          | Description                    |
|-----------|----------|----------------------------------|--------------------------------|
| `baseUrl` | `string` | `https://api.licenseos.com`      | API base URL                   |
| `timeout` | `number` | `30000`                          | Request timeout in milliseconds |

## License Validation

### Basic Validation

```typescript
const result = await client.validate('XXXX-XXXX-XXXX-XXXX');

if (result.isValid()) {
  console.log('Status:', result.license?.status);
}
```

### Validation with Identifier

Pass a domain or device identifier to check whether the license is activated for that identifier:

```typescript
const result = await client.validate('XXXX-XXXX-XXXX-XXXX', 'example.com');

if (result.isValid()) {
  console.log('Activated:', result.activated);
  console.log('Remaining activations:', result.getRemainingActivations());
}
```

### Validation with Metadata

```typescript
const result = await client.validate('XXXX-XXXX-XXXX-XXXX', 'example.com', {
  app_version: '2.1.0',
  os: 'darwin',
});
```

### Checking Entitlements

```typescript
const result = await client.validate('XXXX-XXXX-XXXX-XXXX');

if (result.isValid()) {
  if (result.hasEntitlement('premium_features')) {
    enablePremium();
  }

  const maxSeats = result.getEntitlement('max_seats', 1);
  console.log('Max seats:', maxSeats);
}
```

### Checking Status

```typescript
if (result.isExpired()) {
  console.log('License has expired');
}

if (result.isRevoked()) {
  console.log('License has been revoked');
}

if (result.isActive()) {
  console.log('License is active');
}
```

### ValidationResult Properties

| Property       | Type                                         | Description                              |
|----------------|----------------------------------------------|------------------------------------------|
| `valid`        | `boolean`                                    | Whether the license is valid             |
| `license`      | `ValidationLicenseData \| { status } \| undefined` | License details                    |
| `activated`    | `boolean \| undefined`                       | Whether the identifier is activated      |
| `activation`   | `ValidationActivationData \| undefined`      | Activation details if activated          |
| `offlineToken` | `string \| undefined`                        | Offline validation token (if policy allows) |
| `error`        | `{ code, message } \| undefined`             | Error details if validation failed       |

| Method                     | Returns          | Description                              |
|----------------------------|------------------|------------------------------------------|
| `isValid()`                | `boolean`        | Whether the license is valid             |
| `isActive()`               | `boolean`        | Whether the status is `active`           |
| `isExpired()`              | `boolean`        | Whether the license is expired           |
| `isRevoked()`              | `boolean`        | Whether the license is revoked           |
| `hasEntitlement(key)`      | `boolean`        | Check if an entitlement key exists       |
| `getEntitlement(key, def)` | `unknown`        | Get an entitlement value with default    |
| `getRemainingActivations()`| `number \| null` | Remaining activations, null if unlimited |
| `toJSON()`                 | `object`         | Serialize to a plain object              |

## Activation Management

### Activate a License

```typescript
const result = await client.activate('XXXX-XXXX-XXXX-XXXX', 'example.com', {
  label: 'Production Server',
  metadata: { server_id: 'srv-01' },
});

console.log('Activation ID:', result.activation.id);
console.log('Remaining:', result.getRemainingActivations());
```

### Deactivate a License

```typescript
const result = await client.deactivate('XXXX-XXXX-XXXX-XXXX', 'example.com');

if (result.isDeactivated()) {
  console.log('Successfully deactivated');
}
```

### List Activations

```typescript
const result = await client.listActivations('XXXX-XXXX-XXXX-XXXX');

console.log('Total:', result.count);
console.log('Limit:', result.limit);
console.log('Active:', result.getActiveCount());

for (const activation of result.activations) {
  console.log(`${activation.identifier} - ${activation.status}`);
}

// Check if a specific identifier is activated
if (result.hasActivation('example.com')) {
  console.log('example.com is activated');
}
```

## Heartbeat Check-ins

Send periodic heartbeat signals to confirm an activation is still alive. The server returns the deadline for the next required check-in.

```typescript
const result = await client.heartbeat('XXXX-XXXX-XXXX-XXXX', 'example.com');

console.log('Next check-in at:', result.nextCheckInAt);
console.log('Ms until next:', result.getMillisecondsUntilNextCheckIn());
```

### Automatic Heartbeat with setInterval

```typescript
async function startHeartbeat(licenseKey: string, identifier: string) {
  const sendHeartbeat = async () => {
    try {
      const result = await client.heartbeat(licenseKey, identifier);
      console.log('Heartbeat OK, next at:', result.nextCheckInAt);
    } catch (err) {
      console.error('Heartbeat failed:', err);
    }
  };

  // Send immediately, then repeat every 5 minutes
  await sendHeartbeat();
  setInterval(sendHeartbeat, 5 * 60 * 1000);
}
```

### HeartbeatResult Properties

| Property        | Type     | Description                          |
|-----------------|----------|--------------------------------------|
| `activation`    | `object` | The activation details               |
| `nextCheckInAt` | `string` | ISO 8601 timestamp of next deadline  |

| Method                            | Returns  | Description                            |
|-----------------------------------|----------|----------------------------------------|
| `getNextCheckInDate()`            | `Date`   | Next check-in as a Date object         |
| `getMillisecondsUntilNextCheckIn()` | `number` | Ms remaining until next check-in     |

## Usage Metering

Track and enforce API call counts, export quotas, or any usage-based limit.

### Increment Usage

```typescript
const result = await client.incrementUsage('XXXX-XXXX-XXXX-XXXX');

console.log('Current uses:', result.usage.uses_count);
console.log('Max uses:', result.usage.max_uses);
console.log('Remaining:', result.usage.uses_remaining);
```

### Increment by a Specific Amount

```typescript
const result = await client.incrementUsage('XXXX-XXXX-XXXX-XXXX', 5);
```

### Check Usage Limits

```typescript
if (result.isLimitReached()) {
  console.log('Usage limit reached');
}

const pct = result.getUsagePercentage();
if (pct !== null && pct > 80) {
  console.log(`Warning: ${pct}% of usage limit consumed`);
}

const resetDate = result.getResetDate();
if (resetDate) {
  console.log('Usage resets at:', resetDate.toISOString());
}
```

### UsageResult Properties

| Property | Type        | Description                       |
|----------|-------------|-----------------------------------|
| `usage`  | `UsageData` | Usage counts, limits, and resets  |

The `usage` object contains:

| Field                 | Type             | Description                           |
|-----------------------|------------------|---------------------------------------|
| `uses_count`          | `number`         | Current usage count                   |
| `max_uses`            | `number \| null` | Maximum allowed uses, null if unlimited |
| `uses_remaining`      | `number \| null` | Remaining uses, null if unlimited     |
| `usage_reset_interval`| `string \| null` | Reset interval (`monthly`, `yearly`)  |
| `uses_reset_at`       | `string \| null` | Next reset timestamp (ISO 8601)       |

| Method               | Returns          | Description                            |
|----------------------|------------------|----------------------------------------|
| `isLimitReached()`   | `boolean`        | Whether the usage limit has been hit   |
| `getUsagePercentage()` | `number \| null` | Usage as a percentage of the limit   |
| `getResetDate()`     | `Date \| null`   | Next usage reset as a Date object      |

## Offline Validation

When a policy has offline validation enabled, the `validate()` response includes a signed token. You can verify this token locally without contacting the API server.

### Step 1: Obtain an Offline Token

```typescript
const result = await client.validate('XXXX-XXXX-XXXX-XXXX', 'example.com');

if (result.isValid() && result.offlineToken) {
  // Persist the token (e.g., to a file or database)
  fs.writeFileSync('license.token', result.offlineToken);
}
```

### Step 2: Validate Offline

```typescript
import { OfflineValidator } from '@licenseos/node-sdk';

// Your app's Ed25519 public key (base64-encoded, from the signing-key endpoint)
const validator = new OfflineValidator('base64-encoded-public-key');

const token = fs.readFileSync('license.token', 'utf-8');
const result = validator.validate(token);

if (result.isValid()) {
  console.log('License valid offline');
  console.log('Status:', result.status);
  console.log('Entitlements:', result.entitlements);
  console.log('Expires in:', result.remainingSeconds(), 'seconds');
} else if (result.isExpired()) {
  console.log('Token expired -- re-validate online for a fresh token');
} else {
  console.log('Invalid token:', result.errorCode);
}
```

### OfflineValidationResult Properties

| Property           | Type                              | Description                         |
|--------------------|-----------------------------------|-------------------------------------|
| `valid`            | `boolean`                         | Whether the token is valid          |
| `status`           | `string`                          | License status (`active`, etc.)     |
| `errorCode`        | `string \| null`                  | Error code if invalid               |
| `licenseId`        | `string \| null`                  | License ID                          |
| `entitlements`     | `Record<string, unknown> \| null` | License entitlements                |
| `expiresAt`        | `string \| null`                  | License expiration (ISO 8601)       |
| `activationLimit`  | `number \| null`                  | Maximum activations                 |
| `activationCount`  | `number`                          | Current activation count            |
| `identifier`       | `string \| null`                  | Device/domain identifier            |
| `activated`        | `boolean`                         | Whether identifier is activated     |
| `issuedAt`         | `number`                          | Token issue timestamp (Unix)        |
| `expiresAtTimestamp`| `number`                         | Token expiry timestamp (Unix)       |
| `ttl`              | `number`                          | Token TTL in seconds                |
| `usesCount`        | `number`                          | Current usage count                 |
| `maxUses`          | `number \| null`                  | Maximum allowed uses                |

| Method              | Returns   | Description                              |
|---------------------|-----------|------------------------------------------|
| `isValid()`         | `boolean` | Whether the token is valid               |
| `isExpired()`       | `boolean` | Whether the token has expired            |
| `remainingSeconds()`| `number`  | Seconds remaining before token expiry    |

## Machine Fingerprinting

Generate a stable, unique machine identifier for device-locked licenses. Requires the optional `node-machine-id` peer dependency.

```bash
npm install node-machine-id
```

```typescript
import { getMachineId } from '@licenseos/node-sdk';

const machineId = await getMachineId();
const result = await client.validate('XXXX-XXXX-XXXX-XXXX', machineId);
```

A synchronous variant is also available, but `getMachineId()` must be called at least once first to load the module:

```typescript
import { getMachineId, getMachineIdSync } from '@licenseos/node-sdk';

// Initialize the module once at startup
await getMachineId();

// Then use the sync version in hot paths
const id = getMachineIdSync();
```

## Error Handling

The SDK throws two error types, both extending `LicensesOSError`:

- **`ApiError`** -- The API returned a non-2xx response with an error body. Has `code` (machine-readable string like `LICENSE_NOT_FOUND`) and `statusCode` (HTTP status).
- **`NetworkError`** -- A network-level failure occurred (timeout, DNS resolution, connection refused). Has an optional `cause` with the underlying error.

```typescript
import { ApiError, NetworkError } from '@licenseos/node-sdk';

try {
  const result = await client.activate('XXXX-XXXX-XXXX-XXXX', 'example.com');
} catch (err) {
  if (err instanceof ApiError) {
    console.error(`API error [${err.code}]: ${err.message} (HTTP ${err.statusCode})`);

    switch (err.code) {
      case 'LICENSE_NOT_FOUND':
        console.error('The license key does not exist');
        break;
      case 'ACTIVATION_LIMIT_REACHED':
        console.error('No more activations available');
        break;
      case 'LICENSE_SUSPENDED':
        console.error('This license has been suspended');
        break;
    }
  } else if (err instanceof NetworkError) {
    console.error('Network error:', err.message);
    // Fall back to cached state or offline validation
  }
}
```

Note: The `validate()` method does **not** throw for invalid licenses. An invalid license returns a `ValidationResult` with `valid: false` and an `error` object. `ApiError` is only thrown for actual API errors (authentication failure, server error, etc.).

## Domain Normalization

The SDK automatically normalizes domain/URL identifiers before sending them to the API. The static method is also available for direct use:

```typescript
import { LicensesOSClient } from '@licenseos/node-sdk';

LicensesOSClient.normalizeDomain('https://WWW.Example.Com:8080/path');
// Returns: 'example.com'

LicensesOSClient.normalizeDomain('HTTP://blog.example.com/');
// Returns: 'blog.example.com'
```

Normalization rules:
- Lowercases and trims whitespace
- Strips `http://` and `https://` protocol prefixes
- Strips paths, ports, and `www.` prefix

## Grace Period / Cached State

For resilience when the API is unreachable, cache the validation status and timestamp, then use `shouldAllowPremium()` to decide if the cached result is still trustworthy:

```typescript
import { LicensesOSClient } from '@licenseos/node-sdk';

// After a successful validation, cache the state
const cachedState = {
  status: result.license?.status,
  cached_at: Math.floor(Date.now() / 1000),
};
saveToStorage(cachedState);

// Later, when the API is unreachable
const state = loadFromStorage();
if (LicensesOSClient.shouldAllowPremium(state)) {
  // Cache is fresh enough -- allow premium features
  enablePremium();
} else {
  // Cache is stale -- require online validation
  disablePremium();
}
```

The default grace period is 48 hours beyond the 12-hour cache TTL (60 hours total). You can customize it:

```typescript
// Custom grace period: 24 hours (in seconds)
LicensesOSClient.shouldAllowPremium(state, 86_400);

// No grace period -- strict 12-hour cache TTL only
LicensesOSClient.shouldAllowPremium(state, 0);
```

## TypeScript

The SDK is written in TypeScript and ships with full type definitions. All response types are exported for direct use:

```typescript
import type {
  LicensesOSClientConfig,
  ValidationResponseData,
  ValidationLicenseData,
  ActivationDetailData,
  HeartbeatResponseData,
  UsageData,
  OfflineTokenPayload,
} from '@licenseos/node-sdk';
```

Both ESM (`import`) and CommonJS (`require`) are supported.

## License

MIT