@licenseseat/js 0.1.0 → 0.2.0

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