@sidub-inc/licensing-client 1.3.43

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/dist/index.js ADDED
@@ -0,0 +1,1689 @@
1
+ 'use strict';
2
+
3
+ var React = require('react');
4
+
5
+ /**
6
+ * Error class for licensing-related errors
7
+ */
8
+ class LicensingError extends Error {
9
+ constructor(message, statusCode, response) {
10
+ super(message);
11
+ this.statusCode = statusCode;
12
+ this.response = response;
13
+ this.name = 'LicensingError';
14
+ }
15
+ }
16
+
17
+ /**
18
+ * License classification types
19
+ */
20
+ exports.LicenseClassificationType = void 0;
21
+ (function (LicenseClassificationType) {
22
+ /** Perpetual license with no expiration */
23
+ LicenseClassificationType["Perpetual"] = "Perpetual";
24
+ /** Subscription license with recurring billing */
25
+ LicenseClassificationType["Subscription"] = "Subscription";
26
+ /** Trial license with time-based expiration */
27
+ LicenseClassificationType["Trial"] = "Trial";
28
+ })(exports.LicenseClassificationType || (exports.LicenseClassificationType = {}));
29
+ const LICENSE_CLASSIFICATION_INT_MAP = {
30
+ 0: exports.LicenseClassificationType.Trial,
31
+ 1: exports.LicenseClassificationType.Subscription,
32
+ 2: exports.LicenseClassificationType.Perpetual,
33
+ };
34
+ /**
35
+ * Normalizes a license classification value from server integer or string to the TypeScript enum.
36
+ * The .NET server serializes enums as integers (0=Trial, 1=Subscription, 2=Perpetual).
37
+ */
38
+ function normalizeLicenseClassificationType(value) {
39
+ if (value === null || value === undefined)
40
+ return undefined;
41
+ if (typeof value === 'number')
42
+ return LICENSE_CLASSIFICATION_INT_MAP[value];
43
+ const match = Object.values(exports.LicenseClassificationType)
44
+ .find(v => v.toLowerCase() === String(value).toLowerCase());
45
+ return match;
46
+ }
47
+ /**
48
+ * Billing interval units for subscription licenses.
49
+ * Matches .NET BillingIntervalUnit enum with singular names.
50
+ */
51
+ exports.BillingIntervalUnit = void 0;
52
+ (function (BillingIntervalUnit) {
53
+ BillingIntervalUnit["Hour"] = "Hour";
54
+ BillingIntervalUnit["Day"] = "Day";
55
+ BillingIntervalUnit["Week"] = "Week";
56
+ BillingIntervalUnit["Month"] = "Month";
57
+ BillingIntervalUnit["Year"] = "Year";
58
+ })(exports.BillingIntervalUnit || (exports.BillingIntervalUnit = {}));
59
+ const BILLING_INTERVAL_INT_MAP = {
60
+ 0: exports.BillingIntervalUnit.Hour,
61
+ 1: exports.BillingIntervalUnit.Day,
62
+ 2: exports.BillingIntervalUnit.Week,
63
+ 3: exports.BillingIntervalUnit.Month,
64
+ 4: exports.BillingIntervalUnit.Year,
65
+ };
66
+ /**
67
+ * Normalizes a billing interval unit value from server integer or string to the TypeScript enum.
68
+ */
69
+ function normalizeBillingIntervalUnit(value) {
70
+ if (value === null || value === undefined)
71
+ return undefined;
72
+ if (typeof value === 'number')
73
+ return BILLING_INTERVAL_INT_MAP[value];
74
+ const match = Object.values(exports.BillingIntervalUnit)
75
+ .find(v => v.toLowerCase() === String(value).toLowerCase());
76
+ return match;
77
+ }
78
+
79
+ /**
80
+ * Encoding/decoding constants for credential strings
81
+ */
82
+ const ENCODING_PREFIX = 'SIDUB_LIC_';
83
+ const CURRENT_VERSION = 1;
84
+ /**
85
+ * Encodes a licensing credential to a portable string.
86
+ * This matches the .NET LicensingCredential.ToEncodedString() format.
87
+ *
88
+ * @param credential The credential to encode
89
+ * @returns A portable encoded string prefixed with SIDUB_LIC_
90
+ */
91
+ function encodeCredential(credential) {
92
+ const payload = {
93
+ Version: CURRENT_VERSION,
94
+ LicenseId: credential.licenseId,
95
+ ServiceKeyId: credential.serviceKeyId,
96
+ ServiceKeyPublicMember: credential.serviceKeyPublicMember,
97
+ ApiAccessKey: credential.apiAccessKey
98
+ };
99
+ const json = JSON.stringify(payload);
100
+ const base64 = btoa(json);
101
+ return `${ENCODING_PREFIX}${base64}`;
102
+ }
103
+ /**
104
+ * Decodes a licensing credential from an encoded string.
105
+ * This matches the .NET LicensingCredential.FromEncodedString() format.
106
+ *
107
+ * @param encodedString The encoded credential string
108
+ * @returns The decoded credential
109
+ * @throws {Error} If the format is invalid
110
+ */
111
+ function decodeCredential(encodedString) {
112
+ if (!encodedString || typeof encodedString !== 'string') {
113
+ throw new Error('Encoded credential string cannot be null or empty.');
114
+ }
115
+ const trimmed = encodedString.trim();
116
+ if (!trimmed.startsWith(ENCODING_PREFIX)) {
117
+ throw new Error(`Invalid credential format. Expected prefix '${ENCODING_PREFIX}'.`);
118
+ }
119
+ const base64 = trimmed.substring(ENCODING_PREFIX.length);
120
+ let json;
121
+ try {
122
+ json = atob(base64);
123
+ }
124
+ catch {
125
+ throw new Error('Invalid credential format. Base64 decoding failed.');
126
+ }
127
+ let payload;
128
+ try {
129
+ payload = JSON.parse(json);
130
+ }
131
+ catch {
132
+ throw new Error('Invalid credential format. JSON deserialization failed.');
133
+ }
134
+ if (!payload) {
135
+ throw new Error('Invalid credential format. Payload deserialization returned null.');
136
+ }
137
+ if (payload.Version > CURRENT_VERSION) {
138
+ throw new Error(`Unsupported credential version ${payload.Version}. Maximum supported version is ${CURRENT_VERSION}.`);
139
+ }
140
+ return {
141
+ licenseId: payload.LicenseId,
142
+ serviceKeyId: payload.ServiceKeyId,
143
+ serviceKeyPublicMember: payload.ServiceKeyPublicMember ?? '',
144
+ apiAccessKey: payload.ApiAccessKey ?? ''
145
+ };
146
+ }
147
+ /**
148
+ * Attempts to parse an encoded credential string.
149
+ *
150
+ * @param encodedString The encoded credential string
151
+ * @returns The decoded credential, or null if parsing failed
152
+ */
153
+ function tryDecodeCredential(encodedString) {
154
+ if (!encodedString) {
155
+ return null;
156
+ }
157
+ try {
158
+ return decodeCredential(encodedString);
159
+ }
160
+ catch {
161
+ return null;
162
+ }
163
+ }
164
+ /**
165
+ * Validates that a credential has all required fields for signature verification.
166
+ *
167
+ * @param credential The credential to validate
168
+ * @returns True if the credential has all required cryptography fields
169
+ */
170
+ function hasValidCryptoConfig(credential) {
171
+ if (!credential) {
172
+ return false;
173
+ }
174
+ return !!(credential.serviceKeyId &&
175
+ credential.serviceKeyPublicMember &&
176
+ credential.serviceKeyId.length > 0 &&
177
+ credential.serviceKeyPublicMember.length > 0);
178
+ }
179
+
180
+ /**
181
+ * Gets the normalized feature key from a feature object.
182
+ * Handles both client-side (featureId) and server-side (FeatureKey) naming conventions.
183
+ *
184
+ * @param feature The feature object to extract the key from
185
+ * @returns The feature key, or empty string if not found
186
+ */
187
+ function getFeatureKey(feature) {
188
+ if (!feature) {
189
+ return '';
190
+ }
191
+ // Check both camelCase (client) and PascalCase (server) conventions
192
+ return feature.featureId || feature.FeatureKey || '';
193
+ }
194
+ /**
195
+ * Checks if a feature matches the specified feature key.
196
+ * Handles both client-side (featureId) and server-side (FeatureKey) naming conventions.
197
+ *
198
+ * @param feature The feature to check
199
+ * @param featureKey The key to match against
200
+ * @returns True if the feature matches the key
201
+ */
202
+ function matchesFeatureKey(feature, featureKey) {
203
+ if (!feature || !featureKey) {
204
+ return false;
205
+ }
206
+ const key = getFeatureKey(feature);
207
+ return key === featureKey;
208
+ }
209
+ /**
210
+ * Finds a feature by key in a feature array.
211
+ *
212
+ * @param features Array of features to search
213
+ * @param featureKey The key to find
214
+ * @returns The matching feature, or undefined if not found
215
+ */
216
+ function findFeatureByKey(features, featureKey) {
217
+ if (!features || !Array.isArray(features)) {
218
+ return undefined;
219
+ }
220
+ return features.find(f => matchesFeatureKey(f, featureKey));
221
+ }
222
+ /**
223
+ * Checks if a feature with the specified key exists in the array.
224
+ *
225
+ * @param features Array of features to search
226
+ * @param featureKey The key to check for
227
+ * @returns True if a feature with the key exists
228
+ */
229
+ function hasFeature(features, featureKey) {
230
+ return findFeatureByKey(features, featureKey) !== undefined;
231
+ }
232
+
233
+ /**
234
+ * Base class for implementing license assertions.
235
+ * Provides a foundation for creating custom assertion logic.
236
+ *
237
+ * @typeParam T The type of license feature this assertion operates on
238
+ */
239
+ class LicenseAssertion {
240
+ }
241
+
242
+ /**
243
+ * Service access level types
244
+ */
245
+ exports.ServiceAccessLevel = void 0;
246
+ (function (ServiceAccessLevel) {
247
+ ServiceAccessLevel["Denied"] = "Denied";
248
+ ServiceAccessLevel["Allowed"] = "Allowed";
249
+ })(exports.ServiceAccessLevel || (exports.ServiceAccessLevel = {}));
250
+ const SERVICE_ACCESS_INT_MAP = {
251
+ 0: exports.ServiceAccessLevel.Denied,
252
+ 1: exports.ServiceAccessLevel.Allowed,
253
+ };
254
+ /**
255
+ * Normalizes a service access level value from server integer or string to the TypeScript enum.
256
+ * The .NET server serializes enums as integers (0=Denied, 1=Allowed).
257
+ */
258
+ function normalizeServiceAccessLevel(value) {
259
+ if (value === null || value === undefined)
260
+ return undefined;
261
+ if (typeof value === 'number')
262
+ return SERVICE_ACCESS_INT_MAP[value];
263
+ const match = Object.values(exports.ServiceAccessLevel)
264
+ .find(v => v.toLowerCase() === String(value).toLowerCase());
265
+ return match;
266
+ }
267
+ /**
268
+ * Gets the service access level from a feature, handling both casing conventions.
269
+ */
270
+ function getServiceAccessLevel(feature) {
271
+ const raw = feature.serviceAccessLevel ?? feature.ServiceAccessLevel;
272
+ return normalizeServiceAccessLevel(raw);
273
+ }
274
+ /**
275
+ * Assertion that checks if a service/feature has the required access level.
276
+ *
277
+ * @example
278
+ * ```typescript
279
+ * // Check if 'premium-features' is allowed
280
+ * const assertion = ServiceAccessAssertion.create('premium-features', ServiceAccessLevel.Allowed);
281
+ * const hasAccess = assertion.isSatisfied(authorization);
282
+ * ```
283
+ */
284
+ class ServiceAccessAssertion extends LicenseAssertion {
285
+ /**
286
+ * Creates a new service access assertion
287
+ * @param featureKey The feature key to check
288
+ * @param requiredAccessLevel The required access level (default: Allowed)
289
+ * @param serviceType Optional service type to filter by
290
+ */
291
+ constructor(featureKey, requiredAccessLevel = exports.ServiceAccessLevel.Allowed, serviceType) {
292
+ super();
293
+ this.requiredAccessLevel = requiredAccessLevel;
294
+ this.serviceType = serviceType;
295
+ this.featureKey = featureKey;
296
+ }
297
+ /**
298
+ * Checks if the authorization contains the feature with the required access level
299
+ */
300
+ isSatisfied(authorization) {
301
+ if (!authorization?.features) {
302
+ return false;
303
+ }
304
+ return authorization.features.some((feature) => {
305
+ const serverFeature = feature;
306
+ // Check if this is the matching feature
307
+ if (!matchesFeatureKey(serverFeature, this.featureKey)) {
308
+ return false;
309
+ }
310
+ // Check service type if specified
311
+ if (this.serviceType && serverFeature.serviceType !== this.serviceType) {
312
+ return false;
313
+ }
314
+ // Check access level
315
+ const accessLevel = getServiceAccessLevel(serverFeature);
316
+ if (accessLevel) {
317
+ return accessLevel === this.requiredAccessLevel;
318
+ }
319
+ // If no access level specified, assume allowed if feature exists
320
+ return true;
321
+ });
322
+ }
323
+ /**
324
+ * Factory method to create a service access assertion
325
+ * @param featureKey The feature key to check
326
+ * @param requiredAccessLevel The required access level (default: Allowed)
327
+ * @param serviceType Optional service type to filter by
328
+ */
329
+ static create(featureKey, requiredAccessLevel = exports.ServiceAccessLevel.Allowed, serviceType) {
330
+ return new ServiceAccessAssertion(featureKey, requiredAccessLevel, serviceType);
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Assertion that checks if a rate-limited feature is within its limit.
336
+ *
337
+ * @example
338
+ * ```typescript
339
+ * // Key-based construction (preferred)
340
+ * const assertion = RateLimitAssertion.create('api-calls');
341
+ * const withinLimit = assertion.isSatisfied(authorization);
342
+ *
343
+ * // Feature-based construction (when you already have the feature)
344
+ * const assertion = RateLimitAssertion.create(feature);
345
+ * ```
346
+ */
347
+ class RateLimitAssertion extends LicenseAssertion {
348
+ /**
349
+ * Creates a new rate limit assertion.
350
+ * @param featureKeyOrFeature A feature key string or a rate limit feature instance.
351
+ */
352
+ constructor(featureKeyOrFeature) {
353
+ super();
354
+ if (typeof featureKeyOrFeature === 'string') {
355
+ this.featureKey = featureKeyOrFeature;
356
+ }
357
+ else {
358
+ this.featureKey = featureKeyOrFeature.featureId;
359
+ }
360
+ }
361
+ /**
362
+ * Checks if the feature's consumption is within its rate limit.
363
+ * Prefers local feature state when available, falls back to server currentConsumption.
364
+ */
365
+ isSatisfied(authorization, featureStates) {
366
+ if (!authorization?.features) {
367
+ return false;
368
+ }
369
+ // Find the matching feature in the authorization
370
+ const authFeature = authorization.features.find((f) => matchesFeatureKey(f, this.featureKey));
371
+ if (!authFeature) {
372
+ return false;
373
+ }
374
+ // Get rate limit from the authoritative server feature
375
+ const rateLimit = authFeature.rateLimit;
376
+ // If no rate limit specified, assume unlimited
377
+ if (rateLimit === undefined || rateLimit === null) {
378
+ return true;
379
+ }
380
+ // Prefer local state if available
381
+ if (featureStates) {
382
+ const stateKey = `${authorization.licenseId}.${this.featureKey}`;
383
+ const localState = featureStates.get(stateKey);
384
+ if (localState) {
385
+ return localState.getConsumption() <= rateLimit;
386
+ }
387
+ }
388
+ // Fallback to server-reported currentConsumption
389
+ const consumption = authFeature.currentConsumption ?? 0;
390
+ return consumption <= rateLimit;
391
+ }
392
+ static create(featureKeyOrFeature) {
393
+ return new RateLimitAssertion(featureKeyOrFeature);
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Cryptography service for license signature validation.
399
+ * Uses the Web Crypto API for cross-platform compatibility.
400
+ */
401
+ /**
402
+ * Service for cryptographic operations on license entities.
403
+ * Implements signature verification using ECDSA with P-256 curve and SHA-256.
404
+ */
405
+ class CryptoService {
406
+ constructor(config) {
407
+ this.publicKey = null;
408
+ this.config = config;
409
+ }
410
+ /**
411
+ * Gets the service key ID
412
+ */
413
+ get serviceKeyId() {
414
+ return this.config.serviceKeyId;
415
+ }
416
+ /**
417
+ * Initializes the public key from the configured base64 string.
418
+ * Must be called before verifying signatures.
419
+ */
420
+ async initialize() {
421
+ if (this.publicKey) {
422
+ return; // Already initialized
423
+ }
424
+ try {
425
+ // Decode the base64 public key
426
+ const publicKeyBytes = this.base64ToArrayBuffer(this.config.serviceKeyPublicMember);
427
+ // Import the public key for verification
428
+ // The .NET platform uses ECDSA with P-256 (secp256r1) curve and SHA-256
429
+ this.publicKey = await crypto.subtle.importKey('spki', // SubjectPublicKeyInfo format (DER)
430
+ publicKeyBytes, {
431
+ name: 'ECDSA',
432
+ namedCurve: 'P-256'
433
+ }, false, // Not extractable
434
+ ['verify'] // Only used for verification
435
+ );
436
+ }
437
+ catch (error) {
438
+ throw new CryptoError(`Failed to initialize public key: ${error instanceof Error ? error.message : 'Unknown error'}`, 'KEY_IMPORT_FAILED');
439
+ }
440
+ }
441
+ /**
442
+ * Verifies the signature of a signed entity.
443
+ *
444
+ * @param data The data that was signed (serialized entity without signature)
445
+ * @param signature The signature to verify (base64-encoded)
446
+ * @returns True if the signature is valid, false otherwise
447
+ */
448
+ async verifySignature(data, signature) {
449
+ if (!this.publicKey) {
450
+ throw new CryptoError('CryptoService not initialized. Call initialize() first.', 'NOT_INITIALIZED');
451
+ }
452
+ try {
453
+ const signatureBytes = this.base64ToArrayBuffer(signature);
454
+ return await crypto.subtle.verify({
455
+ name: 'ECDSA',
456
+ hash: 'SHA-256'
457
+ }, this.publicKey, signatureBytes, data);
458
+ }
459
+ catch (error) {
460
+ throw new CryptoError(`Signature verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'VERIFICATION_FAILED');
461
+ }
462
+ }
463
+ /**
464
+ * Converts a base64 string to an ArrayBuffer.
465
+ * Handles both standard base64 and base64url encoding.
466
+ */
467
+ base64ToArrayBuffer(base64) {
468
+ // Handle both standard base64 and base64url encoding
469
+ const normalizedBase64 = base64
470
+ .replace(/-/g, '+')
471
+ .replace(/_/g, '/');
472
+ // Add padding if necessary
473
+ const paddedBase64 = normalizedBase64 + '='.repeat((4 - normalizedBase64.length % 4) % 4);
474
+ const binaryString = atob(paddedBase64);
475
+ const bytes = new Uint8Array(binaryString.length);
476
+ for (let i = 0; i < binaryString.length; i++) {
477
+ bytes[i] = binaryString.charCodeAt(i);
478
+ }
479
+ return bytes.buffer;
480
+ }
481
+ }
482
+ /**
483
+ * Error class for cryptography-related errors
484
+ */
485
+ class CryptoError extends Error {
486
+ constructor(message, code) {
487
+ super(message);
488
+ this.code = code;
489
+ this.name = 'CryptoError';
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Signature validator for license authorization entities.
495
+ * Validates that the authorization returned from the server has not been tampered with.
496
+ */
497
+ /**
498
+ * Validates license authorization signatures to ensure integrity.
499
+ *
500
+ * This class implements the client-side portion of the signature verification
501
+ * that mirrors the .NET LicensingService.GetAuthorization() validation logic.
502
+ */
503
+ class SignatureValidator {
504
+ /**
505
+ * Creates a new SignatureValidator instance.
506
+ *
507
+ * @param config Cryptography configuration. If null, validation will be skipped.
508
+ */
509
+ constructor(config) {
510
+ this.cryptoService = null;
511
+ this.initialized = false;
512
+ if (config) {
513
+ this.cryptoService = new CryptoService(config);
514
+ }
515
+ }
516
+ /**
517
+ * Gets whether cryptographic validation is configured
518
+ */
519
+ get isConfigured() {
520
+ return this.cryptoService !== null;
521
+ }
522
+ /**
523
+ * Initializes the validator. Must be called before validating signatures.
524
+ */
525
+ async initialize() {
526
+ if (this.initialized || !this.cryptoService) {
527
+ return;
528
+ }
529
+ await this.cryptoService.initialize();
530
+ this.initialized = true;
531
+ }
532
+ /**
533
+ * Validates the signature of a license authorization using the raw server response.
534
+ *
535
+ * The validation process:
536
+ * 1. Extracts the signature from __sidub_entitySignature
537
+ * 2. Serializes the raw response data (excluding signature field)
538
+ * 3. Verifies the signature using the configured public key
539
+ *
540
+ * @param rawResponse The raw server response (before client mapping)
541
+ * @param options Validation options
542
+ * @returns Validation result
543
+ * @throws {CryptoError} If throwOnInvalid is true and validation fails
544
+ */
545
+ async validateRawResponse(rawResponse, options = {}) {
546
+ const { throwOnInvalid = true, required = this.isConfigured } = options;
547
+ // If no crypto service configured, skip validation
548
+ if (!this.cryptoService) {
549
+ if (required) {
550
+ const error = 'Signature validation is required but no cryptography configuration was provided.';
551
+ if (throwOnInvalid) {
552
+ throw new CryptoError(error, 'NOT_INITIALIZED');
553
+ }
554
+ return { isValid: false, skipped: false, error };
555
+ }
556
+ return { isValid: true, skipped: true };
557
+ }
558
+ // Ensure initialized
559
+ await this.initialize();
560
+ // Check if response has a signature
561
+ const signature = rawResponse.__sidub_entitySignature;
562
+ if (!signature) {
563
+ if (required) {
564
+ const error = 'Authorization does not contain a signature.';
565
+ if (throwOnInvalid) {
566
+ throw new CryptoError(error, 'INVALID_SIGNATURE');
567
+ }
568
+ return { isValid: false, skipped: false, error };
569
+ }
570
+ return { isValid: true, skipped: true };
571
+ }
572
+ try {
573
+ // Serialize the raw response data for verification (excluding signature)
574
+ const dataToVerify = this.serializeRawForVerification(rawResponse);
575
+ const dataBytes = new TextEncoder().encode(dataToVerify);
576
+ const isValid = await this.cryptoService.verifySignature(dataBytes.buffer, signature);
577
+ if (!isValid) {
578
+ const error = 'Authorization signature verification failed. The authorization may have been tampered with.';
579
+ if (throwOnInvalid) {
580
+ throw new CryptoError(error, 'INVALID_SIGNATURE');
581
+ }
582
+ return { isValid: false, skipped: false, error };
583
+ }
584
+ return { isValid: true, skipped: false };
585
+ }
586
+ catch (error) {
587
+ if (error instanceof CryptoError) {
588
+ throw error;
589
+ }
590
+ const message = `Signature validation error: ${error instanceof Error ? error.message : 'Unknown error'}`;
591
+ if (throwOnInvalid) {
592
+ throw new CryptoError(message, 'VERIFICATION_FAILED');
593
+ }
594
+ return { isValid: false, skipped: false, error: message };
595
+ }
596
+ }
597
+ /**
598
+ * Serializes the raw server response for signature verification.
599
+ * Removes the __sidub_entitySignature field and serializes to JSON.
600
+ */
601
+ serializeRawForVerification(rawResponse) {
602
+ // Create a copy without the signature field
603
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
604
+ const { __sidub_entitySignature, ...dataWithoutSignature } = rawResponse;
605
+ return JSON.stringify(dataWithoutSignature);
606
+ }
607
+ }
608
+
609
+ /**
610
+ * In-memory authorization cache with TTL-based expiry and pseudo-LRU eviction.
611
+ * Cache key format: {licenseId}.{serviceKeyId}
612
+ * One cache instance per LicensingClient — no singletons.
613
+ */
614
+ class AuthorizationCache {
615
+ constructor(maxSize = 100) {
616
+ this.cache = new Map();
617
+ this.maxSize = maxSize;
618
+ }
619
+ /**
620
+ * Gets a cached authorization if it exists and has not expired.
621
+ * On hit, re-inserts the entry to move it to the end (pseudo-LRU freshness).
622
+ */
623
+ get(licenseId, serviceKeyId) {
624
+ const key = `${licenseId}.${serviceKeyId}`;
625
+ const entry = this.cache.get(key);
626
+ if (!entry)
627
+ return null;
628
+ // Lazy TTL — delete expired entries on access
629
+ if (Date.now() >= entry.expiresAt) {
630
+ this.cache.delete(key);
631
+ return null;
632
+ }
633
+ // Move to end of Map for pseudo-LRU freshness
634
+ this.cache.delete(key);
635
+ this.cache.set(key, entry);
636
+ return entry.authorization;
637
+ }
638
+ /**
639
+ * Stores an authorization in the cache.
640
+ * Evicts the oldest entry if max size is reached.
641
+ */
642
+ set(licenseId, serviceKeyId, authorization) {
643
+ const key = `${licenseId}.${serviceKeyId}`;
644
+ // Compute expiresAt from authorization, default 1hr if no expiry
645
+ const expiresAt = authorization.expiresAt
646
+ ? authorization.expiresAt.getTime()
647
+ : Date.now() + 3600000;
648
+ // If key already exists, delete first (so re-insert goes to end)
649
+ if (this.cache.has(key)) {
650
+ this.cache.delete(key);
651
+ }
652
+ else if (this.cache.size >= this.maxSize) {
653
+ // Evict oldest entry (first key in Map insertion order)
654
+ const oldestKey = this.cache.keys().next().value;
655
+ if (oldestKey !== undefined) {
656
+ this.cache.delete(oldestKey);
657
+ }
658
+ }
659
+ this.cache.set(key, { authorization, expiresAt });
660
+ }
661
+ /** Removes all cached entries */
662
+ clearCache() {
663
+ this.cache.clear();
664
+ }
665
+ /** Removes all cached entries for a specific license across all service keys */
666
+ invalidate(licenseId) {
667
+ const prefix = `${licenseId}.`;
668
+ for (const key of Array.from(this.cache.keys())) {
669
+ if (key.startsWith(prefix)) {
670
+ this.cache.delete(key);
671
+ }
672
+ }
673
+ }
674
+ }
675
+
676
+ /**
677
+ * Tracks timestamped consumption metrics for a rate-limited feature.
678
+ * Mirrors the .NET RateLimitLicenseFeatureState pattern.
679
+ *
680
+ * Entries older than sampleSeconds are pruned on every getConsumption() call.
681
+ * Local state is additive/conservative — may over-count (safer than under-counting).
682
+ */
683
+ class RateLimitFeatureState {
684
+ constructor(sampleSeconds) {
685
+ this.metrics = [];
686
+ this.sampleSeconds = sampleSeconds;
687
+ }
688
+ /** Records a consumption event with the current timestamp */
689
+ consumeRate(amount) {
690
+ this.metrics.push({ timestamp: Date.now(), amount });
691
+ }
692
+ /** Returns total consumption within the sample window, pruning stale entries */
693
+ getConsumption() {
694
+ const cutoff = Date.now() - (this.sampleSeconds * 1000);
695
+ this.metrics = this.metrics.filter(m => m.timestamp >= cutoff);
696
+ return this.metrics.reduce((sum, m) => sum + m.amount, 0);
697
+ }
698
+ }
699
+
700
+ class LicensingConfigurationException extends LicensingError {
701
+ constructor(message) {
702
+ super(message);
703
+ this.name = 'LicensingConfigurationException';
704
+ }
705
+ }
706
+
707
+ /**
708
+ * Main client for interacting with the Sidub Licensing service.
709
+ * Provides minimal API surface with only 3 core operations needed for runtime enforcement.
710
+ *
711
+ * Supports cryptographic signature validation to ensure authorization integrity.
712
+ */
713
+ class LicensingClient {
714
+ /**
715
+ * Creates a new instance of the LicensingClient
716
+ * @param config Configuration for the licensing service
717
+ */
718
+ constructor(config) {
719
+ this.featureStates = new Map();
720
+ this.featureMetadata = new Map(); // stateKey -> sampleSeconds
721
+ this.contextResolved = false;
722
+ this.contextResolutionPromise = null;
723
+ // Decode credential if provided
724
+ this.credential = config.encodedCredential
725
+ ? decodeCredential(config.encodedCredential)
726
+ : null;
727
+ // Merge decoded credential with explicit config (explicit config takes precedence)
728
+ this.config = {
729
+ ...config,
730
+ timeout: config.timeout || 30000,
731
+ apiKey: config.apiKey || this.credential?.apiAccessKey,
732
+ licenseId: config.licenseId || this.credential?.licenseId,
733
+ serviceKeyId: config.serviceKeyId || this.credential?.serviceKeyId,
734
+ serviceKeyPublicMember: config.serviceKeyPublicMember || this.credential?.serviceKeyPublicMember
735
+ };
736
+ // Build crypto config if we have the required values
737
+ const cryptoConfig = this.buildCryptoConfig();
738
+ this.signatureValidator = new SignatureValidator(cryptoConfig);
739
+ // Determine if signature validation should be performed
740
+ this.validateSignatures = config.validateSignatures ?? (cryptoConfig !== null);
741
+ // Initialize authorization cache (enabled by default)
742
+ this.cacheEnabled = config.cacheEnabled !== false;
743
+ this.authorizationCache = this.cacheEnabled
744
+ ? new AuthorizationCache(config.cacheMaxSize ?? 100)
745
+ : null;
746
+ }
747
+ /**
748
+ * Ensures context provider has been resolved before making API calls.
749
+ * Called once per client lifetime; concurrent callers share the same promise.
750
+ */
751
+ async ensureContextResolved() {
752
+ if (this.contextResolved)
753
+ return;
754
+ if (this.contextResolutionPromise) {
755
+ await this.contextResolutionPromise;
756
+ return;
757
+ }
758
+ if (!this.config.contextProvider) {
759
+ this.contextResolved = true;
760
+ return;
761
+ }
762
+ this.contextResolutionPromise = (async () => {
763
+ const context = await this.config.contextProvider.resolveContext();
764
+ if (context) {
765
+ const hadCrypto = !!(this.config.serviceKeyId && this.config.serviceKeyPublicMember);
766
+ if (!this.config.licenseId && context.licenseId)
767
+ this.config = { ...this.config, licenseId: context.licenseId };
768
+ if (!this.config.serviceKeyId && context.serviceKeyId)
769
+ this.config = { ...this.config, serviceKeyId: context.serviceKeyId };
770
+ if (!this.config.serviceKeyPublicMember && context.serviceKeyPublicMember)
771
+ this.config = { ...this.config, serviceKeyPublicMember: context.serviceKeyPublicMember };
772
+ if (!this.config.apiKey && context.apiAccessKey)
773
+ this.config = { ...this.config, apiKey: context.apiAccessKey };
774
+ if (!this.config.billableResourceId && context.billableResourceId)
775
+ this.config = { ...this.config, billableResourceId: context.billableResourceId };
776
+ if (!this.config.billablePlanId && context.billablePlanId)
777
+ this.config = { ...this.config, billablePlanId: context.billablePlanId };
778
+ // Rebuild crypto if keys were newly resolved
779
+ const hasCryptoNow = !!(this.config.serviceKeyId && this.config.serviceKeyPublicMember);
780
+ if (!hadCrypto && hasCryptoNow) {
781
+ const cryptoConfig = this.buildCryptoConfig();
782
+ this.signatureValidator = new SignatureValidator(cryptoConfig);
783
+ this.validateSignatures = this.config.validateSignatures ?? (cryptoConfig !== null);
784
+ }
785
+ }
786
+ this.contextResolved = true;
787
+ })();
788
+ await this.contextResolutionPromise;
789
+ }
790
+ /**
791
+ * Builds the cryptography configuration from the resolved config values
792
+ */
793
+ buildCryptoConfig() {
794
+ const { serviceKeyId, serviceKeyPublicMember } = this.config;
795
+ if (serviceKeyId && serviceKeyPublicMember) {
796
+ return {
797
+ serviceKeyId,
798
+ serviceKeyPublicMember
799
+ };
800
+ }
801
+ return null;
802
+ }
803
+ /**
804
+ * Gets whether cryptographic signature validation is enabled
805
+ */
806
+ get isSignatureValidationEnabled() {
807
+ return this.validateSignatures && this.signatureValidator.isConfigured;
808
+ }
809
+ /**
810
+ * Gets the configured license ID (from credential or explicit config)
811
+ */
812
+ get configuredLicenseId() {
813
+ return this.config.licenseId;
814
+ }
815
+ /**
816
+ * Retrieves a license authorization from the server.
817
+ * This is the primary method for validating and obtaining license information.
818
+ *
819
+ * If cryptographic configuration is provided, the authorization signature will be
820
+ * validated to ensure integrity. This mirrors the .NET LicensingService behavior.
821
+ *
822
+ * @param licenseId The unique identifier of the license (optional if configured via encodedCredential)
823
+ * @param apiKey Optional API key for authentication (overrides config default)
824
+ * @returns A promise resolving to the license authorization
825
+ * @throws {LicensingError} If the request fails or authorization is denied
826
+ * @throws {CryptoError} If signature validation fails (when crypto config is provided)
827
+ */
828
+ async getAuthorization(licenseId, apiKey) {
829
+ await this.ensureContextResolved();
830
+ // Use provided licenseId or fall back to configured value
831
+ const effectiveLicenseId = licenseId || this.config.licenseId;
832
+ if (!effectiveLicenseId) {
833
+ throw new LicensingConfigurationException('License ID is required. Provide it as a parameter or via configuration.');
834
+ }
835
+ // Check cache first
836
+ if (this.cacheEnabled && this.authorizationCache) {
837
+ const cached = this.authorizationCache.get(effectiveLicenseId, this.config.serviceKeyId ?? '');
838
+ if (cached)
839
+ return cached;
840
+ }
841
+ const url = `${this.config.licenseServiceUri}/GenerateLicenseAuthorization`;
842
+ const effectiveApiKey = apiKey || this.config.apiKey;
843
+ const headers = {
844
+ 'Content-Type': 'application/json',
845
+ };
846
+ // Add authentication headers if API key is provided
847
+ if (effectiveApiKey) {
848
+ headers['dub-issuerKey'] = effectiveApiKey;
849
+ }
850
+ const parameters = {
851
+ RequestId: this.generateRequestId(),
852
+ LicenseId: effectiveLicenseId
853
+ };
854
+ // Add query parameters for API key if provided
855
+ const queryParams = effectiveApiKey
856
+ ? `?subscription-key=${encodeURIComponent(effectiveApiKey)}`
857
+ : '';
858
+ try {
859
+ const controller = new AbortController();
860
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
861
+ const response = await fetch(`${url}${queryParams}`, {
862
+ method: 'POST',
863
+ headers,
864
+ body: JSON.stringify(parameters),
865
+ signal: controller.signal
866
+ });
867
+ clearTimeout(timeoutId);
868
+ if (!response.ok) {
869
+ const errorText = await response.text().catch(() => 'Unknown error');
870
+ throw new LicensingError(`Authorization failed: ${response.statusText}`, response.status, errorText);
871
+ }
872
+ const data = await response.json();
873
+ // Validate signature FIRST using raw server response (before any mapping)
874
+ // This ensures we verify against the exact bytes the server signed
875
+ let signatureValidated = false;
876
+ if (this.validateSignatures) {
877
+ const validationResult = await this.signatureValidator.validateRawResponse(data, { throwOnInvalid: true, required: this.signatureValidator.isConfigured });
878
+ signatureValidated = validationResult.isValid && !validationResult.skipped;
879
+ }
880
+ // Map server response to client model
881
+ // Note: Server uses PascalCase, we convert to camelCase
882
+ // Signature is returned as base64 string in __sidub_entitySignature from .NET
883
+ const rawSignature = data.__sidub_entitySignature ?? data.Signature ?? data.signature;
884
+ const signature = this.normalizeSignature(rawSignature);
885
+ const authorization = {
886
+ authorizationId: data.AuthorizationId || data.authorizationId,
887
+ licenseId: data.LicenseId || data.licenseId,
888
+ classification: normalizeLicenseClassificationType(data.LicenseClassification ?? data.classification) ?? exports.LicenseClassificationType.Perpetual,
889
+ features: this.mapFeatures(data.Features || data.features || []),
890
+ issuedAt: new Date(data.IssueDate || data.issuedAt || data.IssuedAt),
891
+ expiresAt: (data.ExpiryDate || data.expiresAt || data.ExpiresAt)
892
+ ? new Date(data.ExpiryDate || data.expiresAt || data.ExpiresAt)
893
+ : undefined,
894
+ signature,
895
+ signatureValidated
896
+ };
897
+ // Cache feature sampleSeconds for auto-creating RateLimitFeatureState in performOperation
898
+ for (const f of authorization.features) {
899
+ const rf = f;
900
+ const ss = rf.sampleSeconds ?? rf.SampleSeconds;
901
+ if (ss !== undefined && typeof ss === 'number') {
902
+ this.featureMetadata.set(`${authorization.licenseId}.${f.featureId}`, ss);
903
+ }
904
+ }
905
+ // Store in cache
906
+ if (this.cacheEnabled && this.authorizationCache) {
907
+ this.authorizationCache.set(effectiveLicenseId, this.config.serviceKeyId ?? '', authorization);
908
+ }
909
+ return authorization;
910
+ }
911
+ catch (error) {
912
+ if (error instanceof LicensingError) {
913
+ throw error;
914
+ }
915
+ // Re-throw CryptoError as-is
916
+ if (error instanceof Error && error.name === 'CryptoError') {
917
+ throw error;
918
+ }
919
+ if (error instanceof Error) {
920
+ if (error.name === 'AbortError') {
921
+ throw new LicensingError('Request timeout', 408);
922
+ }
923
+ throw new LicensingError(`Network error: ${error.message}`);
924
+ }
925
+ throw new LicensingError('Unknown error occurred');
926
+ }
927
+ }
928
+ /**
929
+ * Maps server feature format to client format.
930
+ * Explicitly constructs camelCase feature objects instead of spreading server properties.
931
+ * This prevents PascalCase duplicates and ensures consistent property access.
932
+ */
933
+ mapFeatures(serverFeatures) {
934
+ if (!Array.isArray(serverFeatures)) {
935
+ return [];
936
+ }
937
+ return serverFeatures.map((f) => {
938
+ const sf = f;
939
+ // Normalize featureId from PascalCase server response
940
+ const featureId = String(sf.FeatureKey ?? sf.featureKey ?? sf.featureId ?? '');
941
+ // Build normalized feature with camelCase properties only
942
+ const feature = { featureId };
943
+ // ServiceAccessLevel (ServiceAccessLicenseFeature)
944
+ const rawAccess = sf.ServiceAccessLevel ?? sf.serviceAccessLevel;
945
+ if (rawAccess !== undefined) {
946
+ feature.serviceAccessLevel = normalizeServiceAccessLevel(rawAccess);
947
+ }
948
+ // SampleSeconds (RateLimitLicenseFeature)
949
+ const rawSampleSeconds = sf.SampleSeconds ?? sf.sampleSeconds;
950
+ if (rawSampleSeconds !== undefined) {
951
+ feature.sampleSeconds = rawSampleSeconds;
952
+ }
953
+ // RateLimit (RateLimitLicenseFeature)
954
+ const rawRateLimit = sf.RateLimit ?? sf.rateLimit;
955
+ if (rawRateLimit !== undefined) {
956
+ feature.rateLimit = rawRateLimit;
957
+ }
958
+ // CurrentConsumption (may come from server)
959
+ const rawConsumption = sf.CurrentConsumption ?? sf.currentConsumption;
960
+ if (rawConsumption !== undefined) {
961
+ feature.currentConsumption = rawConsumption;
962
+ }
963
+ return feature;
964
+ });
965
+ }
966
+ /**
967
+ * Normalizes the signature from server response.
968
+ * Handles both base64 string format (from .NET Convert.ToBase64String)
969
+ * and byte array format (JSON array of numbers).
970
+ *
971
+ * @param rawSignature The raw signature from server response
972
+ * @returns Normalized base64 string, or undefined if not present
973
+ */
974
+ normalizeSignature(rawSignature) {
975
+ if (rawSignature === null || rawSignature === undefined) {
976
+ return undefined;
977
+ }
978
+ // If it's already a string (base64 from .NET), use as-is
979
+ if (typeof rawSignature === 'string') {
980
+ return rawSignature.length > 0 ? rawSignature : undefined;
981
+ }
982
+ // If it's an array (JSON serialized byte[]), convert to base64
983
+ if (Array.isArray(rawSignature)) {
984
+ try {
985
+ const bytes = new Uint8Array(rawSignature);
986
+ let binary = '';
987
+ for (let i = 0; i < bytes.length; i++) {
988
+ binary += String.fromCharCode(bytes[i]);
989
+ }
990
+ return btoa(binary);
991
+ }
992
+ catch {
993
+ return undefined;
994
+ }
995
+ }
996
+ return undefined;
997
+ }
998
+ /**
999
+ * Asserts whether a license condition is satisfied using the provided assertion.
1000
+ * This evaluates the assertion against a license authorization.
1001
+ *
1002
+ * @param assertion The license assertion to evaluate
1003
+ * @param authorization The license authorization to check against (if not provided, you must call getAuthorization first)
1004
+ * @returns True if the assertion is satisfied, false otherwise
1005
+ *
1006
+ * @example
1007
+ * ```typescript
1008
+ * const authorization = await client.getAuthorization('license-id');
1009
+ * const assertion = ServiceAccessAssertion.create('premium-features');
1010
+ * const hasAccess = client.assertLicense(assertion, authorization);
1011
+ * ```
1012
+ */
1013
+ assertLicense(assertion, authorization) {
1014
+ // Auto-inject feature states for RateLimitAssertion
1015
+ if (assertion instanceof RateLimitAssertion) {
1016
+ return assertion.isSatisfied(authorization, this.featureStates);
1017
+ }
1018
+ return assertion.isSatisfied(authorization);
1019
+ }
1020
+ /** Clears all cached authorizations */
1021
+ clearCache() {
1022
+ this.authorizationCache?.clearCache();
1023
+ }
1024
+ /** Invalidates all cached authorizations for a specific license */
1025
+ invalidateCache(licenseId) {
1026
+ this.authorizationCache?.invalidate(licenseId);
1027
+ }
1028
+ /** Gets the local feature state for a license+feature combination */
1029
+ getFeatureState(licenseId, featureKey) {
1030
+ return this.featureStates.get(`${licenseId}.${featureKey}`);
1031
+ }
1032
+ /** Sets/replaces the local feature state for a license+feature combination */
1033
+ setFeatureState(licenseId, featureKey, state) {
1034
+ this.featureStates.set(`${licenseId}.${featureKey}`, state);
1035
+ }
1036
+ /**
1037
+ * Reports license feature consumption/usage to the server.
1038
+ * This is used for metering and billing purposes.
1039
+ *
1040
+ * @param operation The operation to report
1041
+ * @returns A promise that resolves when the operation is reported
1042
+ * @throws {LicensingError} If the request fails
1043
+ */
1044
+ async performOperation(operation) {
1045
+ await this.ensureContextResolved();
1046
+ // If no consumption service URI is configured, silently skip
1047
+ if (!this.config.consumptionServiceUri) {
1048
+ return;
1049
+ }
1050
+ const url = `${this.config.consumptionServiceUri}/messages`;
1051
+ const apiKey = this.config.apiKey;
1052
+ const headers = {
1053
+ 'Content-Type': 'application/json',
1054
+ };
1055
+ if (apiKey) {
1056
+ headers['dub-apiKey'] = apiKey;
1057
+ }
1058
+ const queryParams = apiKey
1059
+ ? `?subscription-key=${encodeURIComponent(apiKey)}`
1060
+ : '';
1061
+ try {
1062
+ const controller = new AbortController();
1063
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
1064
+ const response = await fetch(`${url}${queryParams}`, {
1065
+ method: 'POST',
1066
+ headers,
1067
+ body: JSON.stringify({
1068
+ LicenseId: operation.licenseId,
1069
+ BillableResourceId: this.config.billableResourceId ?? '00000000-0000-0000-0000-000000000000',
1070
+ BillablePlanId: this.config.billablePlanId ?? '',
1071
+ IsBillable: true,
1072
+ LicenseOperation: {
1073
+ LicenseFeature: {
1074
+ FeatureKey: operation.feature.featureId
1075
+ },
1076
+ Amount: operation.quantity ?? 1
1077
+ }
1078
+ }),
1079
+ signal: controller.signal
1080
+ });
1081
+ clearTimeout(timeoutId);
1082
+ if (!response.ok) {
1083
+ const errorText = await response.text().catch(() => 'Unknown error');
1084
+ throw new LicensingError(`Consumption report failed: ${response.statusText}`, response.status, errorText);
1085
+ }
1086
+ // Update local feature state after successful server report (auto-create if needed)
1087
+ const stateKey = `${operation.licenseId}.${operation.feature.featureId}`;
1088
+ let state = this.featureStates.get(stateKey);
1089
+ if (!state) {
1090
+ const sampleSeconds = this.featureMetadata.get(stateKey) ?? 3600;
1091
+ state = new RateLimitFeatureState(sampleSeconds);
1092
+ this.featureStates.set(stateKey, state);
1093
+ }
1094
+ state.consumeRate(operation.quantity ?? 1);
1095
+ }
1096
+ catch (error) {
1097
+ if (error instanceof LicensingError) {
1098
+ throw error;
1099
+ }
1100
+ if (error instanceof Error) {
1101
+ if (error.name === 'AbortError') {
1102
+ throw new LicensingError('Request timeout', 408);
1103
+ }
1104
+ throw new LicensingError(`Network error: ${error.message}`);
1105
+ }
1106
+ throw new LicensingError('Unknown error occurred');
1107
+ }
1108
+ }
1109
+ /**
1110
+ * Reports a telemetry-only access check event to the server.
1111
+ * This does not affect billing — IsBillable is always false.
1112
+ * Used for tracking feature access patterns without metering costs.
1113
+ *
1114
+ * @param licenseId The license ID (optional if configured)
1115
+ * @param featureKey The feature being accessed
1116
+ * @param apiKey Optional API key override
1117
+ */
1118
+ async reportAccessCheck(licenseId, featureKey, apiKey) {
1119
+ await this.ensureContextResolved();
1120
+ if (!this.config.consumptionServiceUri) {
1121
+ return;
1122
+ }
1123
+ const effectiveLicenseId = licenseId || this.config.licenseId;
1124
+ if (!effectiveLicenseId || !featureKey) {
1125
+ return;
1126
+ }
1127
+ const url = `${this.config.consumptionServiceUri}/messages`;
1128
+ const effectiveApiKey = apiKey || this.config.apiKey;
1129
+ const headers = {
1130
+ 'Content-Type': 'application/json',
1131
+ };
1132
+ if (effectiveApiKey) {
1133
+ headers['dub-apiKey'] = effectiveApiKey;
1134
+ }
1135
+ const queryParams = effectiveApiKey
1136
+ ? `?subscription-key=${encodeURIComponent(effectiveApiKey)}`
1137
+ : '';
1138
+ const body = JSON.stringify({
1139
+ LicenseId: effectiveLicenseId,
1140
+ BillableResourceId: this.config.billableResourceId ?? '00000000-0000-0000-0000-000000000000',
1141
+ BillablePlanId: this.config.billablePlanId ?? '',
1142
+ IsBillable: false,
1143
+ LicenseOperation: {
1144
+ LicenseFeature: {
1145
+ FeatureKey: featureKey
1146
+ },
1147
+ Amount: 0
1148
+ }
1149
+ });
1150
+ try {
1151
+ const controller = new AbortController();
1152
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
1153
+ const response = await fetch(`${url}${queryParams}`, {
1154
+ method: 'POST',
1155
+ headers,
1156
+ body,
1157
+ signal: controller.signal
1158
+ });
1159
+ clearTimeout(timeoutId);
1160
+ if (!response.ok) {
1161
+ const errorText = await response.text().catch(() => 'Unknown error');
1162
+ throw new LicensingError(`Access check report failed: ${response.statusText}`, response.status, errorText);
1163
+ }
1164
+ }
1165
+ catch (error) {
1166
+ if (error instanceof LicensingError)
1167
+ throw error;
1168
+ if (error instanceof Error) {
1169
+ if (error.name === 'AbortError')
1170
+ throw new LicensingError('Request timeout', 408);
1171
+ throw new LicensingError(`Network error: ${error.message}`);
1172
+ }
1173
+ throw new LicensingError('Unknown error occurred');
1174
+ }
1175
+ }
1176
+ /**
1177
+ * Creates an embedded checkout session for purchasing an offering.
1178
+ * For free/trial offerings, the license is provisioned immediately (sessionUrl will be null).
1179
+ * For paid offerings, sessionUrl will contain the payment provider URL.
1180
+ *
1181
+ * Uses licenseServiceUri (not consumptionServiceUri) — checkout is a licensing operation.
1182
+ *
1183
+ * @param request Checkout request parameters
1184
+ * @param apiKey Optional API key override (reseller's API key)
1185
+ * @returns The created checkout session with sessionId and optional sessionUrl
1186
+ * @throws {LicensingError} If the request fails
1187
+ */
1188
+ async createCheckoutSession(request, apiKey) {
1189
+ await this.ensureContextResolved();
1190
+ const url = `${this.config.licenseServiceUri}/checkout/embedded`;
1191
+ const effectiveApiKey = apiKey || this.config.apiKey;
1192
+ const headers = {
1193
+ 'Content-Type': 'application/json',
1194
+ };
1195
+ if (effectiveApiKey) {
1196
+ headers['dub-apiKey'] = effectiveApiKey;
1197
+ }
1198
+ const queryParams = effectiveApiKey
1199
+ ? `?subscription-key=${encodeURIComponent(effectiveApiKey)}`
1200
+ : '';
1201
+ // Send PascalCase body matching CreateCheckoutSessionParameters.cs
1202
+ const body = JSON.stringify({
1203
+ RequestId: this.generateRequestId(),
1204
+ OfferingId: request.offeringId,
1205
+ IssuerClientId: request.issuerClientId,
1206
+ CorrelationId: request.correlationId,
1207
+ CustomerEmail: request.customerEmail,
1208
+ SuccessUrl: request.successUrl,
1209
+ CancelUrl: request.cancelUrl
1210
+ });
1211
+ try {
1212
+ const controller = new AbortController();
1213
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
1214
+ const response = await fetch(`${url}${queryParams}`, {
1215
+ method: 'POST',
1216
+ headers,
1217
+ body,
1218
+ signal: controller.signal
1219
+ });
1220
+ clearTimeout(timeoutId);
1221
+ if (!response.ok) {
1222
+ const errorText = await response.text().catch(() => 'Unknown error');
1223
+ throw new LicensingError(`Checkout session creation failed: ${response.statusText}`, response.status, errorText);
1224
+ }
1225
+ const data = await response.json();
1226
+ // Map PascalCase server response to camelCase DTO
1227
+ return {
1228
+ sessionId: data.SessionId ?? data.sessionId,
1229
+ sessionUrl: data.SessionUrl ?? data.sessionUrl ?? null
1230
+ };
1231
+ }
1232
+ catch (error) {
1233
+ if (error instanceof LicensingError)
1234
+ throw error;
1235
+ if (error instanceof Error) {
1236
+ if (error.name === 'AbortError')
1237
+ throw new LicensingError('Request timeout', 408);
1238
+ throw new LicensingError(`Network error: ${error.message}`);
1239
+ }
1240
+ throw new LicensingError('Unknown error occurred');
1241
+ }
1242
+ }
1243
+ /**
1244
+ * Retrieves the result of a checkout session.
1245
+ * Poll this method after creating a checkout session to check if the purchase is complete.
1246
+ *
1247
+ * For free/trial offerings, the result is immediately available (status = 'completed').
1248
+ * For paid offerings, status will be 'pending' until the customer completes payment.
1249
+ *
1250
+ * @param sessionId The session ID from createCheckoutSession()
1251
+ * @param apiKey Optional API key override
1252
+ * @returns The checkout session result with status, credential, and error fields
1253
+ * @throws {LicensingError} If the request fails
1254
+ */
1255
+ async getCheckoutResult(sessionId, apiKey) {
1256
+ await this.ensureContextResolved();
1257
+ const url = `${this.config.licenseServiceUri}/checkout/session/result`;
1258
+ const effectiveApiKey = apiKey || this.config.apiKey;
1259
+ const headers = {
1260
+ 'Content-Type': 'application/json',
1261
+ };
1262
+ if (effectiveApiKey) {
1263
+ headers['dub-apiKey'] = effectiveApiKey;
1264
+ }
1265
+ const queryParams = effectiveApiKey
1266
+ ? `?subscription-key=${encodeURIComponent(effectiveApiKey)}`
1267
+ : '';
1268
+ // Send PascalCase body matching GetCheckoutSessionResultParameters.cs
1269
+ const body = JSON.stringify({
1270
+ SessionId: sessionId
1271
+ });
1272
+ try {
1273
+ const controller = new AbortController();
1274
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
1275
+ const response = await fetch(`${url}${queryParams}`, {
1276
+ method: 'POST',
1277
+ headers,
1278
+ body,
1279
+ signal: controller.signal
1280
+ });
1281
+ clearTimeout(timeoutId);
1282
+ if (!response.ok) {
1283
+ const errorText = await response.text().catch(() => 'Unknown error');
1284
+ throw new LicensingError(`Checkout result retrieval failed: ${response.statusText}`, response.status, errorText);
1285
+ }
1286
+ const data = await response.json();
1287
+ // Map PascalCase server response to camelCase DTO
1288
+ const status = (data.Status ?? data.status ?? 'pending').toLowerCase();
1289
+ return {
1290
+ status,
1291
+ correlationId: data.CorrelationId ?? data.correlationId,
1292
+ encodedCredential: data.EncodedCredential ?? data.encodedCredential,
1293
+ licenseId: data.LicenseId ?? data.licenseId,
1294
+ offeringId: data.OfferingId ?? data.offeringId,
1295
+ error: data.Error ?? data.error
1296
+ };
1297
+ }
1298
+ catch (error) {
1299
+ if (error instanceof LicensingError)
1300
+ throw error;
1301
+ if (error instanceof Error) {
1302
+ if (error.name === 'AbortError')
1303
+ throw new LicensingError('Request timeout', 408);
1304
+ throw new LicensingError(`Network error: ${error.message}`);
1305
+ }
1306
+ throw new LicensingError('Unknown error occurred');
1307
+ }
1308
+ }
1309
+ /**
1310
+ * Generates a unique request ID for tracking
1311
+ * @private
1312
+ */
1313
+ generateRequestId() {
1314
+ // Use crypto.randomUUID if available (modern browsers and Node 16+)
1315
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
1316
+ return crypto.randomUUID();
1317
+ }
1318
+ // Fallback: Generate a simple UUID v4
1319
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
1320
+ const r = (Math.random() * 16) | 0;
1321
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
1322
+ return v.toString(16);
1323
+ });
1324
+ }
1325
+ /**
1326
+ * Polls for a checkout session result with exponential backoff.
1327
+ * Replaces the useCheckout hook pattern with an imperative API.
1328
+ *
1329
+ * @param sessionId The session ID from createCheckoutSession()
1330
+ * @param options Optional: apiKey override, AbortSignal for cancellation, maxAttempts (default 60)
1331
+ * @returns The completed or failed checkout session result
1332
+ * @throws {LicensingError} If polling times out or the request fails
1333
+ */
1334
+ async pollCheckoutResult(sessionId, options) {
1335
+ const maxAttempts = options?.maxAttempts ?? 60;
1336
+ const signal = options?.signal;
1337
+ let delay = 1000;
1338
+ const maxDelay = 15000;
1339
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1340
+ if (signal?.aborted) {
1341
+ throw new LicensingError('Checkout polling cancelled');
1342
+ }
1343
+ const result = await this.getCheckoutResult(sessionId, options?.apiKey);
1344
+ if (result.status === 'completed' || result.status === 'failed') {
1345
+ return result;
1346
+ }
1347
+ // Wait with exponential backoff
1348
+ await new Promise((resolve) => {
1349
+ const timer = setTimeout(resolve, delay);
1350
+ if (signal) {
1351
+ const onAbort = () => { clearTimeout(timer); resolve(); };
1352
+ signal.addEventListener('abort', onAbort, { once: true });
1353
+ }
1354
+ });
1355
+ delay = Math.min(delay * 2, maxDelay);
1356
+ }
1357
+ throw new LicensingError('Checkout polling timed out');
1358
+ }
1359
+ /**
1360
+ * Gets the current configuration
1361
+ */
1362
+ getConfig() {
1363
+ return { ...this.config };
1364
+ }
1365
+ }
1366
+
1367
+ /**
1368
+ * Assertion that checks if a feature with a specific key exists in the authorization.
1369
+ * This is the simplest form of assertion for basic feature checking.
1370
+ *
1371
+ * @example
1372
+ * ```typescript
1373
+ * const assertion = FeatureExistsAssertion.create('premium-analytics');
1374
+ * const hasFeature = assertion.isSatisfied(authorization);
1375
+ * ```
1376
+ */
1377
+ class FeatureExistsAssertion extends LicenseAssertion {
1378
+ /**
1379
+ * Creates a new feature exists assertion
1380
+ * @param featureKey The feature key to check for
1381
+ */
1382
+ constructor(featureKey) {
1383
+ super();
1384
+ this.featureKey = featureKey;
1385
+ }
1386
+ /**
1387
+ * Checks if the authorization contains a feature with the specified key
1388
+ */
1389
+ isSatisfied(authorization) {
1390
+ if (!authorization?.features) {
1391
+ return false;
1392
+ }
1393
+ return hasFeature(authorization.features, this.featureKey);
1394
+ }
1395
+ /**
1396
+ * Factory method to create a feature exists assertion
1397
+ * @param featureKey The feature key to check for
1398
+ */
1399
+ static create(featureKey) {
1400
+ return new FeatureExistsAssertion(featureKey);
1401
+ }
1402
+ }
1403
+ /**
1404
+ * Assertion that combines multiple assertions with AND logic.
1405
+ * All assertions must be satisfied for this to return true.
1406
+ *
1407
+ * @example
1408
+ * ```typescript
1409
+ * const assertion = CompositeAssertion.and(
1410
+ * FeatureExistsAssertion.create('feature1'),
1411
+ * FeatureExistsAssertion.create('feature2')
1412
+ * );
1413
+ * ```
1414
+ */
1415
+ class CompositeAssertion extends LicenseAssertion {
1416
+ /**
1417
+ * Creates a new composite assertion
1418
+ * @param assertions The assertions to combine
1419
+ * @param logic The logic to apply ('and' or 'or')
1420
+ */
1421
+ constructor(assertions, logic = 'and') {
1422
+ super();
1423
+ this.assertions = assertions;
1424
+ this.logic = logic;
1425
+ this.featureKey = assertions.map(a => a.featureKey).join(logic === 'and' ? '&' : '|');
1426
+ }
1427
+ /**
1428
+ * Evaluates all assertions according to the logic
1429
+ */
1430
+ isSatisfied(authorization) {
1431
+ if (this.assertions.length === 0) {
1432
+ return true;
1433
+ }
1434
+ if (this.logic === 'and') {
1435
+ return this.assertions.every(a => a.isSatisfied(authorization));
1436
+ }
1437
+ else {
1438
+ return this.assertions.some(a => a.isSatisfied(authorization));
1439
+ }
1440
+ }
1441
+ /**
1442
+ * Factory method to create an AND composite assertion
1443
+ * @param assertions The assertions to combine with AND logic
1444
+ */
1445
+ static and(...assertions) {
1446
+ return new CompositeAssertion(assertions, 'and');
1447
+ }
1448
+ /**
1449
+ * Factory method to create an OR composite assertion
1450
+ * @param assertions The assertions to combine with OR logic
1451
+ */
1452
+ static or(...assertions) {
1453
+ return new CompositeAssertion(assertions, 'or');
1454
+ }
1455
+ }
1456
+ /**
1457
+ * Assertion that inverts another assertion's result.
1458
+ *
1459
+ * @example
1460
+ * ```typescript
1461
+ * const assertion = NotAssertion.create(
1462
+ * FeatureExistsAssertion.create('blocked-feature')
1463
+ * );
1464
+ * // Returns true if the feature does NOT exist
1465
+ * ```
1466
+ */
1467
+ class NotAssertion extends LicenseAssertion {
1468
+ /**
1469
+ * Creates a new NOT assertion
1470
+ * @param assertion The assertion to invert
1471
+ */
1472
+ constructor(assertion) {
1473
+ super();
1474
+ this.assertion = assertion;
1475
+ this.featureKey = assertion.featureKey;
1476
+ }
1477
+ /**
1478
+ * Returns the opposite of the wrapped assertion's result
1479
+ */
1480
+ isSatisfied(authorization) {
1481
+ return !this.assertion.isSatisfied(authorization);
1482
+ }
1483
+ /**
1484
+ * Factory method to create a NOT assertion
1485
+ * @param assertion The assertion to invert
1486
+ */
1487
+ static create(assertion) {
1488
+ return new NotAssertion(assertion);
1489
+ }
1490
+ }
1491
+
1492
+ const LicensingContext = React.createContext(undefined);
1493
+ /**
1494
+ * Provider component for the Sidub Licensing context.
1495
+ * Wraps your application to provide licensing functionality to all child components.
1496
+ *
1497
+ * Supports cryptographic signature validation when configured with serviceKeyId
1498
+ * and serviceKeyPublicMember (either directly or via encodedCredential).
1499
+ *
1500
+ * @example
1501
+ * ```tsx
1502
+ * // Basic usage (no signature validation)
1503
+ * <LicensingProvider config={{
1504
+ * licenseServiceUri: 'https://api.example.com',
1505
+ * apiKey: 'your-api-key'
1506
+ * }}>
1507
+ * <App />
1508
+ * </LicensingProvider>
1509
+ *
1510
+ * // With encoded credential (includes signature validation)
1511
+ * <LicensingProvider config={{
1512
+ * licenseServiceUri: 'https://api.example.com',
1513
+ * encodedCredential: 'SIDUB_LIC_...'
1514
+ * }}>
1515
+ * <App />
1516
+ * </LicensingProvider>
1517
+ * ```
1518
+ */
1519
+ const LicensingProvider = ({ config, children }) => {
1520
+ // Create client with memoization - recreate only if config changes
1521
+ const value = React.useMemo(() => {
1522
+ const client = new LicensingClient(config);
1523
+ return {
1524
+ client,
1525
+ isSignatureValidationEnabled: client.isSignatureValidationEnabled,
1526
+ configuredLicenseId: client.configuredLicenseId
1527
+ };
1528
+ }, [
1529
+ config.licenseServiceUri,
1530
+ config.consumptionServiceUri,
1531
+ config.apiKey,
1532
+ config.timeout,
1533
+ config.encodedCredential,
1534
+ config.serviceKeyId,
1535
+ config.serviceKeyPublicMember,
1536
+ config.licenseId,
1537
+ config.validateSignatures
1538
+ ]);
1539
+ return (React.createElement(LicensingContext.Provider, { value: value }, children));
1540
+ };
1541
+ /**
1542
+ * Hook to access the LicensingClient from context.
1543
+ * Must be used within a LicensingProvider.
1544
+ *
1545
+ * @throws {Error} If used outside of a LicensingProvider
1546
+ * @returns The LicensingClient instance
1547
+ *
1548
+ * @example
1549
+ * ```tsx
1550
+ * function MyComponent() {
1551
+ * const client = useLicensingContext();
1552
+ * // Use client.getAuthorization(), etc.
1553
+ * }
1554
+ * ```
1555
+ */
1556
+ const useLicensingContext = () => {
1557
+ const context = React.useContext(LicensingContext);
1558
+ if (!context) {
1559
+ throw new Error('useLicensingContext must be used within a LicensingProvider');
1560
+ }
1561
+ return context.client;
1562
+ };
1563
+ /**
1564
+ * Hook to access the full licensing context including metadata.
1565
+ * Must be used within a LicensingProvider.
1566
+ *
1567
+ * @throws {Error} If used outside of a LicensingProvider
1568
+ * @returns The full licensing context value
1569
+ *
1570
+ * @example
1571
+ * ```tsx
1572
+ * function MyComponent() {
1573
+ * const { client, isSignatureValidationEnabled, configuredLicenseId } = useLicensingContextValue();
1574
+ *
1575
+ * if (!isSignatureValidationEnabled) {
1576
+ * console.warn('Signature validation is disabled');
1577
+ * }
1578
+ * }
1579
+ * ```
1580
+ */
1581
+ const useLicensingContextValue = () => {
1582
+ const context = React.useContext(LicensingContext);
1583
+ if (!context) {
1584
+ throw new Error('useLicensingContextValue must be used within a LicensingProvider');
1585
+ }
1586
+ return context;
1587
+ };
1588
+
1589
+ /**
1590
+ * Creates a LicensingContextType from an encoded credential string.
1591
+ * Billable fields are NOT included in the encoding (per .NET behavior) and must be provided separately.
1592
+ */
1593
+ function licensingContextFromEncodedString(encoded, billableResourceId, billablePlanId) {
1594
+ const credential = decodeCredential(encoded);
1595
+ return {
1596
+ licenseId: credential.licenseId,
1597
+ serviceKeyId: credential.serviceKeyId,
1598
+ serviceKeyPublicMember: credential.serviceKeyPublicMember,
1599
+ apiAccessKey: credential.apiAccessKey,
1600
+ billableResourceId,
1601
+ billablePlanId
1602
+ };
1603
+ }
1604
+ /**
1605
+ * Encodes a LicensingContextType to a portable string.
1606
+ * Per .NET behavior: only the 4 credential fields are encoded, NOT billable fields.
1607
+ */
1608
+ function licensingContextToEncodedString(context) {
1609
+ return encodeCredential({
1610
+ licenseId: context.licenseId,
1611
+ serviceKeyId: context.serviceKeyId,
1612
+ serviceKeyPublicMember: context.serviceKeyPublicMember,
1613
+ apiAccessKey: context.apiAccessKey
1614
+ });
1615
+ }
1616
+
1617
+ /**
1618
+ * Default context provider that builds a LicensingContextType from LicensingConfig.
1619
+ * Returns null when no credentials are configured (user hasn't purchased a license yet).
1620
+ * Caches the result after first resolution (lazy, one-shot).
1621
+ */
1622
+ class ConfigurationContextProvider {
1623
+ constructor(config) {
1624
+ this.cached = undefined; // undefined = not yet resolved
1625
+ this.config = config;
1626
+ }
1627
+ async resolveContext() {
1628
+ // Return cached result if already resolved
1629
+ if (this.cached !== undefined)
1630
+ return this.cached;
1631
+ let context = null;
1632
+ if (this.config.encodedCredential) {
1633
+ const credential = decodeCredential(this.config.encodedCredential);
1634
+ context = {
1635
+ licenseId: credential.licenseId,
1636
+ serviceKeyId: credential.serviceKeyId,
1637
+ serviceKeyPublicMember: credential.serviceKeyPublicMember,
1638
+ apiAccessKey: credential.apiAccessKey,
1639
+ billableResourceId: this.config.billableResourceId,
1640
+ billablePlanId: this.config.billablePlanId
1641
+ };
1642
+ }
1643
+ else if (this.config.licenseId && this.config.serviceKeyId && this.config.serviceKeyPublicMember && this.config.apiKey) {
1644
+ context = {
1645
+ licenseId: this.config.licenseId,
1646
+ serviceKeyId: this.config.serviceKeyId,
1647
+ serviceKeyPublicMember: this.config.serviceKeyPublicMember,
1648
+ apiAccessKey: this.config.apiKey,
1649
+ billableResourceId: this.config.billableResourceId,
1650
+ billablePlanId: this.config.billablePlanId
1651
+ };
1652
+ }
1653
+ this.cached = context;
1654
+ return context;
1655
+ }
1656
+ }
1657
+
1658
+ exports.AuthorizationCache = AuthorizationCache;
1659
+ exports.CompositeAssertion = CompositeAssertion;
1660
+ exports.ConfigurationContextProvider = ConfigurationContextProvider;
1661
+ exports.CryptoError = CryptoError;
1662
+ exports.CryptoService = CryptoService;
1663
+ exports.FeatureExistsAssertion = FeatureExistsAssertion;
1664
+ exports.LicenseAssertion = LicenseAssertion;
1665
+ exports.LicensingClient = LicensingClient;
1666
+ exports.LicensingConfigurationException = LicensingConfigurationException;
1667
+ exports.LicensingError = LicensingError;
1668
+ exports.LicensingProvider = LicensingProvider;
1669
+ exports.NotAssertion = NotAssertion;
1670
+ exports.RateLimitAssertion = RateLimitAssertion;
1671
+ exports.RateLimitFeatureState = RateLimitFeatureState;
1672
+ exports.ServiceAccessAssertion = ServiceAccessAssertion;
1673
+ exports.SignatureValidator = SignatureValidator;
1674
+ exports.decodeCredential = decodeCredential;
1675
+ exports.encodeCredential = encodeCredential;
1676
+ exports.findFeatureByKey = findFeatureByKey;
1677
+ exports.getFeatureKey = getFeatureKey;
1678
+ exports.hasFeature = hasFeature;
1679
+ exports.hasValidCryptoConfig = hasValidCryptoConfig;
1680
+ exports.licensingContextFromEncodedString = licensingContextFromEncodedString;
1681
+ exports.licensingContextToEncodedString = licensingContextToEncodedString;
1682
+ exports.matchesFeatureKey = matchesFeatureKey;
1683
+ exports.normalizeBillingIntervalUnit = normalizeBillingIntervalUnit;
1684
+ exports.normalizeLicenseClassificationType = normalizeLicenseClassificationType;
1685
+ exports.normalizeServiceAccessLevel = normalizeServiceAccessLevel;
1686
+ exports.tryDecodeCredential = tryDecodeCredential;
1687
+ exports.useLicensingContext = useLicensingContext;
1688
+ exports.useLicensingContextValue = useLicensingContextValue;
1689
+ //# sourceMappingURL=index.js.map