@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.
@@ -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
+ }