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