@licenseseat/js 0.1.0 → 0.2.1
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/README.md +684 -83
- package/dist/index.js +775 -1526
- package/dist/types/LicenseSeat.d.ts +309 -0
- package/dist/types/LicenseSeat.d.ts.map +1 -0
- package/dist/types/cache.d.ts +93 -0
- package/dist/types/cache.d.ts.map +1 -0
- package/dist/types/errors.d.ts +58 -0
- package/dist/types/errors.d.ts.map +1 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/types.d.ts +342 -0
- package/dist/types/types.d.ts.map +1 -0
- package/dist/types/utils.d.ts +54 -0
- package/dist/types/utils.d.ts.map +1 -0
- package/package.json +44 -8
- package/src/LicenseSeat.js +1247 -0
- package/src/cache.js +189 -0
- package/src/errors.js +77 -0
- package/src/index.js +62 -0
- package/src/types.js +148 -0
- package/src/utils.js +154 -0
|
@@ -0,0 +1,1247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LicenseSeat JavaScript SDK
|
|
3
|
+
*
|
|
4
|
+
* A comprehensive client-side SDK for managing software licenses
|
|
5
|
+
* with the LicenseSeat licensing system.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - License activation and deactivation
|
|
9
|
+
* - Local caching with encryption support
|
|
10
|
+
* - Online and offline validation
|
|
11
|
+
* - Automatic re-validation
|
|
12
|
+
* - Entitlement checking
|
|
13
|
+
* - Event-driven architecture
|
|
14
|
+
* - Device fingerprinting
|
|
15
|
+
* - Retry logic with exponential backoff
|
|
16
|
+
*
|
|
17
|
+
* @module LicenseSeat
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as ed from "@noble/ed25519";
|
|
21
|
+
import { sha512 } from "@noble/hashes/sha512";
|
|
22
|
+
|
|
23
|
+
import { LicenseCache } from "./cache.js";
|
|
24
|
+
import { APIError, LicenseError, CryptoError } from "./errors.js";
|
|
25
|
+
import {
|
|
26
|
+
parseActiveEntitlements,
|
|
27
|
+
constantTimeEqual,
|
|
28
|
+
canonicalJsonStringify,
|
|
29
|
+
base64UrlDecode,
|
|
30
|
+
generateDeviceId,
|
|
31
|
+
sleep,
|
|
32
|
+
getCsrfToken,
|
|
33
|
+
} from "./utils.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Default configuration values
|
|
37
|
+
* @type {import('./types.js').LicenseSeatConfig}
|
|
38
|
+
*/
|
|
39
|
+
const DEFAULT_CONFIG = {
|
|
40
|
+
apiBaseUrl: "https://licenseseat.com/api",
|
|
41
|
+
storagePrefix: "licenseseat_",
|
|
42
|
+
autoValidateInterval: 3600000, // 1 hour
|
|
43
|
+
networkRecheckInterval: 30000, // 30 seconds
|
|
44
|
+
maxRetries: 3,
|
|
45
|
+
retryDelay: 1000,
|
|
46
|
+
apiKey: null,
|
|
47
|
+
debug: false,
|
|
48
|
+
offlineLicenseRefreshInterval: 1000 * 60 * 60 * 72, // 72 hours
|
|
49
|
+
offlineFallbackEnabled: false, // default false (strict mode, matches Swift SDK)
|
|
50
|
+
maxOfflineDays: 0, // 0 = disabled
|
|
51
|
+
maxClockSkewMs: 5 * 60 * 1000, // 5 minutes
|
|
52
|
+
autoInitialize: true,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* LicenseSeat SDK Main Class
|
|
57
|
+
*
|
|
58
|
+
* Provides license activation, validation, and entitlement checking
|
|
59
|
+
* for client-side JavaScript applications.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```js
|
|
63
|
+
* const sdk = new LicenseSeatSDK({
|
|
64
|
+
* apiKey: 'your-api-key',
|
|
65
|
+
* debug: true
|
|
66
|
+
* });
|
|
67
|
+
*
|
|
68
|
+
* // Activate a license
|
|
69
|
+
* await sdk.activate('LICENSE-KEY-HERE');
|
|
70
|
+
*
|
|
71
|
+
* // Check entitlements
|
|
72
|
+
* if (sdk.hasEntitlement('pro-features')) {
|
|
73
|
+
* // Enable pro features
|
|
74
|
+
* }
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export class LicenseSeatSDK {
|
|
78
|
+
/**
|
|
79
|
+
* Create a new LicenseSeat SDK instance
|
|
80
|
+
* @param {import('./types.js').LicenseSeatConfig} [config={}] - Configuration options
|
|
81
|
+
*/
|
|
82
|
+
constructor(config = {}) {
|
|
83
|
+
/**
|
|
84
|
+
* SDK configuration
|
|
85
|
+
* @type {import('./types.js').LicenseSeatConfig}
|
|
86
|
+
*/
|
|
87
|
+
this.config = {
|
|
88
|
+
...DEFAULT_CONFIG,
|
|
89
|
+
...config,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Event listeners map
|
|
94
|
+
* @type {Object<string, import('./types.js').EventCallback[]>}
|
|
95
|
+
* @private
|
|
96
|
+
*/
|
|
97
|
+
this.eventListeners = {};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Auto-validation timer ID
|
|
101
|
+
* @type {number|null}
|
|
102
|
+
* @private
|
|
103
|
+
*/
|
|
104
|
+
this.validationTimer = null;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* License cache manager
|
|
108
|
+
* @type {LicenseCache}
|
|
109
|
+
* @private
|
|
110
|
+
*/
|
|
111
|
+
this.cache = new LicenseCache(this.config.storagePrefix);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Current online status
|
|
115
|
+
* @type {boolean}
|
|
116
|
+
* @private
|
|
117
|
+
*/
|
|
118
|
+
this.online = true;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Current license key being auto-validated
|
|
122
|
+
* @type {string|null}
|
|
123
|
+
* @private
|
|
124
|
+
*/
|
|
125
|
+
this.currentAutoLicenseKey = null;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Connectivity polling timer ID
|
|
129
|
+
* @type {number|null}
|
|
130
|
+
* @private
|
|
131
|
+
*/
|
|
132
|
+
this.connectivityTimer = null;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Offline license refresh timer ID
|
|
136
|
+
* @type {number|null}
|
|
137
|
+
* @private
|
|
138
|
+
*/
|
|
139
|
+
this.offlineRefreshTimer = null;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Last offline validation result (to avoid duplicate emits)
|
|
143
|
+
* @type {import('./types.js').ValidationResult|null}
|
|
144
|
+
* @private
|
|
145
|
+
*/
|
|
146
|
+
this.lastOfflineValidation = null;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Flag to prevent concurrent syncOfflineAssets calls
|
|
150
|
+
* @type {boolean}
|
|
151
|
+
* @private
|
|
152
|
+
*/
|
|
153
|
+
this.syncingOfflineAssets = false;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Flag indicating if SDK has been destroyed
|
|
157
|
+
* @type {boolean}
|
|
158
|
+
* @private
|
|
159
|
+
*/
|
|
160
|
+
this.destroyed = false;
|
|
161
|
+
|
|
162
|
+
// Enable synchronous SHA512 for noble-ed25519
|
|
163
|
+
if (ed && ed.etc && sha512) {
|
|
164
|
+
ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m));
|
|
165
|
+
} else {
|
|
166
|
+
console.error(
|
|
167
|
+
"[LicenseSeat SDK] Noble-ed25519 or Noble-hashes not loaded correctly. Sync crypto methods may fail."
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Initialize on construction (unless autoInitialize is disabled)
|
|
172
|
+
if (this.config.autoInitialize) {
|
|
173
|
+
this.initialize();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Initialize the SDK
|
|
179
|
+
* Loads cached license and starts auto-validation if configured.
|
|
180
|
+
* Called automatically unless autoInitialize is set to false.
|
|
181
|
+
* @returns {void}
|
|
182
|
+
*/
|
|
183
|
+
initialize() {
|
|
184
|
+
this.log("LicenseSeat SDK initialized", this.config);
|
|
185
|
+
|
|
186
|
+
const cachedLicense = this.cache.getLicense();
|
|
187
|
+
if (cachedLicense) {
|
|
188
|
+
this.emit("license:loaded", cachedLicense);
|
|
189
|
+
|
|
190
|
+
// Quick offline verification for instant UX
|
|
191
|
+
if (this.config.offlineFallbackEnabled) {
|
|
192
|
+
this.quickVerifyCachedOfflineLocal()
|
|
193
|
+
.then((offlineResult) => {
|
|
194
|
+
if (offlineResult) {
|
|
195
|
+
this.cache.updateValidation(offlineResult);
|
|
196
|
+
if (offlineResult.valid) {
|
|
197
|
+
this.emit("validation:offline-success", offlineResult);
|
|
198
|
+
} else {
|
|
199
|
+
this.emit("validation:offline-failed", offlineResult);
|
|
200
|
+
}
|
|
201
|
+
this.lastOfflineValidation = offlineResult;
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
.catch(() => {});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Start auto-validation if API key is configured
|
|
208
|
+
if (this.config.apiKey) {
|
|
209
|
+
this.startAutoValidation(cachedLicense.license_key);
|
|
210
|
+
|
|
211
|
+
// Validate in background
|
|
212
|
+
this.validateLicense(cachedLicense.license_key).catch((err) => {
|
|
213
|
+
this.log("Background validation failed:", err);
|
|
214
|
+
|
|
215
|
+
if (
|
|
216
|
+
err instanceof APIError &&
|
|
217
|
+
(err.status === 401 || err.status === 501)
|
|
218
|
+
) {
|
|
219
|
+
this.log(
|
|
220
|
+
"Authentication issue during validation, using cached license data"
|
|
221
|
+
);
|
|
222
|
+
this.emit("validation:auth-failed", {
|
|
223
|
+
licenseKey: cachedLicense.license_key,
|
|
224
|
+
error: err,
|
|
225
|
+
cached: true,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Activate a license
|
|
235
|
+
* @param {string} licenseKey - The license key to activate
|
|
236
|
+
* @param {import('./types.js').ActivationOptions} [options={}] - Activation options
|
|
237
|
+
* @returns {Promise<import('./types.js').CachedLicense>} Activation result with cached license data
|
|
238
|
+
* @throws {APIError} When the API request fails
|
|
239
|
+
*/
|
|
240
|
+
async activate(licenseKey, options = {}) {
|
|
241
|
+
const deviceId = options.deviceIdentifier || generateDeviceId();
|
|
242
|
+
const payload = {
|
|
243
|
+
license_key: licenseKey,
|
|
244
|
+
device_identifier: deviceId,
|
|
245
|
+
metadata: options.metadata || {},
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
if (options.softwareReleaseDate) {
|
|
249
|
+
payload.software_release_date = options.softwareReleaseDate;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
this.emit("activation:start", { licenseKey, deviceId });
|
|
254
|
+
|
|
255
|
+
const response = await this.apiCall("/activations/activate", {
|
|
256
|
+
method: "POST",
|
|
257
|
+
body: payload,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
/** @type {import('./types.js').CachedLicense} */
|
|
261
|
+
const licenseData = {
|
|
262
|
+
license_key: licenseKey,
|
|
263
|
+
device_identifier: deviceId,
|
|
264
|
+
activation: response,
|
|
265
|
+
activated_at: new Date().toISOString(),
|
|
266
|
+
last_validated: new Date().toISOString(),
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
this.cache.setLicense(licenseData);
|
|
270
|
+
this.cache.updateValidation({ valid: true, optimistic: true });
|
|
271
|
+
this.startAutoValidation(licenseKey);
|
|
272
|
+
this.syncOfflineAssets();
|
|
273
|
+
this.scheduleOfflineRefresh();
|
|
274
|
+
|
|
275
|
+
this.emit("activation:success", licenseData);
|
|
276
|
+
return licenseData;
|
|
277
|
+
} catch (error) {
|
|
278
|
+
this.emit("activation:error", { licenseKey, error });
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Deactivate the current license
|
|
285
|
+
* @returns {Promise<Object>} Deactivation result from the API
|
|
286
|
+
* @throws {LicenseError} When no active license is found
|
|
287
|
+
* @throws {APIError} When the API request fails
|
|
288
|
+
*/
|
|
289
|
+
async deactivate() {
|
|
290
|
+
const cachedLicense = this.cache.getLicense();
|
|
291
|
+
if (!cachedLicense) {
|
|
292
|
+
throw new LicenseError("No active license found", "no_license");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
this.emit("deactivation:start", cachedLicense);
|
|
297
|
+
|
|
298
|
+
const response = await this.apiCall("/activations/deactivate", {
|
|
299
|
+
method: "POST",
|
|
300
|
+
body: {
|
|
301
|
+
license_key: cachedLicense.license_key,
|
|
302
|
+
device_identifier: cachedLicense.device_identifier,
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
this.cache.clearLicense();
|
|
307
|
+
this.cache.clearOfflineLicense();
|
|
308
|
+
this.stopAutoValidation();
|
|
309
|
+
|
|
310
|
+
this.emit("deactivation:success", response);
|
|
311
|
+
return response;
|
|
312
|
+
} catch (error) {
|
|
313
|
+
this.emit("deactivation:error", { error, license: cachedLicense });
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Validate a license
|
|
320
|
+
* @param {string} licenseKey - License key to validate
|
|
321
|
+
* @param {import('./types.js').ValidationOptions} [options={}] - Validation options
|
|
322
|
+
* @returns {Promise<import('./types.js').ValidationResult>} Validation result
|
|
323
|
+
* @throws {APIError} When the API request fails and offline fallback is not available
|
|
324
|
+
*/
|
|
325
|
+
async validateLicense(licenseKey, options = {}) {
|
|
326
|
+
try {
|
|
327
|
+
this.emit("validation:start", { licenseKey });
|
|
328
|
+
|
|
329
|
+
const rawResponse = await this.apiCall("/licenses/validate", {
|
|
330
|
+
method: "POST",
|
|
331
|
+
body: {
|
|
332
|
+
license_key: licenseKey,
|
|
333
|
+
device_identifier: options.deviceIdentifier || this.cache.getDeviceId(),
|
|
334
|
+
product_slug: options.productSlug,
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Normalize response: API returns { valid, license: { active_entitlements, ... } }
|
|
339
|
+
// SDK expects flat structure { valid, active_entitlements, ... }
|
|
340
|
+
const response = {
|
|
341
|
+
valid: rawResponse.valid,
|
|
342
|
+
...(rawResponse.license || {}),
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// Preserve cached entitlements if server response omits them
|
|
346
|
+
const cachedLicense = this.cache.getLicense();
|
|
347
|
+
if (
|
|
348
|
+
(!response.active_entitlements ||
|
|
349
|
+
response.active_entitlements.length === 0) &&
|
|
350
|
+
cachedLicense?.validation?.active_entitlements?.length
|
|
351
|
+
) {
|
|
352
|
+
response.active_entitlements =
|
|
353
|
+
cachedLicense.validation.active_entitlements;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (cachedLicense && cachedLicense.license_key === licenseKey) {
|
|
357
|
+
this.cache.updateValidation(response);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (response.valid) {
|
|
361
|
+
this.emit("validation:success", response);
|
|
362
|
+
this.cache.setLastSeenTimestamp(Date.now());
|
|
363
|
+
} else {
|
|
364
|
+
this.emit("validation:failed", response);
|
|
365
|
+
this.stopAutoValidation();
|
|
366
|
+
this.currentAutoLicenseKey = null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
this.cache.setLastSeenTimestamp(Date.now());
|
|
370
|
+
return response;
|
|
371
|
+
} catch (error) {
|
|
372
|
+
this.emit("validation:error", { licenseKey, error });
|
|
373
|
+
|
|
374
|
+
// Check for offline fallback
|
|
375
|
+
const isNetworkFailure =
|
|
376
|
+
(error instanceof TypeError && error.message.includes("fetch")) ||
|
|
377
|
+
(error instanceof APIError && [0, 408].includes(error.status));
|
|
378
|
+
|
|
379
|
+
if (this.config.offlineFallbackEnabled && isNetworkFailure) {
|
|
380
|
+
const offlineResult = await this.verifyCachedOffline();
|
|
381
|
+
|
|
382
|
+
const cachedLicense = this.cache.getLicense();
|
|
383
|
+
if (cachedLicense && cachedLicense.license_key === licenseKey) {
|
|
384
|
+
this.cache.updateValidation(offlineResult);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (offlineResult.valid) {
|
|
388
|
+
this.emit("validation:offline-success", offlineResult);
|
|
389
|
+
return offlineResult;
|
|
390
|
+
} else {
|
|
391
|
+
this.emit("validation:offline-failed", offlineResult);
|
|
392
|
+
this.stopAutoValidation();
|
|
393
|
+
this.currentAutoLicenseKey = null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Persist invalid status
|
|
398
|
+
if (error instanceof APIError && error.data) {
|
|
399
|
+
const cachedLicense = this.cache.getLicense();
|
|
400
|
+
if (cachedLicense && cachedLicense.license_key === licenseKey) {
|
|
401
|
+
this.cache.updateValidation({ valid: false, ...error.data });
|
|
402
|
+
}
|
|
403
|
+
if (![0, 408, 429].includes(error.status)) {
|
|
404
|
+
this.stopAutoValidation();
|
|
405
|
+
this.currentAutoLicenseKey = null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Check if a specific entitlement is active (detailed version)
|
|
415
|
+
* @param {string} entitlementKey - The entitlement key to check
|
|
416
|
+
* @returns {import('./types.js').EntitlementCheckResult} Entitlement status with details
|
|
417
|
+
*/
|
|
418
|
+
checkEntitlement(entitlementKey) {
|
|
419
|
+
const license = this.cache.getLicense();
|
|
420
|
+
if (!license || !license.validation) {
|
|
421
|
+
return { active: false, reason: "no_license" };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const entitlements = license.validation.active_entitlements || [];
|
|
425
|
+
const entitlement = entitlements.find((e) => e.key === entitlementKey);
|
|
426
|
+
|
|
427
|
+
if (!entitlement) {
|
|
428
|
+
return { active: false, reason: "not_found" };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (entitlement.expires_at) {
|
|
432
|
+
const expiresAt = new Date(entitlement.expires_at);
|
|
433
|
+
const now = new Date();
|
|
434
|
+
|
|
435
|
+
if (expiresAt < now) {
|
|
436
|
+
return {
|
|
437
|
+
active: false,
|
|
438
|
+
reason: "expired",
|
|
439
|
+
expires_at: entitlement.expires_at,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return { active: true, entitlement };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Check if a specific entitlement is active (simple boolean version)
|
|
449
|
+
* This is a convenience method that returns a simple boolean.
|
|
450
|
+
* Use checkEntitlement() for detailed status information.
|
|
451
|
+
* @param {string} entitlementKey - The entitlement key to check
|
|
452
|
+
* @returns {boolean} True if the entitlement is active, false otherwise
|
|
453
|
+
*/
|
|
454
|
+
hasEntitlement(entitlementKey) {
|
|
455
|
+
return this.checkEntitlement(entitlementKey).active;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Get offline license data from the server
|
|
460
|
+
* @returns {Promise<import('./types.js').SignedOfflineLicense>} Signed offline license data
|
|
461
|
+
* @throws {LicenseError} When no active license is found
|
|
462
|
+
* @throws {APIError} When the API request fails
|
|
463
|
+
*/
|
|
464
|
+
async getOfflineLicense() {
|
|
465
|
+
const license = this.cache.getLicense();
|
|
466
|
+
if (!license || !license.license_key) {
|
|
467
|
+
const errorMsg =
|
|
468
|
+
"No active license key found in cache to fetch offline license.";
|
|
469
|
+
this.emit("sdk:error", { message: errorMsg });
|
|
470
|
+
throw new LicenseError(errorMsg, "no_license");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
this.emit("offlineLicense:fetching", { licenseKey: license.license_key });
|
|
475
|
+
const path = `/licenses/${license.license_key}/offline_license`;
|
|
476
|
+
|
|
477
|
+
const response = await this.apiCall(path, { method: "POST" });
|
|
478
|
+
|
|
479
|
+
this.emit("offlineLicense:fetched", {
|
|
480
|
+
licenseKey: license.license_key,
|
|
481
|
+
data: response,
|
|
482
|
+
});
|
|
483
|
+
return response;
|
|
484
|
+
} catch (error) {
|
|
485
|
+
this.log(
|
|
486
|
+
`Failed to get offline license for ${license.license_key}:`,
|
|
487
|
+
error
|
|
488
|
+
);
|
|
489
|
+
this.emit("offlineLicense:fetchError", {
|
|
490
|
+
licenseKey: license.license_key,
|
|
491
|
+
error: error,
|
|
492
|
+
});
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Fetch a public key from the server by key ID
|
|
499
|
+
* @param {string} keyId - The Key ID (kid) for which to fetch the public key
|
|
500
|
+
* @returns {Promise<string>} Base64-encoded public key
|
|
501
|
+
* @throws {Error} When keyId is not provided or the key is not found
|
|
502
|
+
*/
|
|
503
|
+
async getPublicKey(keyId) {
|
|
504
|
+
if (!keyId) {
|
|
505
|
+
throw new Error("Key ID is required to fetch a public key.");
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
this.log(`Fetching public key for kid: ${keyId}`);
|
|
509
|
+
const response = await this.apiCall(`/public_keys/${keyId}`, {
|
|
510
|
+
method: "GET",
|
|
511
|
+
});
|
|
512
|
+
if (response && response.public_key_b64) {
|
|
513
|
+
this.log(`Successfully fetched public key for kid: ${keyId}`);
|
|
514
|
+
return response.public_key_b64;
|
|
515
|
+
} else {
|
|
516
|
+
throw new Error(
|
|
517
|
+
`Public key not found or invalid response for kid: ${keyId}`
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
} catch (error) {
|
|
521
|
+
this.log(`Failed to fetch public key for kid ${keyId}:`, error);
|
|
522
|
+
throw error;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Verify a signed offline license client-side using Ed25519
|
|
528
|
+
* @param {import('./types.js').SignedOfflineLicense} signedLicenseData - The signed license data
|
|
529
|
+
* @param {string} publicKeyB64 - Base64-encoded public Ed25519 key
|
|
530
|
+
* @returns {Promise<boolean>} True if verification is successful
|
|
531
|
+
* @throws {CryptoError} When crypto library is not available
|
|
532
|
+
* @throws {Error} When inputs are invalid
|
|
533
|
+
*/
|
|
534
|
+
async verifyOfflineLicense(signedLicenseData, publicKeyB64) {
|
|
535
|
+
this.log("Attempting to verify offline license client-side.");
|
|
536
|
+
if (
|
|
537
|
+
!signedLicenseData ||
|
|
538
|
+
!signedLicenseData.payload ||
|
|
539
|
+
!signedLicenseData.signature_b64u
|
|
540
|
+
) {
|
|
541
|
+
throw new Error("Invalid signedLicenseData object provided.");
|
|
542
|
+
}
|
|
543
|
+
if (!publicKeyB64) {
|
|
544
|
+
throw new Error("Public key (Base64 encoded) is required.");
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (!ed || !ed.verify || !ed.etc.sha512Sync) {
|
|
548
|
+
const err = new CryptoError(
|
|
549
|
+
"noble-ed25519 crypto library not available/configured for offline verification."
|
|
550
|
+
);
|
|
551
|
+
this.emit("sdk:error", { message: err.message });
|
|
552
|
+
throw err;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
const payloadString = canonicalJsonStringify(signedLicenseData.payload);
|
|
557
|
+
const messageBytes = new TextEncoder().encode(payloadString);
|
|
558
|
+
const publicKeyBytes = base64UrlDecode(publicKeyB64);
|
|
559
|
+
const signatureBytes = base64UrlDecode(signedLicenseData.signature_b64u);
|
|
560
|
+
|
|
561
|
+
const isValid = ed.verify(signatureBytes, messageBytes, publicKeyBytes);
|
|
562
|
+
|
|
563
|
+
if (isValid) {
|
|
564
|
+
this.log(
|
|
565
|
+
"Offline license signature VERIFIED successfully client-side."
|
|
566
|
+
);
|
|
567
|
+
this.emit("offlineLicense:verified", {
|
|
568
|
+
payload: signedLicenseData.payload,
|
|
569
|
+
});
|
|
570
|
+
} else {
|
|
571
|
+
this.log("Offline license signature INVALID client-side.");
|
|
572
|
+
this.emit("offlineLicense:verificationFailed", {
|
|
573
|
+
payload: signedLicenseData.payload,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
return isValid;
|
|
577
|
+
} catch (error) {
|
|
578
|
+
this.log("Client-side offline license verification error:", error);
|
|
579
|
+
this.emit("sdk:error", {
|
|
580
|
+
message: "Client-side verification failed.",
|
|
581
|
+
error: error,
|
|
582
|
+
});
|
|
583
|
+
throw error;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Get current license status
|
|
589
|
+
* @returns {import('./types.js').LicenseStatus} Current license status
|
|
590
|
+
*/
|
|
591
|
+
getStatus() {
|
|
592
|
+
const license = this.cache.getLicense();
|
|
593
|
+
if (!license) {
|
|
594
|
+
return { status: "inactive", message: "No license activated" };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const validation = license.validation;
|
|
598
|
+
if (!validation) {
|
|
599
|
+
return { status: "pending", message: "License pending validation" };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (!validation.valid) {
|
|
603
|
+
if (validation.offline) {
|
|
604
|
+
return {
|
|
605
|
+
status: "offline-invalid",
|
|
606
|
+
message: validation.reason_code || "License invalid (offline)",
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
return {
|
|
610
|
+
status: "invalid",
|
|
611
|
+
message: validation.reason || "License invalid",
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (validation.offline) {
|
|
616
|
+
return {
|
|
617
|
+
status: "offline-valid",
|
|
618
|
+
license: license.license_key,
|
|
619
|
+
device: license.device_identifier,
|
|
620
|
+
activated_at: license.activated_at,
|
|
621
|
+
last_validated: license.last_validated,
|
|
622
|
+
entitlements: validation.active_entitlements || [],
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
status: "active",
|
|
628
|
+
license: license.license_key,
|
|
629
|
+
device: license.device_identifier,
|
|
630
|
+
activated_at: license.activated_at,
|
|
631
|
+
last_validated: license.last_validated,
|
|
632
|
+
entitlements: validation.active_entitlements || [],
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Test server authentication
|
|
638
|
+
* Useful for verifying API key/session is valid.
|
|
639
|
+
* @returns {Promise<Object>} Result from the server
|
|
640
|
+
* @throws {Error} When API key is not configured
|
|
641
|
+
* @throws {APIError} When authentication fails
|
|
642
|
+
*/
|
|
643
|
+
async testAuth() {
|
|
644
|
+
if (!this.config.apiKey) {
|
|
645
|
+
const err = new Error("API key is required for auth test");
|
|
646
|
+
this.emit("auth_test:error", { error: err });
|
|
647
|
+
throw err;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
try {
|
|
651
|
+
this.emit("auth_test:start");
|
|
652
|
+
const response = await this.apiCall("/auth_test", { method: "GET" });
|
|
653
|
+
this.emit("auth_test:success", response);
|
|
654
|
+
return response;
|
|
655
|
+
} catch (error) {
|
|
656
|
+
this.emit("auth_test:error", { error });
|
|
657
|
+
throw error;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Clear all data and reset SDK state
|
|
663
|
+
* @returns {void}
|
|
664
|
+
*/
|
|
665
|
+
reset() {
|
|
666
|
+
this.stopAutoValidation();
|
|
667
|
+
this.stopConnectivityPolling();
|
|
668
|
+
if (this.offlineRefreshTimer) {
|
|
669
|
+
clearInterval(this.offlineRefreshTimer);
|
|
670
|
+
this.offlineRefreshTimer = null;
|
|
671
|
+
}
|
|
672
|
+
this.cache.clear();
|
|
673
|
+
this.lastOfflineValidation = null;
|
|
674
|
+
this.currentAutoLicenseKey = null;
|
|
675
|
+
this.emit("sdk:reset");
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Destroy the SDK instance and release all resources
|
|
680
|
+
* Call this when you no longer need the SDK to prevent memory leaks.
|
|
681
|
+
* After calling destroy(), the SDK instance should not be used.
|
|
682
|
+
* @returns {void}
|
|
683
|
+
*/
|
|
684
|
+
destroy() {
|
|
685
|
+
this.destroyed = true;
|
|
686
|
+
this.stopAutoValidation();
|
|
687
|
+
this.stopConnectivityPolling();
|
|
688
|
+
if (this.offlineRefreshTimer) {
|
|
689
|
+
clearInterval(this.offlineRefreshTimer);
|
|
690
|
+
this.offlineRefreshTimer = null;
|
|
691
|
+
}
|
|
692
|
+
this.eventListeners = {};
|
|
693
|
+
this.cache.clear();
|
|
694
|
+
this.lastOfflineValidation = null;
|
|
695
|
+
this.currentAutoLicenseKey = null;
|
|
696
|
+
this.emit("sdk:destroyed");
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ============================================================
|
|
700
|
+
// Event Handling
|
|
701
|
+
// ============================================================
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Subscribe to an event
|
|
705
|
+
* @param {string} event - Event name
|
|
706
|
+
* @param {import('./types.js').EventCallback} callback - Event handler
|
|
707
|
+
* @returns {import('./types.js').EventUnsubscribe} Unsubscribe function
|
|
708
|
+
*/
|
|
709
|
+
on(event, callback) {
|
|
710
|
+
if (!this.eventListeners[event]) {
|
|
711
|
+
this.eventListeners[event] = [];
|
|
712
|
+
}
|
|
713
|
+
this.eventListeners[event].push(callback);
|
|
714
|
+
return () => this.off(event, callback);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Unsubscribe from an event
|
|
719
|
+
* @param {string} event - Event name
|
|
720
|
+
* @param {import('./types.js').EventCallback} callback - Event handler to remove
|
|
721
|
+
* @returns {void}
|
|
722
|
+
*/
|
|
723
|
+
off(event, callback) {
|
|
724
|
+
if (this.eventListeners[event]) {
|
|
725
|
+
this.eventListeners[event] = this.eventListeners[event].filter(
|
|
726
|
+
(cb) => cb !== callback
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Emit an event
|
|
733
|
+
* @param {string} event - Event name
|
|
734
|
+
* @param {*} data - Event data
|
|
735
|
+
* @returns {void}
|
|
736
|
+
* @private
|
|
737
|
+
*/
|
|
738
|
+
emit(event, data) {
|
|
739
|
+
this.log(`Event: ${event}`, data);
|
|
740
|
+
if (this.eventListeners[event]) {
|
|
741
|
+
this.eventListeners[event].forEach((callback) => {
|
|
742
|
+
try {
|
|
743
|
+
callback(data);
|
|
744
|
+
} catch (error) {
|
|
745
|
+
console.error(`Error in event listener for ${event}:`, error);
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ============================================================
|
|
752
|
+
// Auto-Validation & Connectivity
|
|
753
|
+
// ============================================================
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Start automatic license validation
|
|
757
|
+
* @param {string} licenseKey - License key to validate
|
|
758
|
+
* @returns {void}
|
|
759
|
+
* @private
|
|
760
|
+
*/
|
|
761
|
+
startAutoValidation(licenseKey) {
|
|
762
|
+
this.stopAutoValidation();
|
|
763
|
+
|
|
764
|
+
this.currentAutoLicenseKey = licenseKey;
|
|
765
|
+
const validationInterval = this.config.autoValidateInterval;
|
|
766
|
+
|
|
767
|
+
const performAndReschedule = () => {
|
|
768
|
+
this.validateLicense(licenseKey).catch((err) => {
|
|
769
|
+
this.log("Auto-validation failed:", err);
|
|
770
|
+
this.emit("validation:auto-failed", { licenseKey, error: err });
|
|
771
|
+
});
|
|
772
|
+
this.emit("autovalidation:cycle", {
|
|
773
|
+
nextRunAt: new Date(Date.now() + validationInterval),
|
|
774
|
+
});
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
this.validationTimer = setInterval(performAndReschedule, validationInterval);
|
|
778
|
+
|
|
779
|
+
this.emit("autovalidation:cycle", {
|
|
780
|
+
nextRunAt: new Date(Date.now() + validationInterval),
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Stop automatic validation
|
|
786
|
+
* @returns {void}
|
|
787
|
+
* @private
|
|
788
|
+
*/
|
|
789
|
+
stopAutoValidation() {
|
|
790
|
+
if (this.validationTimer) {
|
|
791
|
+
clearInterval(this.validationTimer);
|
|
792
|
+
this.validationTimer = null;
|
|
793
|
+
this.emit("autovalidation:stopped");
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Start connectivity polling (when offline)
|
|
799
|
+
* @returns {void}
|
|
800
|
+
* @private
|
|
801
|
+
*/
|
|
802
|
+
startConnectivityPolling() {
|
|
803
|
+
if (this.connectivityTimer) return;
|
|
804
|
+
|
|
805
|
+
const heartbeat = async () => {
|
|
806
|
+
try {
|
|
807
|
+
await fetch(`${this.config.apiBaseUrl}/heartbeat`, {
|
|
808
|
+
method: "GET",
|
|
809
|
+
credentials: "omit",
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
if (!this.online) {
|
|
813
|
+
this.online = true;
|
|
814
|
+
this.emit("network:online");
|
|
815
|
+
if (this.currentAutoLicenseKey && !this.validationTimer) {
|
|
816
|
+
this.startAutoValidation(this.currentAutoLicenseKey);
|
|
817
|
+
}
|
|
818
|
+
this.syncOfflineAssets();
|
|
819
|
+
}
|
|
820
|
+
this.stopConnectivityPolling();
|
|
821
|
+
} catch (err) {
|
|
822
|
+
// Still offline
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
this.connectivityTimer = setInterval(
|
|
827
|
+
heartbeat,
|
|
828
|
+
this.config.networkRecheckInterval
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Stop connectivity polling
|
|
834
|
+
* @returns {void}
|
|
835
|
+
* @private
|
|
836
|
+
*/
|
|
837
|
+
stopConnectivityPolling() {
|
|
838
|
+
if (this.connectivityTimer) {
|
|
839
|
+
clearInterval(this.connectivityTimer);
|
|
840
|
+
this.connectivityTimer = null;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ============================================================
|
|
845
|
+
// Offline License Management
|
|
846
|
+
// ============================================================
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Fetch and cache offline license and public key
|
|
850
|
+
* Uses a lock to prevent concurrent calls from causing race conditions
|
|
851
|
+
* @returns {Promise<void>}
|
|
852
|
+
* @private
|
|
853
|
+
*/
|
|
854
|
+
async syncOfflineAssets() {
|
|
855
|
+
// Prevent concurrent syncs
|
|
856
|
+
if (this.syncingOfflineAssets || this.destroyed) {
|
|
857
|
+
this.log("Skipping syncOfflineAssets: already syncing or destroyed");
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
this.syncingOfflineAssets = true;
|
|
862
|
+
try {
|
|
863
|
+
const offline = await this.getOfflineLicense();
|
|
864
|
+
this.cache.setOfflineLicense(offline);
|
|
865
|
+
|
|
866
|
+
const kid = offline.kid || offline.payload?.kid;
|
|
867
|
+
if (kid) {
|
|
868
|
+
const existingKey = this.cache.getPublicKey(kid);
|
|
869
|
+
if (!existingKey) {
|
|
870
|
+
const pub = await this.getPublicKey(kid);
|
|
871
|
+
this.cache.setPublicKey(kid, pub);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
this.emit("offlineLicense:ready", {
|
|
876
|
+
kid: offline.kid || offline.payload?.kid,
|
|
877
|
+
exp_at: offline.payload?.exp_at,
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
// Verify freshly-cached assets
|
|
881
|
+
const res = await this.quickVerifyCachedOfflineLocal();
|
|
882
|
+
if (res) {
|
|
883
|
+
this.cache.updateValidation(res);
|
|
884
|
+
this.emit(
|
|
885
|
+
res.valid ? "validation:offline-success" : "validation:offline-failed",
|
|
886
|
+
res
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
} catch (err) {
|
|
890
|
+
this.log("Failed to sync offline assets:", err);
|
|
891
|
+
} finally {
|
|
892
|
+
this.syncingOfflineAssets = false;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Schedule periodic offline license refresh
|
|
898
|
+
* @returns {void}
|
|
899
|
+
* @private
|
|
900
|
+
*/
|
|
901
|
+
scheduleOfflineRefresh() {
|
|
902
|
+
if (this.offlineRefreshTimer) clearInterval(this.offlineRefreshTimer);
|
|
903
|
+
this.offlineRefreshTimer = setInterval(
|
|
904
|
+
() => this.syncOfflineAssets(),
|
|
905
|
+
this.config.offlineLicenseRefreshInterval
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Verify cached offline license
|
|
911
|
+
* @returns {Promise<import('./types.js').ValidationResult>}
|
|
912
|
+
* @private
|
|
913
|
+
*/
|
|
914
|
+
async verifyCachedOffline() {
|
|
915
|
+
const signed = this.cache.getOfflineLicense();
|
|
916
|
+
if (!signed) {
|
|
917
|
+
return { valid: false, offline: true, reason_code: "no_offline_license" };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const kid = signed.kid || signed.payload?.kid;
|
|
921
|
+
let pub = kid ? this.cache.getPublicKey(kid) : null;
|
|
922
|
+
if (!pub) {
|
|
923
|
+
try {
|
|
924
|
+
pub = await this.getPublicKey(kid);
|
|
925
|
+
this.cache.setPublicKey(kid, pub);
|
|
926
|
+
} catch (e) {
|
|
927
|
+
return { valid: false, offline: true, reason_code: "no_public_key" };
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
try {
|
|
932
|
+
const ok = await this.verifyOfflineLicense(signed, pub);
|
|
933
|
+
if (!ok) {
|
|
934
|
+
return { valid: false, offline: true, reason_code: "signature_invalid" };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** @type {import('./types.js').OfflineLicensePayload} */
|
|
938
|
+
const payload = signed.payload || {};
|
|
939
|
+
const cached = this.cache.getLicense();
|
|
940
|
+
|
|
941
|
+
// License key match
|
|
942
|
+
if (
|
|
943
|
+
!cached ||
|
|
944
|
+
!constantTimeEqual(payload.lic_k || "", cached.license_key || "")
|
|
945
|
+
) {
|
|
946
|
+
return { valid: false, offline: true, reason_code: "license_mismatch" };
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// Expiry check
|
|
950
|
+
const now = Date.now();
|
|
951
|
+
const expAt = payload.exp_at ? Date.parse(payload.exp_at) : null;
|
|
952
|
+
if (expAt && expAt < now) {
|
|
953
|
+
return { valid: false, offline: true, reason_code: "expired" };
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Grace period check
|
|
957
|
+
if (!expAt && this.config.maxOfflineDays > 0) {
|
|
958
|
+
const pivot = cached.last_validated || cached.activated_at;
|
|
959
|
+
if (pivot) {
|
|
960
|
+
const ageMs = now - new Date(pivot).getTime();
|
|
961
|
+
if (ageMs > this.config.maxOfflineDays * 24 * 60 * 60 * 1000) {
|
|
962
|
+
return {
|
|
963
|
+
valid: false,
|
|
964
|
+
offline: true,
|
|
965
|
+
reason_code: "grace_period_expired",
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Clock tamper detection
|
|
972
|
+
const lastSeen = this.cache.getLastSeenTimestamp();
|
|
973
|
+
if (lastSeen && now + this.config.maxClockSkewMs < lastSeen) {
|
|
974
|
+
return { valid: false, offline: true, reason_code: "clock_tamper" };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
this.cache.setLastSeenTimestamp(now);
|
|
978
|
+
|
|
979
|
+
const active = parseActiveEntitlements(payload);
|
|
980
|
+
return {
|
|
981
|
+
valid: true,
|
|
982
|
+
offline: true,
|
|
983
|
+
...(active.length ? { active_entitlements: active } : {}),
|
|
984
|
+
};
|
|
985
|
+
} catch (e) {
|
|
986
|
+
return { valid: false, offline: true, reason_code: "verification_error" };
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Quick offline verification using only local data (no network)
|
|
992
|
+
* Performs signature verification plus basic validity checks (expiry, license key match)
|
|
993
|
+
* @returns {Promise<import('./types.js').ValidationResult|null>}
|
|
994
|
+
* @private
|
|
995
|
+
*/
|
|
996
|
+
async quickVerifyCachedOfflineLocal() {
|
|
997
|
+
const signed = this.cache.getOfflineLicense();
|
|
998
|
+
if (!signed) return null;
|
|
999
|
+
const kid = signed.kid || signed.payload?.kid;
|
|
1000
|
+
const pub = kid ? this.cache.getPublicKey(kid) : null;
|
|
1001
|
+
if (!pub) return null;
|
|
1002
|
+
|
|
1003
|
+
try {
|
|
1004
|
+
const ok = await this.verifyOfflineLicense(signed, pub);
|
|
1005
|
+
if (!ok) {
|
|
1006
|
+
return { valid: false, offline: true, reason_code: "signature_invalid" };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/** @type {import('./types.js').OfflineLicensePayload} */
|
|
1010
|
+
const payload = signed.payload || {};
|
|
1011
|
+
const cached = this.cache.getLicense();
|
|
1012
|
+
|
|
1013
|
+
// License key match check
|
|
1014
|
+
if (
|
|
1015
|
+
!cached ||
|
|
1016
|
+
!constantTimeEqual(payload.lic_k || "", cached.license_key || "")
|
|
1017
|
+
) {
|
|
1018
|
+
return { valid: false, offline: true, reason_code: "license_mismatch" };
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Expiry check
|
|
1022
|
+
const now = Date.now();
|
|
1023
|
+
const expAt = payload.exp_at ? Date.parse(payload.exp_at) : null;
|
|
1024
|
+
if (expAt && expAt < now) {
|
|
1025
|
+
return { valid: false, offline: true, reason_code: "expired" };
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Clock tamper detection
|
|
1029
|
+
const lastSeen = this.cache.getLastSeenTimestamp();
|
|
1030
|
+
if (lastSeen && now + this.config.maxClockSkewMs < lastSeen) {
|
|
1031
|
+
return { valid: false, offline: true, reason_code: "clock_tamper" };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const active = parseActiveEntitlements(payload);
|
|
1035
|
+
return {
|
|
1036
|
+
valid: true,
|
|
1037
|
+
offline: true,
|
|
1038
|
+
...(active.length ? { active_entitlements: active } : {}),
|
|
1039
|
+
};
|
|
1040
|
+
} catch (_) {
|
|
1041
|
+
return { valid: false, offline: true, reason_code: "verification_error" };
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// ============================================================
|
|
1046
|
+
// API Communication
|
|
1047
|
+
// ============================================================
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Make an API call with retry logic
|
|
1051
|
+
* @param {string} endpoint - API endpoint (will be appended to apiBaseUrl)
|
|
1052
|
+
* @param {Object} [options={}] - Fetch options
|
|
1053
|
+
* @param {string} [options.method="GET"] - HTTP method
|
|
1054
|
+
* @param {Object} [options.body] - Request body (will be JSON-stringified)
|
|
1055
|
+
* @param {Object} [options.headers] - Additional headers
|
|
1056
|
+
* @returns {Promise<Object>} API response data
|
|
1057
|
+
* @throws {APIError} When the request fails after all retries
|
|
1058
|
+
* @private
|
|
1059
|
+
*/
|
|
1060
|
+
async apiCall(endpoint, options = {}) {
|
|
1061
|
+
const url = `${this.config.apiBaseUrl}${endpoint}`;
|
|
1062
|
+
let lastError;
|
|
1063
|
+
|
|
1064
|
+
const headers = {
|
|
1065
|
+
"Content-Type": "application/json",
|
|
1066
|
+
Accept: "application/json",
|
|
1067
|
+
...options.headers,
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
if (this.config.apiKey) {
|
|
1071
|
+
headers["Authorization"] = `Bearer ${this.config.apiKey}`;
|
|
1072
|
+
} else {
|
|
1073
|
+
this.log(
|
|
1074
|
+
"[Warning] No API key configured for LicenseSeat SDK. Authenticated endpoints will fail."
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
|
|
1079
|
+
try {
|
|
1080
|
+
const response = await fetch(url, {
|
|
1081
|
+
method: options.method || "GET",
|
|
1082
|
+
headers: headers,
|
|
1083
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
1084
|
+
credentials: "omit",
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
const data = await response.json();
|
|
1088
|
+
|
|
1089
|
+
if (!response.ok) {
|
|
1090
|
+
throw new APIError(
|
|
1091
|
+
data.error || "Request failed",
|
|
1092
|
+
response.status,
|
|
1093
|
+
data
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Back online
|
|
1098
|
+
if (!this.online) {
|
|
1099
|
+
this.online = true;
|
|
1100
|
+
this.emit("network:online");
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
this.stopConnectivityPolling();
|
|
1104
|
+
|
|
1105
|
+
if (!this.validationTimer && this.currentAutoLicenseKey) {
|
|
1106
|
+
this.startAutoValidation(this.currentAutoLicenseKey);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
return data;
|
|
1110
|
+
} catch (error) {
|
|
1111
|
+
const networkFailure =
|
|
1112
|
+
(error instanceof TypeError && error.message.includes("fetch")) ||
|
|
1113
|
+
(error instanceof APIError && error.status === 0);
|
|
1114
|
+
|
|
1115
|
+
if (networkFailure && this.online) {
|
|
1116
|
+
this.online = false;
|
|
1117
|
+
this.emit("network:offline", { error });
|
|
1118
|
+
this.stopAutoValidation();
|
|
1119
|
+
this.startConnectivityPolling();
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
lastError = error;
|
|
1123
|
+
|
|
1124
|
+
const shouldRetry =
|
|
1125
|
+
attempt < this.config.maxRetries && this.shouldRetryError(error);
|
|
1126
|
+
|
|
1127
|
+
if (shouldRetry) {
|
|
1128
|
+
const delay = this.config.retryDelay * Math.pow(2, attempt);
|
|
1129
|
+
this.log(
|
|
1130
|
+
`Retry attempt ${attempt + 1} after ${delay}ms for error:`,
|
|
1131
|
+
error.message
|
|
1132
|
+
);
|
|
1133
|
+
await sleep(delay);
|
|
1134
|
+
} else {
|
|
1135
|
+
throw error;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
throw lastError;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Determine if an error should be retried
|
|
1145
|
+
* @param {Error} error - The error to check
|
|
1146
|
+
* @returns {boolean} True if the error should trigger a retry
|
|
1147
|
+
* @private
|
|
1148
|
+
*/
|
|
1149
|
+
shouldRetryError(error) {
|
|
1150
|
+
if (error instanceof TypeError && error.message.includes("fetch")) {
|
|
1151
|
+
return true;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (error instanceof APIError) {
|
|
1155
|
+
const status = error.status;
|
|
1156
|
+
|
|
1157
|
+
// Retry on server errors (5xx) except 500 and 501
|
|
1158
|
+
if (status >= 502 && status < 600) {
|
|
1159
|
+
return true;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Retry on network-related errors
|
|
1163
|
+
if (status === 0 || status === 408 || status === 429) {
|
|
1164
|
+
return true;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
return false;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
return false;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// ============================================================
|
|
1174
|
+
// Utilities
|
|
1175
|
+
// ============================================================
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Get CSRF token from meta tag
|
|
1179
|
+
* @returns {string} CSRF token or empty string
|
|
1180
|
+
*/
|
|
1181
|
+
getCsrfToken() {
|
|
1182
|
+
return getCsrfToken();
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* Log a message (if debug mode is enabled)
|
|
1187
|
+
* @param {...*} args - Arguments to log
|
|
1188
|
+
* @returns {void}
|
|
1189
|
+
* @private
|
|
1190
|
+
*/
|
|
1191
|
+
log(...args) {
|
|
1192
|
+
if (this.config.debug) {
|
|
1193
|
+
console.log("[LicenseSeat SDK]", ...args);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// ============================================================
|
|
1199
|
+
// Singleton Pattern Support
|
|
1200
|
+
// ============================================================
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Shared singleton instance
|
|
1204
|
+
* @type {LicenseSeatSDK|null}
|
|
1205
|
+
* @private
|
|
1206
|
+
*/
|
|
1207
|
+
let sharedInstance = null;
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Get or create the shared singleton instance
|
|
1211
|
+
* @param {import('./types.js').LicenseSeatConfig} [config] - Configuration (only used on first call)
|
|
1212
|
+
* @returns {LicenseSeatSDK} The shared instance
|
|
1213
|
+
*/
|
|
1214
|
+
export function getSharedInstance(config) {
|
|
1215
|
+
if (!sharedInstance) {
|
|
1216
|
+
sharedInstance = new LicenseSeatSDK(config);
|
|
1217
|
+
}
|
|
1218
|
+
return sharedInstance;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Configure the shared singleton instance
|
|
1223
|
+
* @param {import('./types.js').LicenseSeatConfig} config - Configuration options
|
|
1224
|
+
* @param {boolean} [force=false] - Force reconfiguration even if already configured
|
|
1225
|
+
* @returns {LicenseSeatSDK} The configured shared instance
|
|
1226
|
+
*/
|
|
1227
|
+
export function configure(config, force = false) {
|
|
1228
|
+
if (sharedInstance && !force) {
|
|
1229
|
+
console.warn(
|
|
1230
|
+
"[LicenseSeat SDK] Already configured. Call configure with force=true to reconfigure."
|
|
1231
|
+
);
|
|
1232
|
+
return sharedInstance;
|
|
1233
|
+
}
|
|
1234
|
+
sharedInstance = new LicenseSeatSDK(config);
|
|
1235
|
+
return sharedInstance;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Reset the shared singleton instance
|
|
1240
|
+
* @returns {void}
|
|
1241
|
+
*/
|
|
1242
|
+
export function resetSharedInstance() {
|
|
1243
|
+
if (sharedInstance) {
|
|
1244
|
+
sharedInstance.reset();
|
|
1245
|
+
sharedInstance = null;
|
|
1246
|
+
}
|
|
1247
|
+
}
|