@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/README.md +559 -0
- package/dist/index.d.ts +1058 -0
- package/dist/index.esm.js +1657 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +1689 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
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
|