sentinel-js-client 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Demeng Chen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,14 @@
1
+ [![npm](https://img.shields.io/npm/v/sentinel-js-client)](https://www.npmjs.com/package/sentinel-js-client)
2
+
3
+ # Sentinel Node.js Client
4
+
5
+ Official Node.js client library for the [Sentinel](https://demeng.dev/sentinel) v2 API.
6
+
7
+ ## Documentation
8
+
9
+ Full documentation is available on GitBook:
10
+ [https://demeng.gitbook.io/sentinel/clients/node](https://demeng.gitbook.io/sentinel/clients/node)
11
+
12
+ ## License
13
+
14
+ [MIT](LICENSE)
@@ -0,0 +1,24 @@
1
+ import { ConnectionOperations } from './operations/connections.js';
2
+ import { IpOperations } from './operations/ips.js';
3
+ import { ServerOperations } from './operations/servers.js';
4
+ import { SubUserOperations } from './operations/sub-users.js';
5
+ import type { CreateLicenseRequest, License, ListLicensesRequest, Page, SentinelClientOptions, UpdateLicenseRequest, ValidateRequest, ValidationDetails, ValidationResult } from './types.js';
6
+ export declare class SentinelClient {
7
+ readonly connections: ConnectionOperations;
8
+ readonly subUsers: SubUserOperations;
9
+ readonly servers: ServerOperations;
10
+ readonly ips: IpOperations;
11
+ private readonly http;
12
+ private readonly signatureVerifier?;
13
+ private readonly replayProtector?;
14
+ constructor(options: SentinelClientOptions);
15
+ validate(request: ValidateRequest): Promise<ValidationResult>;
16
+ create(request: CreateLicenseRequest): Promise<License>;
17
+ get(key: string): Promise<License>;
18
+ list(request?: ListLicensesRequest): Promise<Page<License>>;
19
+ update(key: string, request: UpdateLicenseRequest): Promise<License>;
20
+ delete(key: string): Promise<void>;
21
+ regenerateKey(key: string, newKey?: string): Promise<License>;
22
+ private buildUpdateBody;
23
+ }
24
+ export declare function requireValid(result: ValidationResult): ValidationDetails;
package/dist/client.js ADDED
@@ -0,0 +1,147 @@
1
+ import { LicenseValidationError, SentinelError } from './errors.js';
2
+ import { getMachineFingerprint } from './fingerprint.js';
3
+ import { SentinelHttpClient } from './http.js';
4
+ import { ConnectionOperations } from './operations/connections.js';
5
+ import { IpOperations } from './operations/ips.js';
6
+ import { ServerOperations } from './operations/servers.js';
7
+ import { SubUserOperations } from './operations/sub-users.js';
8
+ import { parseApiResponse, parseLicense, parsePage, parseValidationResponse } from './parsing.js';
9
+ import { ReplayProtector } from './replay.js';
10
+ import { SignatureVerifier } from './signature.js';
11
+ export class SentinelClient {
12
+ connections;
13
+ subUsers;
14
+ servers;
15
+ ips;
16
+ http;
17
+ signatureVerifier;
18
+ replayProtector;
19
+ constructor(options) {
20
+ if (!options.baseUrl) {
21
+ throw new SentinelError('baseUrl is required');
22
+ }
23
+ if (!options.apiKey) {
24
+ throw new SentinelError('apiKey is required');
25
+ }
26
+ if (options.replayProtection && !options.publicKey) {
27
+ throw new SentinelError('replayProtection requires publicKey to be set');
28
+ }
29
+ this.http = new SentinelHttpClient(options.baseUrl, options.apiKey, options.timeout ?? 10_000);
30
+ if (options.publicKey) {
31
+ this.signatureVerifier = new SignatureVerifier(options.publicKey);
32
+ this.replayProtector = new ReplayProtector(options.replayProtection?.maxTimestampAge ?? 30_000, options.replayProtection?.cacheSize ?? 1_000);
33
+ }
34
+ this.connections = new ConnectionOperations(this.http);
35
+ this.subUsers = new SubUserOperations(this.http);
36
+ this.servers = new ServerOperations(this.http);
37
+ this.ips = new IpOperations(this.http);
38
+ }
39
+ async validate(request) {
40
+ const body = { product: request.product };
41
+ if (request.key !== undefined)
42
+ body.key = request.key;
43
+ if (request.connectionPlatform !== undefined)
44
+ body.connectionPlatform = request.connectionPlatform;
45
+ if (request.connectionValue !== undefined)
46
+ body.connectionValue = request.connectionValue;
47
+ body.server = request.server ?? getMachineFingerprint();
48
+ if (request.ip !== undefined)
49
+ body.ip = request.ip;
50
+ const response = await this.http.request('POST', '/api/v2/licenses/validate', body);
51
+ const apiResponse = parseApiResponse(response, 403);
52
+ const parsed = parseValidationResponse(apiResponse);
53
+ if (apiResponse.statusCode === 200 && this.signatureVerifier) {
54
+ if (parsed.rawPayload !== null) {
55
+ this.signatureVerifier.verify(parsed.rawPayload, parsed.signature);
56
+ if (parsed.nonce !== null && parsed.timestamp !== null) {
57
+ this.replayProtector?.check(parsed.nonce, parsed.timestamp);
58
+ }
59
+ }
60
+ }
61
+ return parsed.result;
62
+ }
63
+ async create(request) {
64
+ const response = await this.http.request('POST', '/api/v2/licenses', request);
65
+ const apiResponse = parseApiResponse(response);
66
+ return parseLicense(apiResponse.result.license);
67
+ }
68
+ async get(key) {
69
+ const response = await this.http.request('GET', `/api/v2/licenses/${encodeURIComponent(key)}`);
70
+ const apiResponse = parseApiResponse(response);
71
+ return parseLicense(apiResponse.result.license);
72
+ }
73
+ async list(request) {
74
+ const query = new URLSearchParams();
75
+ if (request) {
76
+ for (const [key, value] of Object.entries(request)) {
77
+ if (value !== undefined)
78
+ query.set(key, String(value));
79
+ }
80
+ }
81
+ const response = await this.http.request('GET', '/api/v2/licenses', undefined, query.size > 0 ? query : undefined);
82
+ const apiResponse = parseApiResponse(response);
83
+ return parsePage(apiResponse.result.page);
84
+ }
85
+ async update(key, request) {
86
+ const body = this.buildUpdateBody(request);
87
+ const response = await this.http.request('PATCH', `/api/v2/licenses/${encodeURIComponent(key)}`, body);
88
+ const apiResponse = parseApiResponse(response);
89
+ return parseLicense(apiResponse.result.license);
90
+ }
91
+ async delete(key) {
92
+ const response = await this.http.request('DELETE', `/api/v2/licenses/${encodeURIComponent(key)}`);
93
+ parseApiResponse(response);
94
+ }
95
+ async regenerateKey(key, newKey) {
96
+ const query = newKey ? new URLSearchParams({ newKey }) : undefined;
97
+ const response = await this.http.request('POST', `/api/v2/licenses/${encodeURIComponent(key)}/regenerate-key`, undefined, query);
98
+ const apiResponse = parseApiResponse(response);
99
+ return parseLicense(apiResponse.result.license);
100
+ }
101
+ buildUpdateBody(request) {
102
+ const body = {};
103
+ if (request.product !== undefined)
104
+ body.product = request.product;
105
+ if (request.tier !== undefined)
106
+ body.tier = request.tier;
107
+ if (request.expiration === null) {
108
+ body.expiration = '1970-01-01T00:00:00Z';
109
+ }
110
+ else if (request.expiration !== undefined) {
111
+ body.expiration = request.expiration.toISOString();
112
+ }
113
+ if (request.maxServers !== undefined)
114
+ body.maxServers = request.maxServers;
115
+ if (request.maxIps !== undefined)
116
+ body.maxIps = request.maxIps;
117
+ if (request.note === null) {
118
+ body.note = '';
119
+ }
120
+ else if (request.note !== undefined) {
121
+ body.note = request.note;
122
+ }
123
+ if (request.connections !== undefined)
124
+ body.connections = request.connections;
125
+ if (request.subUsers !== undefined)
126
+ body.subUsers = request.subUsers;
127
+ if (request.servers !== undefined)
128
+ body.servers = request.servers;
129
+ if (request.ips !== undefined)
130
+ body.ips = request.ips;
131
+ if (request.additionalEntitlements !== undefined)
132
+ body.additionalEntitlements = request.additionalEntitlements;
133
+ if (request.blacklistReason === null) {
134
+ body.blacklistReason = '';
135
+ }
136
+ else if (request.blacklistReason !== undefined) {
137
+ body.blacklistReason = request.blacklistReason;
138
+ }
139
+ return body;
140
+ }
141
+ }
142
+ export function requireValid(result) {
143
+ if (!result.success) {
144
+ throw new LicenseValidationError(result.message, result.type, result.details);
145
+ }
146
+ return result.details;
147
+ }
@@ -0,0 +1,27 @@
1
+ import type { FailureDetails, ValidationResultType } from './types.js';
2
+ export declare class SentinelError extends Error {
3
+ name: string;
4
+ }
5
+ export declare class SentinelApiError extends SentinelError {
6
+ name: string;
7
+ readonly statusCode: number;
8
+ readonly retryAfter?: number;
9
+ readonly type?: string;
10
+ constructor(message: string, statusCode: number, retryAfter?: number, type?: string);
11
+ }
12
+ export declare class SentinelConnectionError extends SentinelError {
13
+ name: string;
14
+ readonly cause: Error;
15
+ }
16
+ export declare class LicenseValidationError extends SentinelError {
17
+ name: string;
18
+ readonly type: ValidationResultType;
19
+ readonly details?: FailureDetails;
20
+ constructor(message: string, type: ValidationResultType, details?: FailureDetails);
21
+ }
22
+ export declare class SignatureVerificationError extends SentinelError {
23
+ name: string;
24
+ }
25
+ export declare class ReplayDetectedError extends SentinelError {
26
+ name: string;
27
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,34 @@
1
+ export class SentinelError extends Error {
2
+ name = 'SentinelError';
3
+ }
4
+ export class SentinelApiError extends SentinelError {
5
+ name = 'SentinelApiError';
6
+ statusCode;
7
+ retryAfter;
8
+ type;
9
+ constructor(message, statusCode, retryAfter, type) {
10
+ super(message);
11
+ this.statusCode = statusCode;
12
+ this.retryAfter = retryAfter;
13
+ this.type = type;
14
+ }
15
+ }
16
+ export class SentinelConnectionError extends SentinelError {
17
+ name = 'SentinelConnectionError';
18
+ }
19
+ export class LicenseValidationError extends SentinelError {
20
+ name = 'LicenseValidationError';
21
+ type;
22
+ details;
23
+ constructor(message, type, details) {
24
+ super(message);
25
+ this.type = type;
26
+ this.details = details;
27
+ }
28
+ }
29
+ export class SignatureVerificationError extends SentinelError {
30
+ name = 'SignatureVerificationError';
31
+ }
32
+ export class ReplayDetectedError extends SentinelError {
33
+ name = 'ReplayDetectedError';
34
+ }
@@ -0,0 +1 @@
1
+ export declare function getMachineFingerprint(): string;
@@ -0,0 +1,175 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { arch, hostname, networkInterfaces, type as osType, platform } from 'node:os';
5
+ let cached;
6
+ export function getMachineFingerprint() {
7
+ if (cached !== undefined)
8
+ return cached;
9
+ const signals = [];
10
+ const primaryId = getPlatformId();
11
+ if (primaryId) {
12
+ signals.push(primaryId);
13
+ }
14
+ else {
15
+ collectFallbackSignals(signals);
16
+ }
17
+ cached = createHash('sha256').update(signals.join('\0')).digest('hex').substring(0, 32);
18
+ return cached;
19
+ }
20
+ function getPlatformId() {
21
+ try {
22
+ switch (platform()) {
23
+ case 'linux':
24
+ return readFileSafe('/etc/machine-id') ?? readFileSafe('/var/lib/dbus/machine-id');
25
+ case 'darwin': {
26
+ const output = execSafe('ioreg -rd1 -c IOPlatformExpertDevice');
27
+ return output?.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/)?.[1] ?? null;
28
+ }
29
+ case 'win32': {
30
+ const output = execSafe('reg query "HKLM\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid');
31
+ return output?.match(/MachineGuid\s+REG_SZ\s+(\S+)/)?.[1] ?? null;
32
+ }
33
+ case 'freebsd':
34
+ return execSafe('sysctl -n kern.hostuuid')?.trim() ?? null;
35
+ default:
36
+ return null;
37
+ }
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ function collectFallbackSignals(signals) {
44
+ const containerized = isContainerized();
45
+ if (platform() === 'linux') {
46
+ collectDmiSignals(signals);
47
+ }
48
+ signals.push(platform(), arch(), osType());
49
+ if (!containerized) {
50
+ const host = hostname();
51
+ if (host)
52
+ signals.push(host);
53
+ }
54
+ const macs = getMacAddresses(containerized);
55
+ if (macs.length > 0)
56
+ signals.push(...macs);
57
+ }
58
+ const VIRTUAL_INTERFACE_PREFIXES = [
59
+ 'docker',
60
+ 'br-',
61
+ 'veth',
62
+ 'cni',
63
+ 'flannel',
64
+ 'cali',
65
+ 'virbr',
66
+ 'podman',
67
+ 'vmnet',
68
+ 'vboxnet',
69
+ 'awdl',
70
+ 'llw',
71
+ 'utun',
72
+ 'dummy',
73
+ 'zt',
74
+ 'tailscale',
75
+ 'wg',
76
+ 'tun',
77
+ 'tap',
78
+ 'isatap',
79
+ 'teredo',
80
+ 'bond',
81
+ 'team',
82
+ ];
83
+ function getMacAddresses(containerized) {
84
+ const interfaces = networkInterfaces();
85
+ const macs = new Set();
86
+ for (const [name, entries] of Object.entries(interfaces)) {
87
+ if (!entries)
88
+ continue;
89
+ const lower = name.toLowerCase();
90
+ if (VIRTUAL_INTERFACE_PREFIXES.some((p) => lower.startsWith(p)))
91
+ continue;
92
+ for (const entry of entries) {
93
+ if (entry.internal)
94
+ continue;
95
+ if (!entry.mac || entry.mac === '00:00:00:00:00:00' || entry.mac === 'ff:ff:ff:ff:ff:ff')
96
+ continue;
97
+ if (containerized && isLocallyAdministered(entry.mac))
98
+ continue;
99
+ macs.add(entry.mac);
100
+ }
101
+ }
102
+ if (containerized && macs.size === 0) {
103
+ return getMacAddresses(false);
104
+ }
105
+ return [...macs].sort();
106
+ }
107
+ function isLocallyAdministered(mac) {
108
+ const firstByte = parseInt(mac.split(':')[0], 16);
109
+ return (firstByte & 0x02) !== 0;
110
+ }
111
+ const DMI_PATHS = [
112
+ '/sys/class/dmi/id/product_serial',
113
+ '/sys/class/dmi/id/board_serial',
114
+ '/sys/class/dmi/id/product_uuid',
115
+ ];
116
+ const DMI_COMPOSITE_PATHS = [
117
+ '/sys/class/dmi/id/board_vendor',
118
+ '/sys/class/dmi/id/board_name',
119
+ '/sys/class/dmi/id/product_name',
120
+ '/sys/class/dmi/id/sys_vendor',
121
+ ];
122
+ function collectDmiSignals(signals) {
123
+ for (const path of DMI_PATHS) {
124
+ const value = readFileSafe(path);
125
+ if (value) {
126
+ signals.push(value);
127
+ return;
128
+ }
129
+ }
130
+ const composite = DMI_COMPOSITE_PATHS.map(readFileSafe)
131
+ .filter((v) => v !== null)
132
+ .join(':');
133
+ if (composite)
134
+ signals.push(composite);
135
+ }
136
+ function isContainerized() {
137
+ if (platform() !== 'linux')
138
+ return false;
139
+ try {
140
+ if (existsSync('/.dockerenv') || existsSync('/run/.containerenv'))
141
+ return true;
142
+ }
143
+ catch {
144
+ /* ignore */
145
+ }
146
+ if (process.env.KUBERNETES_SERVICE_HOST ||
147
+ process.env.container ||
148
+ process.env.DOTNET_RUNNING_IN_CONTAINER) {
149
+ return true;
150
+ }
151
+ const cgroup = readFileSafe('/proc/1/cgroup') ?? readFileSafe('/proc/self/cgroup');
152
+ if (cgroup) {
153
+ const markers = ['docker', 'containerd', 'kubepods', 'podman', 'libpod', 'lxc', 'machine.slice'];
154
+ if (markers.some((m) => cgroup.includes(m)))
155
+ return true;
156
+ }
157
+ return false;
158
+ }
159
+ function readFileSafe(path) {
160
+ try {
161
+ const content = readFileSync(path, 'utf-8').trim();
162
+ return content || null;
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ }
168
+ function execSafe(command) {
169
+ try {
170
+ return execSync(command, { encoding: 'utf-8', timeout: 5_000 });
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ export interface HttpResponse {
2
+ statusCode: number;
3
+ body: string | null;
4
+ headers: Headers;
5
+ }
6
+ export declare class SentinelHttpClient {
7
+ private readonly baseUrl;
8
+ private readonly apiKey;
9
+ private readonly timeout;
10
+ constructor(baseUrl: string, apiKey: string, timeout: number);
11
+ request(method: string, path: string, body?: unknown, query?: URLSearchParams): Promise<HttpResponse>;
12
+ }
package/dist/http.js ADDED
@@ -0,0 +1,42 @@
1
+ import { SentinelConnectionError } from './errors.js';
2
+ export class SentinelHttpClient {
3
+ baseUrl;
4
+ apiKey;
5
+ timeout;
6
+ constructor(baseUrl, apiKey, timeout) {
7
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
8
+ this.apiKey = apiKey;
9
+ this.timeout = timeout;
10
+ }
11
+ async request(method, path, body, query) {
12
+ let url = `${this.baseUrl}${path}`;
13
+ if (query && query.size > 0) {
14
+ url += `?${query.toString()}`;
15
+ }
16
+ const headers = {
17
+ Authorization: `Bearer ${this.apiKey}`,
18
+ Accept: 'application/json',
19
+ };
20
+ const init = {
21
+ method,
22
+ headers,
23
+ signal: AbortSignal.timeout(this.timeout),
24
+ };
25
+ if (body !== undefined) {
26
+ headers['Content-Type'] = 'application/json';
27
+ init.body = JSON.stringify(body);
28
+ }
29
+ try {
30
+ const response = await fetch(url, init);
31
+ const text = await response.text();
32
+ return {
33
+ statusCode: response.status,
34
+ body: text || null,
35
+ headers: response.headers,
36
+ };
37
+ }
38
+ catch (error) {
39
+ throw new SentinelConnectionError(error instanceof Error ? error.message : 'Connection failed', { cause: error });
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,6 @@
1
+ export { requireValid, SentinelClient } from './client.js';
2
+ export { LicenseValidationError, ReplayDetectedError, SentinelApiError, SentinelConnectionError, SentinelError, SignatureVerificationError, } from './errors.js';
3
+ export { getMachineFingerprint } from './fingerprint.js';
4
+ export { getPublicIp } from './ip.js';
5
+ export type { BlacklistInfo, CreateLicenseRequest, FailureDetails, License, LicenseIssuer, LicenseProduct, LicenseTier, ListLicensesRequest, Page, SentinelClientOptions, SubUser, UpdateLicenseRequest, ValidateRequest, ValidationDetails, ValidationResult, } from './types.js';
6
+ export { ValidationResultType } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { requireValid, SentinelClient } from './client.js';
2
+ export { LicenseValidationError, ReplayDetectedError, SentinelApiError, SentinelConnectionError, SentinelError, SignatureVerificationError, } from './errors.js';
3
+ export { getMachineFingerprint } from './fingerprint.js';
4
+ export { getPublicIp } from './ip.js';
5
+ export { ValidationResultType } from './types.js';
package/dist/ip.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function getPublicIp(): Promise<string | null>;
package/dist/ip.js ADDED
@@ -0,0 +1,14 @@
1
+ export async function getPublicIp() {
2
+ try {
3
+ const response = await fetch('https://checkip.amazonaws.com', {
4
+ signal: AbortSignal.timeout(5_000),
5
+ });
6
+ if (!response.ok)
7
+ return null;
8
+ const text = await response.text();
9
+ return text.trim();
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
@@ -0,0 +1,8 @@
1
+ import type { SentinelHttpClient } from '../http.js';
2
+ import type { License } from '../types.js';
3
+ export declare class ConnectionOperations {
4
+ private readonly http;
5
+ constructor(http: SentinelHttpClient);
6
+ add(key: string, connections: Record<string, string>): Promise<License>;
7
+ remove(key: string, platforms: string[]): Promise<License>;
8
+ }
@@ -0,0 +1,20 @@
1
+ import { parseApiResponse, parseLicense } from '../parsing.js';
2
+ export class ConnectionOperations {
3
+ http;
4
+ constructor(http) {
5
+ this.http = http;
6
+ }
7
+ async add(key, connections) {
8
+ const response = await this.http.request('POST', `/api/v2/licenses/${encodeURIComponent(key)}/connections`, connections);
9
+ const apiResponse = parseApiResponse(response);
10
+ return parseLicense(apiResponse.result.license);
11
+ }
12
+ async remove(key, platforms) {
13
+ const query = new URLSearchParams();
14
+ for (const p of platforms)
15
+ query.append('platforms', p);
16
+ const response = await this.http.request('DELETE', `/api/v2/licenses/${encodeURIComponent(key)}/connections`, undefined, query);
17
+ const apiResponse = parseApiResponse(response);
18
+ return parseLicense(apiResponse.result.license);
19
+ }
20
+ }
@@ -0,0 +1,8 @@
1
+ import type { SentinelHttpClient } from '../http.js';
2
+ import type { License } from '../types.js';
3
+ export declare class IpOperations {
4
+ private readonly http;
5
+ constructor(http: SentinelHttpClient);
6
+ add(key: string, ips: string[]): Promise<License>;
7
+ remove(key: string, ips: string[]): Promise<License>;
8
+ }
@@ -0,0 +1,20 @@
1
+ import { parseApiResponse, parseLicense } from '../parsing.js';
2
+ export class IpOperations {
3
+ http;
4
+ constructor(http) {
5
+ this.http = http;
6
+ }
7
+ async add(key, ips) {
8
+ const response = await this.http.request('POST', `/api/v2/licenses/${encodeURIComponent(key)}/ips`, ips);
9
+ const apiResponse = parseApiResponse(response);
10
+ return parseLicense(apiResponse.result.license);
11
+ }
12
+ async remove(key, ips) {
13
+ const query = new URLSearchParams();
14
+ for (const ip of ips)
15
+ query.append('ips', ip);
16
+ const response = await this.http.request('DELETE', `/api/v2/licenses/${encodeURIComponent(key)}/ips`, undefined, query);
17
+ const apiResponse = parseApiResponse(response);
18
+ return parseLicense(apiResponse.result.license);
19
+ }
20
+ }
@@ -0,0 +1,8 @@
1
+ import type { SentinelHttpClient } from '../http.js';
2
+ import type { License } from '../types.js';
3
+ export declare class ServerOperations {
4
+ private readonly http;
5
+ constructor(http: SentinelHttpClient);
6
+ add(key: string, servers: string[]): Promise<License>;
7
+ remove(key: string, servers: string[]): Promise<License>;
8
+ }
@@ -0,0 +1,20 @@
1
+ import { parseApiResponse, parseLicense } from '../parsing.js';
2
+ export class ServerOperations {
3
+ http;
4
+ constructor(http) {
5
+ this.http = http;
6
+ }
7
+ async add(key, servers) {
8
+ const response = await this.http.request('POST', `/api/v2/licenses/${encodeURIComponent(key)}/servers`, servers);
9
+ const apiResponse = parseApiResponse(response);
10
+ return parseLicense(apiResponse.result.license);
11
+ }
12
+ async remove(key, servers) {
13
+ const query = new URLSearchParams();
14
+ for (const s of servers)
15
+ query.append('servers', s);
16
+ const response = await this.http.request('DELETE', `/api/v2/licenses/${encodeURIComponent(key)}/servers`, undefined, query);
17
+ const apiResponse = parseApiResponse(response);
18
+ return parseLicense(apiResponse.result.license);
19
+ }
20
+ }
@@ -0,0 +1,8 @@
1
+ import type { SentinelHttpClient } from '../http.js';
2
+ import type { License, SubUser } from '../types.js';
3
+ export declare class SubUserOperations {
4
+ private readonly http;
5
+ constructor(http: SentinelHttpClient);
6
+ add(key: string, subUsers: SubUser[]): Promise<License>;
7
+ remove(key: string, subUsers: SubUser[]): Promise<License>;
8
+ }
@@ -0,0 +1,17 @@
1
+ import { parseApiResponse, parseLicense } from '../parsing.js';
2
+ export class SubUserOperations {
3
+ http;
4
+ constructor(http) {
5
+ this.http = http;
6
+ }
7
+ async add(key, subUsers) {
8
+ const response = await this.http.request('POST', `/api/v2/licenses/${encodeURIComponent(key)}/sub-users`, subUsers);
9
+ const apiResponse = parseApiResponse(response);
10
+ return parseLicense(apiResponse.result.license);
11
+ }
12
+ async remove(key, subUsers) {
13
+ const response = await this.http.request('POST', `/api/v2/licenses/${encodeURIComponent(key)}/sub-users/remove`, subUsers);
14
+ const apiResponse = parseApiResponse(response);
15
+ return parseLicense(apiResponse.result.license);
16
+ }
17
+ }
@@ -0,0 +1,20 @@
1
+ import type { HttpResponse } from './http.js';
2
+ import type { CanonicalPayloadFields } from './signature.js';
3
+ import type { License, Page, ValidationResult } from './types.js';
4
+ export interface ApiResponse {
5
+ statusCode: number;
6
+ type: string;
7
+ message: string;
8
+ result: unknown;
9
+ }
10
+ export interface ParsedValidation {
11
+ result: ValidationResult;
12
+ nonce: string | null;
13
+ timestamp: number | null;
14
+ signature: string | null;
15
+ rawPayload: CanonicalPayloadFields | null;
16
+ }
17
+ export declare function parseApiResponse(response: HttpResponse, ...allowedStatuses: number[]): ApiResponse;
18
+ export declare function parseLicense(data: unknown): License;
19
+ export declare function parsePage(data: unknown): Page<License>;
20
+ export declare function parseValidationResponse(response: ApiResponse): ParsedValidation;
@@ -0,0 +1,193 @@
1
+ import { SentinelApiError } from './errors.js';
2
+ import { ValidationResultType } from './types.js';
3
+ export function parseApiResponse(response, ...allowedStatuses) {
4
+ const isSuccess = response.statusCode >= 200 && response.statusCode < 300;
5
+ const isAllowed = allowedStatuses.includes(response.statusCode);
6
+ let type = '';
7
+ let message = '';
8
+ let result = null;
9
+ if (response.body) {
10
+ try {
11
+ const json = JSON.parse(response.body);
12
+ type = json.type ?? '';
13
+ message = json.message ?? '';
14
+ result = json.result ?? null;
15
+ }
16
+ catch {
17
+ // non-JSON body
18
+ }
19
+ }
20
+ if (!isSuccess && !isAllowed) {
21
+ let retryAfter;
22
+ if (response.statusCode === 429) {
23
+ const header = response.headers.get('X-Rate-Limit-Retry-After-Seconds');
24
+ if (header)
25
+ retryAfter = parseInt(header, 10) || undefined;
26
+ }
27
+ throw new SentinelApiError(message || `HTTP ${response.statusCode}`, response.statusCode, retryAfter, type || undefined);
28
+ }
29
+ return { statusCode: response.statusCode, type, message, result };
30
+ }
31
+ export function parseLicense(data) {
32
+ const d = data;
33
+ return {
34
+ id: d.id,
35
+ key: d.key,
36
+ product: parseProduct(d.product),
37
+ tier: parseTier(d.tier),
38
+ issuer: parseIssuer(d.issuer),
39
+ createdAt: new Date(d.createdAt),
40
+ expiration: d.expiration ? new Date(d.expiration) : undefined,
41
+ maxServers: d.maxServers,
42
+ maxIps: d.maxIps,
43
+ note: d.note ?? undefined,
44
+ connections: d.connections ?? {},
45
+ subUsers: (d.subUsers ?? []).map(parseSubUser),
46
+ servers: parseTimestampMap(d.servers),
47
+ ips: parseTimestampMap(d.ips),
48
+ additionalEntitlements: new Set(d.additionalEntitlements ?? []),
49
+ entitlements: new Set(d.entitlements ?? []),
50
+ blacklist: d.blacklist ? parseBlacklist(d.blacklist) : undefined,
51
+ };
52
+ }
53
+ export function parsePage(data) {
54
+ const d = data;
55
+ const meta = d.page;
56
+ return {
57
+ content: (d.content ?? []).map(parseLicense),
58
+ size: meta.size,
59
+ number: meta.number,
60
+ totalElements: meta.totalElements,
61
+ totalPages: meta.totalPages,
62
+ };
63
+ }
64
+ export function parseValidationResponse(response) {
65
+ if (response.statusCode === 200) {
66
+ const result = response.result;
67
+ const validation = result?.validation;
68
+ const details = validation?.details;
69
+ const rawEntitlements = details?.entitlements ?? [];
70
+ const rawExpiration = details?.expiration ?? null;
71
+ const validationDetails = {
72
+ expiration: rawExpiration ? new Date(rawExpiration) : undefined,
73
+ serverCount: details.serverCount,
74
+ maxServers: details.maxServers,
75
+ ipCount: details.ipCount,
76
+ maxIps: details.maxIps,
77
+ tier: details.tier,
78
+ entitlements: new Set(rawEntitlements),
79
+ };
80
+ return {
81
+ result: { success: true, message: response.message, details: validationDetails },
82
+ nonce: validation?.nonce ?? null,
83
+ timestamp: validation?.timestamp ?? null,
84
+ signature: validation?.signature ?? null,
85
+ rawPayload: {
86
+ entitlements: rawEntitlements,
87
+ expiration: rawExpiration,
88
+ ipCount: details.ipCount,
89
+ maxIps: details.maxIps,
90
+ maxServers: details.maxServers,
91
+ nonce: validation?.nonce,
92
+ serverCount: details.serverCount,
93
+ tier: details.tier,
94
+ timestamp: validation?.timestamp,
95
+ },
96
+ };
97
+ }
98
+ const resultType = mapValidationResultType(response.type);
99
+ if (resultType === ValidationResultType.Unknown) {
100
+ throw new SentinelApiError(response.message || `HTTP ${response.statusCode}`, response.statusCode, undefined, response.type || undefined);
101
+ }
102
+ const failureDetails = parseFailureDetails(resultType, response.result);
103
+ return {
104
+ result: {
105
+ success: false,
106
+ message: response.message,
107
+ type: resultType,
108
+ details: failureDetails,
109
+ },
110
+ nonce: null,
111
+ timestamp: null,
112
+ signature: null,
113
+ rawPayload: null,
114
+ };
115
+ }
116
+ function mapValidationResultType(type) {
117
+ const match = Object.values(ValidationResultType).find((v) => v === type);
118
+ return match ?? ValidationResultType.Unknown;
119
+ }
120
+ function parseFailureDetails(type, result) {
121
+ if (!result)
122
+ return undefined;
123
+ const r = result;
124
+ switch (type) {
125
+ case ValidationResultType.BlacklistedLicense: {
126
+ const blacklist = r.blacklist;
127
+ if (blacklist) {
128
+ return {
129
+ type: 'blacklist',
130
+ timestamp: new Date(blacklist.timestamp),
131
+ reason: blacklist.reason,
132
+ };
133
+ }
134
+ return undefined;
135
+ }
136
+ case ValidationResultType.ExcessiveServers:
137
+ if (r.maxServers !== undefined) {
138
+ return { type: 'excessiveServers', maxServers: r.maxServers };
139
+ }
140
+ return undefined;
141
+ case ValidationResultType.ExcessiveIps:
142
+ if (r.maxIps !== undefined) {
143
+ return { type: 'excessiveIps', maxIps: r.maxIps };
144
+ }
145
+ return undefined;
146
+ default:
147
+ return undefined;
148
+ }
149
+ }
150
+ function parseProduct(data) {
151
+ const d = data;
152
+ return {
153
+ id: d.id,
154
+ name: d.name,
155
+ description: d.description ?? undefined,
156
+ logoUrl: d.logoUrl ?? undefined,
157
+ };
158
+ }
159
+ function parseTier(data) {
160
+ const d = data;
161
+ return {
162
+ name: d.name,
163
+ entitlements: new Set(d.entitlements ?? []),
164
+ };
165
+ }
166
+ function parseIssuer(data) {
167
+ const d = data;
168
+ return {
169
+ type: d.type,
170
+ id: d.id,
171
+ displayName: d.displayName,
172
+ };
173
+ }
174
+ function parseSubUser(data) {
175
+ const d = data;
176
+ return { platform: d.platform, value: d.value };
177
+ }
178
+ function parseBlacklist(data) {
179
+ const d = data;
180
+ return {
181
+ timestamp: new Date(d.timestamp),
182
+ reason: d.reason ?? undefined,
183
+ };
184
+ }
185
+ function parseTimestampMap(data) {
186
+ if (!data)
187
+ return {};
188
+ const result = {};
189
+ for (const [key, value] of Object.entries(data)) {
190
+ result[key] = new Date(value);
191
+ }
192
+ return result;
193
+ }
@@ -0,0 +1,7 @@
1
+ export declare class ReplayProtector {
2
+ private readonly maxAge;
3
+ private readonly maxSize;
4
+ private readonly seen;
5
+ constructor(maxTimestampAge: number, cacheSize: number);
6
+ check(nonce: string, timestamp: number): void;
7
+ }
package/dist/replay.js ADDED
@@ -0,0 +1,25 @@
1
+ import { ReplayDetectedError } from './errors.js';
2
+ export class ReplayProtector {
3
+ maxAge;
4
+ maxSize;
5
+ seen = new Map();
6
+ constructor(maxTimestampAge, cacheSize) {
7
+ this.maxAge = maxTimestampAge;
8
+ this.maxSize = cacheSize;
9
+ }
10
+ check(nonce, timestamp) {
11
+ const now = Date.now();
12
+ if (Math.abs(now - timestamp) > this.maxAge) {
13
+ throw new ReplayDetectedError(`Response timestamp is outside acceptable window (drift: ${Math.abs(now - timestamp)}ms, max: ${this.maxAge}ms)`);
14
+ }
15
+ if (this.seen.has(nonce)) {
16
+ throw new ReplayDetectedError(`Duplicate nonce detected: ${nonce}`);
17
+ }
18
+ this.seen.set(nonce, timestamp);
19
+ if (this.seen.size > this.maxSize) {
20
+ const oldestKey = this.seen.keys().next().value;
21
+ if (oldestKey !== undefined)
22
+ this.seen.delete(oldestKey);
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,17 @@
1
+ export interface CanonicalPayloadFields {
2
+ entitlements: string[];
3
+ expiration: string | null;
4
+ ipCount: number;
5
+ maxIps: number;
6
+ maxServers: number;
7
+ nonce: string;
8
+ serverCount: number;
9
+ tier: string;
10
+ timestamp: number;
11
+ }
12
+ export declare function buildCanonicalPayload(fields: CanonicalPayloadFields): string;
13
+ export declare class SignatureVerifier {
14
+ private readonly publicKey;
15
+ constructor(base64PublicKey: string);
16
+ verify(payload: CanonicalPayloadFields, signature: string | null): void;
17
+ }
@@ -0,0 +1,35 @@
1
+ import { createPublicKey, verify } from 'node:crypto';
2
+ import { SignatureVerificationError } from './errors.js';
3
+ export function buildCanonicalPayload(fields) {
4
+ return JSON.stringify({
5
+ entitlements: [...fields.entitlements].sort(),
6
+ expiration: fields.expiration,
7
+ ipCount: fields.ipCount,
8
+ maxIps: fields.maxIps,
9
+ maxServers: fields.maxServers,
10
+ nonce: fields.nonce,
11
+ serverCount: fields.serverCount,
12
+ tier: fields.tier,
13
+ timestamp: fields.timestamp,
14
+ });
15
+ }
16
+ export class SignatureVerifier {
17
+ publicKey;
18
+ constructor(base64PublicKey) {
19
+ this.publicKey = createPublicKey({
20
+ key: Buffer.from(base64PublicKey, 'base64'),
21
+ format: 'der',
22
+ type: 'spki',
23
+ });
24
+ }
25
+ verify(payload, signature) {
26
+ if (!signature) {
27
+ throw new SignatureVerificationError('Response signature is missing');
28
+ }
29
+ const canonical = buildCanonicalPayload(payload);
30
+ const isValid = verify(null, Buffer.from(canonical), this.publicKey, Buffer.from(signature, 'base64'));
31
+ if (!isValid) {
32
+ throw new SignatureVerificationError('Signature verification failed');
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,145 @@
1
+ export interface SentinelClientOptions {
2
+ baseUrl: string;
3
+ apiKey: string;
4
+ timeout?: number;
5
+ publicKey?: string;
6
+ replayProtection?: {
7
+ maxTimestampAge?: number;
8
+ cacheSize?: number;
9
+ };
10
+ }
11
+ export interface License {
12
+ id: string;
13
+ key: string;
14
+ product: LicenseProduct;
15
+ tier: LicenseTier;
16
+ issuer: LicenseIssuer;
17
+ createdAt: Date;
18
+ expiration?: Date;
19
+ maxServers: number;
20
+ maxIps: number;
21
+ note?: string;
22
+ connections: Record<string, string>;
23
+ subUsers: SubUser[];
24
+ servers: Record<string, Date>;
25
+ ips: Record<string, Date>;
26
+ additionalEntitlements: Set<string>;
27
+ entitlements: Set<string>;
28
+ blacklist?: BlacklistInfo;
29
+ }
30
+ export interface LicenseProduct {
31
+ id: string;
32
+ name: string;
33
+ description?: string;
34
+ logoUrl?: string;
35
+ }
36
+ export interface LicenseTier {
37
+ name: string;
38
+ entitlements: Set<string>;
39
+ }
40
+ export interface LicenseIssuer {
41
+ type: string;
42
+ id: string;
43
+ displayName: string;
44
+ }
45
+ export interface SubUser {
46
+ platform: string;
47
+ value: string;
48
+ }
49
+ export interface BlacklistInfo {
50
+ timestamp: Date;
51
+ reason?: string;
52
+ }
53
+ export interface Page<T> {
54
+ content: T[];
55
+ size: number;
56
+ number: number;
57
+ totalElements: number;
58
+ totalPages: number;
59
+ }
60
+ export type ValidationResult = {
61
+ success: true;
62
+ message: string;
63
+ details: ValidationDetails;
64
+ } | {
65
+ success: false;
66
+ message: string;
67
+ type: ValidationResultType;
68
+ details?: FailureDetails;
69
+ };
70
+ export interface ValidationDetails {
71
+ expiration?: Date;
72
+ serverCount: number;
73
+ maxServers: number;
74
+ ipCount: number;
75
+ maxIps: number;
76
+ tier: string;
77
+ entitlements: Set<string>;
78
+ }
79
+ export declare enum ValidationResultType {
80
+ Success = "SUCCESS",
81
+ InvalidProduct = "INVALID_PRODUCT",
82
+ InvalidLicense = "INVALID_LICENSE",
83
+ InvalidPlatform = "INVALID_PLATFORM",
84
+ ExpiredLicense = "EXPIRED_LICENSE",
85
+ BlacklistedLicense = "BLACKLISTED_LICENSE",
86
+ ConnectionMismatch = "CONNECTION_MISMATCH",
87
+ ExcessiveServers = "EXCESSIVE_SERVERS",
88
+ ExcessiveIps = "EXCESSIVE_IPS",
89
+ Unknown = "UNKNOWN"
90
+ }
91
+ export type FailureDetails = {
92
+ type: 'blacklist';
93
+ timestamp: Date;
94
+ reason: string;
95
+ } | {
96
+ type: 'excessiveServers';
97
+ maxServers: number;
98
+ } | {
99
+ type: 'excessiveIps';
100
+ maxIps: number;
101
+ };
102
+ export interface ValidateRequest {
103
+ key?: string;
104
+ product: string;
105
+ connectionPlatform?: string;
106
+ connectionValue?: string;
107
+ server?: string;
108
+ ip?: string;
109
+ }
110
+ export interface CreateLicenseRequest {
111
+ product: string;
112
+ key?: string;
113
+ tier?: string;
114
+ expiration?: Date;
115
+ maxServers?: number;
116
+ maxIps?: number;
117
+ note?: string;
118
+ connections?: Record<string, string>;
119
+ additionalEntitlements?: string[];
120
+ }
121
+ export interface UpdateLicenseRequest {
122
+ product?: string;
123
+ tier?: string;
124
+ expiration?: Date | null;
125
+ maxServers?: number;
126
+ maxIps?: number;
127
+ note?: string | null;
128
+ connections?: Record<string, string>;
129
+ subUsers?: SubUser[];
130
+ servers?: string[];
131
+ ips?: string[];
132
+ additionalEntitlements?: string[];
133
+ blacklistReason?: string | null;
134
+ }
135
+ export interface ListLicensesRequest {
136
+ product?: string;
137
+ status?: string;
138
+ query?: string;
139
+ platform?: string;
140
+ value?: string;
141
+ server?: string;
142
+ ip?: string;
143
+ page?: number;
144
+ size?: number;
145
+ }
package/dist/types.js ADDED
@@ -0,0 +1,13 @@
1
+ export var ValidationResultType;
2
+ (function (ValidationResultType) {
3
+ ValidationResultType["Success"] = "SUCCESS";
4
+ ValidationResultType["InvalidProduct"] = "INVALID_PRODUCT";
5
+ ValidationResultType["InvalidLicense"] = "INVALID_LICENSE";
6
+ ValidationResultType["InvalidPlatform"] = "INVALID_PLATFORM";
7
+ ValidationResultType["ExpiredLicense"] = "EXPIRED_LICENSE";
8
+ ValidationResultType["BlacklistedLicense"] = "BLACKLISTED_LICENSE";
9
+ ValidationResultType["ConnectionMismatch"] = "CONNECTION_MISMATCH";
10
+ ValidationResultType["ExcessiveServers"] = "EXCESSIVE_SERVERS";
11
+ ValidationResultType["ExcessiveIps"] = "EXCESSIVE_IPS";
12
+ ValidationResultType["Unknown"] = "UNKNOWN";
13
+ })(ValidationResultType || (ValidationResultType = {}));
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "sentinel-js-client",
3
+ "version": "2.0.3",
4
+ "description": "Node.js client library for Sentinel",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "exports": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "test": "tsx --test test/**/*.test.ts",
18
+ "check": "biome check",
19
+ "format": "biome check --write"
20
+ },
21
+ "devDependencies": {
22
+ "@biomejs/biome": "2.4.9",
23
+ "@types/node": "^25.5.0",
24
+ "tsx": "^4.19.0",
25
+ "typescript": "^5.7.0"
26
+ },
27
+ "license": "MIT",
28
+ "author": {
29
+ "name": "Demeng",
30
+ "email": "hi@demeng.dev"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/demengc/sentinel-js-client.git"
35
+ },
36
+ "homepage": "https://github.com/demengc/sentinel-js-client",
37
+ "bugs": {
38
+ "url": "https://github.com/demengc/sentinel-js-client/issues"
39
+ },
40
+ "keywords": [
41
+ "sentinel",
42
+ "license",
43
+ "licensing",
44
+ "api-client"
45
+ ]
46
+ }