@sidub-inc/licensing-client 1.5.59 → 1.5.72
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 +65 -99
- package/dist/{index.js → index.cjs} +550 -47
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +179 -13
- package/dist/index.esm.js +550 -48
- package/dist/index.esm.js.map +1 -1
- package/docs/MIGRATION.md +133 -0
- package/docs/REACT_GUIDE.md +45 -0
- package/package.json +14 -2
- package/dist/index.js.map +0 -1
package/dist/index.esm.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { createContext, useMemo, useEffect, useContext } from 'react';
|
|
1
|
+
import React, { createContext, useMemo, useRef, useEffect, useContext } from 'react';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Error class for licensing-related errors.
|
|
@@ -407,6 +407,12 @@ class RateLimitAssertion extends LicenseAssertion {
|
|
|
407
407
|
/**
|
|
408
408
|
* Checks if the feature's consumption is within its rate limit.
|
|
409
409
|
* Prefers local feature state when available, falls back to server currentConsumption.
|
|
410
|
+
*
|
|
411
|
+
* The comparison is inclusive (`consumption <= rateLimit`), mirroring the .NET
|
|
412
|
+
* RateLimitLicenseAssertion exactly: the assertion still passes when consumption
|
|
413
|
+
* has already reached the limit, so the operation consuming past the final unit
|
|
414
|
+
* is permitted before the assertion begins failing. The SDKs must agree on this
|
|
415
|
+
* boundary — do not tighten it here independently of .NET.
|
|
410
416
|
*/
|
|
411
417
|
isSatisfied(authorization, featureStates) {
|
|
412
418
|
if (!authorization?.features) {
|
|
@@ -550,6 +556,159 @@ class CryptoError extends Error {
|
|
|
550
556
|
* Signature validator for license authorization entities.
|
|
551
557
|
* Validates that the authorization returned from the server has not been tampered with.
|
|
552
558
|
*/
|
|
559
|
+
const SIGNATURE_MEMBER_KEY = '"__sidub_entitySignature"';
|
|
560
|
+
function isJsonWhitespace(c) {
|
|
561
|
+
return c === ' ' || c === '\t' || c === '\n' || c === '\r';
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Returns the index immediately after the closing quote of the JSON string
|
|
565
|
+
* starting at `startQuoteIndex`, honoring backslash escapes.
|
|
566
|
+
*/
|
|
567
|
+
function skipJsonString(text, startQuoteIndex) {
|
|
568
|
+
let i = startQuoteIndex + 1;
|
|
569
|
+
while (i < text.length) {
|
|
570
|
+
const c = text[i];
|
|
571
|
+
if (c === '\\') {
|
|
572
|
+
i += 2;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (c === '"') {
|
|
576
|
+
return i + 1;
|
|
577
|
+
}
|
|
578
|
+
i++;
|
|
579
|
+
}
|
|
580
|
+
return text.length;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Returns the index immediately after the JSON value starting at `start`.
|
|
584
|
+
* Handles strings (with escapes), bracket-matched objects/arrays, and primitives.
|
|
585
|
+
*/
|
|
586
|
+
function skipJsonValue(text, start) {
|
|
587
|
+
const c = text[start];
|
|
588
|
+
if (c === '"') {
|
|
589
|
+
return skipJsonString(text, start);
|
|
590
|
+
}
|
|
591
|
+
if (c === '{' || c === '[') {
|
|
592
|
+
let depth = 0;
|
|
593
|
+
let i = start;
|
|
594
|
+
while (i < text.length) {
|
|
595
|
+
const ch = text[i];
|
|
596
|
+
if (ch === '"') {
|
|
597
|
+
i = skipJsonString(text, i);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (ch === '{' || ch === '[')
|
|
601
|
+
depth++;
|
|
602
|
+
else if (ch === '}' || ch === ']') {
|
|
603
|
+
depth--;
|
|
604
|
+
if (depth === 0)
|
|
605
|
+
return i + 1;
|
|
606
|
+
}
|
|
607
|
+
i++;
|
|
608
|
+
}
|
|
609
|
+
return text.length;
|
|
610
|
+
}
|
|
611
|
+
// Primitive (number, true, false, null) — ends at a structural character.
|
|
612
|
+
let i = start;
|
|
613
|
+
while (i < text.length && text[i] !== ',' && text[i] !== '}' && text[i] !== ']' && !isJsonWhitespace(text[i])) {
|
|
614
|
+
i++;
|
|
615
|
+
}
|
|
616
|
+
return i;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Removes the member spanning [keyStart, valueEnd) together with its structural
|
|
620
|
+
* comma. The trailing comma is consumed when the member is first or in the middle;
|
|
621
|
+
* the leading comma when it is last — reconstructing the exact pre-signing bytes
|
|
622
|
+
* for a member the server appended (or prepended) to compact JSON.
|
|
623
|
+
*/
|
|
624
|
+
function removeMemberSpan(text, keyStart, valueEnd) {
|
|
625
|
+
let after = valueEnd;
|
|
626
|
+
while (after < text.length && isJsonWhitespace(text[after]))
|
|
627
|
+
after++;
|
|
628
|
+
if (text[after] === ',') {
|
|
629
|
+
return text.slice(0, keyStart) + text.slice(after + 1);
|
|
630
|
+
}
|
|
631
|
+
let before = keyStart - 1;
|
|
632
|
+
while (before >= 0 && isJsonWhitespace(text[before]))
|
|
633
|
+
before--;
|
|
634
|
+
if (before >= 0 && text[before] === ',') {
|
|
635
|
+
return text.slice(0, before) + text.slice(valueEnd);
|
|
636
|
+
}
|
|
637
|
+
return text.slice(0, keyStart) + text.slice(valueEnd);
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Decodes the raw JSON value literal of the signature member into a base64 string.
|
|
641
|
+
* String values are unescaped via JSON.parse; byte-array values are converted to
|
|
642
|
+
* base64 (matching the .NET byte[] serialization mode); null/empty yield undefined.
|
|
643
|
+
*/
|
|
644
|
+
function decodeSignatureLiteral(valueLiteral) {
|
|
645
|
+
try {
|
|
646
|
+
const parsed = JSON.parse(valueLiteral);
|
|
647
|
+
if (typeof parsed === 'string') {
|
|
648
|
+
return parsed.length > 0 ? parsed : undefined;
|
|
649
|
+
}
|
|
650
|
+
if (Array.isArray(parsed)) {
|
|
651
|
+
const bytes = new Uint8Array(parsed);
|
|
652
|
+
let binary = '';
|
|
653
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
654
|
+
binary += String.fromCharCode(bytes[i]);
|
|
655
|
+
}
|
|
656
|
+
return btoa(binary);
|
|
657
|
+
}
|
|
658
|
+
return undefined;
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
return undefined;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Textually extracts the `__sidub_entitySignature` member from a raw JSON response.
|
|
666
|
+
*
|
|
667
|
+
* The member and its structural comma are removed by string surgery — the response
|
|
668
|
+
* is never re-serialized, so the remaining text is byte-identical to what the
|
|
669
|
+
* server signed. Only a member of the top-level object is matched; occurrences of
|
|
670
|
+
* the key text inside string values cannot match because their quotes are escaped
|
|
671
|
+
* in raw JSON.
|
|
672
|
+
*
|
|
673
|
+
* @param rawText The exact response body text as received from the server.
|
|
674
|
+
* @returns The stripped text, the decoded signature, and whether the member was found.
|
|
675
|
+
*/
|
|
676
|
+
function extractEntitySignatureMember(rawText) {
|
|
677
|
+
const n = rawText.length;
|
|
678
|
+
let depth = 0;
|
|
679
|
+
let i = 0;
|
|
680
|
+
while (i < n) {
|
|
681
|
+
const c = rawText[i];
|
|
682
|
+
if (c === '"') {
|
|
683
|
+
if (depth === 1 && rawText.startsWith(SIGNATURE_MEMBER_KEY, i)) {
|
|
684
|
+
let j = i + SIGNATURE_MEMBER_KEY.length;
|
|
685
|
+
while (j < n && isJsonWhitespace(rawText[j]))
|
|
686
|
+
j++;
|
|
687
|
+
if (rawText[j] === ':') {
|
|
688
|
+
j++;
|
|
689
|
+
while (j < n && isJsonWhitespace(rawText[j]))
|
|
690
|
+
j++;
|
|
691
|
+
const valueEnd = skipJsonValue(rawText, j);
|
|
692
|
+
if (valueEnd > j) {
|
|
693
|
+
return {
|
|
694
|
+
strippedText: removeMemberSpan(rawText, i, valueEnd),
|
|
695
|
+
signature: decodeSignatureLiteral(rawText.slice(j, valueEnd)),
|
|
696
|
+
found: true
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
i = skipJsonString(rawText, i);
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
if (c === '{' || c === '[')
|
|
705
|
+
depth++;
|
|
706
|
+
else if (c === '}' || c === ']')
|
|
707
|
+
depth--;
|
|
708
|
+
i++;
|
|
709
|
+
}
|
|
710
|
+
return { strippedText: rawText, signature: undefined, found: false };
|
|
711
|
+
}
|
|
553
712
|
/**
|
|
554
713
|
* Validates license authorization signatures to ensure integrity.
|
|
555
714
|
*
|
|
@@ -586,13 +745,20 @@ class SignatureValidator {
|
|
|
586
745
|
this.initialized = true;
|
|
587
746
|
}
|
|
588
747
|
/**
|
|
589
|
-
* Validates the signature of a license authorization using
|
|
748
|
+
* Validates the signature of a license authorization using a PARSED server response.
|
|
590
749
|
*
|
|
591
750
|
* The validation process:
|
|
592
751
|
* 1. Extracts the signature from __sidub_entitySignature
|
|
593
|
-
* 2.
|
|
752
|
+
* 2. Re-serializes the raw response data (excluding signature field)
|
|
594
753
|
* 3. Verifies the signature using the configured public key
|
|
595
754
|
*
|
|
755
|
+
* @deprecated Use {@link validateResponseText} instead (MON-205). Step 2 re-serializes an
|
|
756
|
+
* already-parsed object, and `JSON.stringify` does not round-trip byte-identically —
|
|
757
|
+
* property order, number formatting and string escaping can each differ from what the
|
|
758
|
+
* server actually signed, so a genuine authorization can fail verification.
|
|
759
|
+
* `validateResponseText` verifies the original response bytes and is what
|
|
760
|
+
* `LicensingClient` now uses; this method is retained only for compatibility.
|
|
761
|
+
*
|
|
596
762
|
* @param rawResponse The raw server response (before client mapping)
|
|
597
763
|
* @param options Validation options
|
|
598
764
|
* @returns Validation result
|
|
@@ -650,6 +816,69 @@ class SignatureValidator {
|
|
|
650
816
|
return { isValid: false, skipped: false, error: message };
|
|
651
817
|
}
|
|
652
818
|
}
|
|
819
|
+
/**
|
|
820
|
+
* Validates the signature of a license authorization against the exact raw
|
|
821
|
+
* response text (MON-205).
|
|
822
|
+
*
|
|
823
|
+
* Unlike {@link validateRawResponse}, which re-serializes a parsed object and is
|
|
824
|
+
* therefore sensitive to property-order and formatting differences, this method
|
|
825
|
+
* verifies against the original bytes: the `__sidub_entitySignature` member is
|
|
826
|
+
* removed textually and the remaining text is what the server actually signed.
|
|
827
|
+
*
|
|
828
|
+
* @param rawText The exact response body text as received from the server.
|
|
829
|
+
* @param options Validation options (same required/throwOnInvalid semantics).
|
|
830
|
+
* @returns Validation result
|
|
831
|
+
* @throws {CryptoError} If throwOnInvalid is true and validation fails
|
|
832
|
+
*/
|
|
833
|
+
async validateResponseText(rawText, options = {}) {
|
|
834
|
+
const { throwOnInvalid = true, required = this.isConfigured } = options;
|
|
835
|
+
// If no crypto service configured, skip validation
|
|
836
|
+
if (!this.cryptoService) {
|
|
837
|
+
if (required) {
|
|
838
|
+
const error = 'Signature validation is required but no cryptography configuration was provided.';
|
|
839
|
+
if (throwOnInvalid) {
|
|
840
|
+
throw new CryptoError(error, 'NOT_INITIALIZED');
|
|
841
|
+
}
|
|
842
|
+
return { isValid: false, skipped: false, error };
|
|
843
|
+
}
|
|
844
|
+
return { isValid: true, skipped: true };
|
|
845
|
+
}
|
|
846
|
+
// Ensure initialized
|
|
847
|
+
await this.initialize();
|
|
848
|
+
const { strippedText, signature } = extractEntitySignatureMember(rawText);
|
|
849
|
+
if (!signature) {
|
|
850
|
+
if (required) {
|
|
851
|
+
const error = 'Authorization does not contain a signature.';
|
|
852
|
+
if (throwOnInvalid) {
|
|
853
|
+
throw new CryptoError(error, 'INVALID_SIGNATURE');
|
|
854
|
+
}
|
|
855
|
+
return { isValid: false, skipped: false, error };
|
|
856
|
+
}
|
|
857
|
+
return { isValid: true, skipped: true };
|
|
858
|
+
}
|
|
859
|
+
try {
|
|
860
|
+
const dataBytes = new TextEncoder().encode(strippedText);
|
|
861
|
+
const isValid = await this.cryptoService.verifySignature(dataBytes.buffer, signature);
|
|
862
|
+
if (!isValid) {
|
|
863
|
+
const error = 'Authorization signature verification failed. The authorization may have been tampered with.';
|
|
864
|
+
if (throwOnInvalid) {
|
|
865
|
+
throw new CryptoError(error, 'INVALID_SIGNATURE');
|
|
866
|
+
}
|
|
867
|
+
return { isValid: false, skipped: false, error };
|
|
868
|
+
}
|
|
869
|
+
return { isValid: true, skipped: false };
|
|
870
|
+
}
|
|
871
|
+
catch (error) {
|
|
872
|
+
if (error instanceof CryptoError) {
|
|
873
|
+
throw error;
|
|
874
|
+
}
|
|
875
|
+
const message = `Signature validation error: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
|
876
|
+
if (throwOnInvalid) {
|
|
877
|
+
throw new CryptoError(message, 'VERIFICATION_FAILED');
|
|
878
|
+
}
|
|
879
|
+
return { isValid: false, skipped: false, error: message };
|
|
880
|
+
}
|
|
881
|
+
}
|
|
653
882
|
/**
|
|
654
883
|
* Serializes the raw server response for signature verification.
|
|
655
884
|
* Removes the __sidub_entitySignature field and serializes to JSON.
|
|
@@ -754,11 +983,39 @@ function onStorageChange(prefix, callback) {
|
|
|
754
983
|
return () => window.removeEventListener('storage', handler);
|
|
755
984
|
}
|
|
756
985
|
|
|
986
|
+
/** Default freshness TTL when the authorization carries no expiry. */
|
|
987
|
+
const DEFAULT_TTL_MS = 3600000; // 1h
|
|
988
|
+
/** Freshness is capped so revocation cannot stay invisible for the license lifetime. */
|
|
989
|
+
const MAX_FRESH_TTL_MS = 86400000; // 24h
|
|
990
|
+
/** Default stale-serve grace window beyond the freshness deadline. */
|
|
991
|
+
const DEFAULT_GRACE_MS = 86400000; // 24h
|
|
992
|
+
/** Upper bound for a configured grace window. */
|
|
993
|
+
const MAX_GRACE_MS = 7 * 86400000; // 7d
|
|
994
|
+
/**
|
|
995
|
+
* Non-cryptographic FNV-1a hash. Scoping only — localStorage key names must not
|
|
996
|
+
* contain raw API keys, and cache keys never need to be reversed.
|
|
997
|
+
*/
|
|
998
|
+
function hashKeyComponent(value) {
|
|
999
|
+
let hash = 0x811c9dc5;
|
|
1000
|
+
for (let i = 0; i < value.length; i++) {
|
|
1001
|
+
hash ^= value.charCodeAt(i);
|
|
1002
|
+
hash = Math.imul(hash, 0x01000193);
|
|
1003
|
+
}
|
|
1004
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
1005
|
+
}
|
|
757
1006
|
/**
|
|
758
1007
|
* Authorization cache with TTL-based expiry, pseudo-LRU eviction, and localStorage persistence.
|
|
759
|
-
* Cache key format: {licenseId}.{serviceKeyId}
|
|
1008
|
+
* Cache key format: {licenseId}.{serviceKeyId}.{apiKeyHash}
|
|
760
1009
|
* One cache instance per LicensingClient — no singletons.
|
|
761
1010
|
*
|
|
1011
|
+
* The API key participates in the cache key (hashed — never stored raw in
|
|
1012
|
+
* localStorage key names) so an apiKey override can never read another tenant's
|
|
1013
|
+
* cached authorization.
|
|
1014
|
+
*
|
|
1015
|
+
* Freshness TTL is capped at 24h regardless of license expiry; expired entries are
|
|
1016
|
+
* retained for a bounded grace window and served only via getStale() — flagged, and
|
|
1017
|
+
* only when they carry a validated server signature.
|
|
1018
|
+
*
|
|
762
1019
|
* When a storagePrefix is provided, entries are persisted to localStorage and restored
|
|
763
1020
|
* on construction, surviving page refreshes. Cross-tab sync is available via onSync().
|
|
764
1021
|
*/
|
|
@@ -767,13 +1024,16 @@ class AuthorizationCache {
|
|
|
767
1024
|
* Creates a new authorization cache.
|
|
768
1025
|
* @param maxSize The maximum number of entries to store before evicting the oldest. Defaults to 100.
|
|
769
1026
|
* @param storagePrefix Optional localStorage key prefix for persisting entries across page refreshes.
|
|
1027
|
+
* @param graceMs Stale-serve grace window in milliseconds beyond the freshness deadline.
|
|
1028
|
+
* Defaults to 24h; capped at 7 days.
|
|
770
1029
|
*/
|
|
771
|
-
constructor(maxSize = 100, storagePrefix) {
|
|
1030
|
+
constructor(maxSize = 100, storagePrefix, graceMs) {
|
|
772
1031
|
this.cache = new Map();
|
|
773
1032
|
this.unsubscribe = null;
|
|
774
1033
|
this.maxSize = maxSize;
|
|
775
1034
|
this.storagePrefix = storagePrefix ?? null;
|
|
776
|
-
|
|
1035
|
+
this.graceMs = Math.min(Math.max(graceMs ?? DEFAULT_GRACE_MS, 0), MAX_GRACE_MS);
|
|
1036
|
+
// Restore persisted entries, dropping ones past their stale window
|
|
777
1037
|
if (this.storagePrefix) {
|
|
778
1038
|
const keys = listKeys(this.storagePrefix);
|
|
779
1039
|
for (const fullKey of keys) {
|
|
@@ -782,7 +1042,7 @@ class AuthorizationCache {
|
|
|
782
1042
|
const stored = load(fullKey);
|
|
783
1043
|
if (!stored)
|
|
784
1044
|
continue;
|
|
785
|
-
if (Date.now() >= stored.expiresAt) {
|
|
1045
|
+
if (Date.now() >= (stored.staleUntil ?? stored.expiresAt)) {
|
|
786
1046
|
remove(fullKey);
|
|
787
1047
|
continue;
|
|
788
1048
|
}
|
|
@@ -795,45 +1055,98 @@ class AuthorizationCache {
|
|
|
795
1055
|
}
|
|
796
1056
|
}
|
|
797
1057
|
/**
|
|
798
|
-
*
|
|
1058
|
+
* Builds the composite cache key. The API key is hashed so raw key material
|
|
1059
|
+
* never appears in localStorage key names.
|
|
1060
|
+
*/
|
|
1061
|
+
buildKey(licenseId, serviceKeyId, apiKey) {
|
|
1062
|
+
const apiKeySegment = apiKey ? hashKeyComponent(apiKey) : '';
|
|
1063
|
+
return `${licenseId}.${serviceKeyId}.${apiKeySegment}`;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Gets a cached authorization if it exists and is still fresh.
|
|
799
1067
|
* On hit, re-inserts the entry to move it to the end (pseudo-LRU freshness).
|
|
1068
|
+
* Entries past freshness but within the grace window are kept (for getStale)
|
|
1069
|
+
* but reported as a miss here.
|
|
800
1070
|
*
|
|
801
1071
|
* @param licenseId The license identifier.
|
|
802
1072
|
* @param serviceKeyId The service key identifier.
|
|
803
|
-
* @
|
|
1073
|
+
* @param apiKey The effective API key the authorization was fetched with.
|
|
1074
|
+
* @returns The cached authorization, or null if not cached or no longer fresh.
|
|
804
1075
|
*/
|
|
805
|
-
get(licenseId, serviceKeyId) {
|
|
806
|
-
const key =
|
|
1076
|
+
get(licenseId, serviceKeyId, apiKey) {
|
|
1077
|
+
const key = this.buildKey(licenseId, serviceKeyId, apiKey);
|
|
807
1078
|
const entry = this.cache.get(key);
|
|
808
1079
|
if (!entry)
|
|
809
1080
|
return null;
|
|
810
|
-
|
|
811
|
-
|
|
1081
|
+
const now = Date.now();
|
|
1082
|
+
// Past the grace window — drop entirely
|
|
1083
|
+
if (now >= entry.staleUntil) {
|
|
812
1084
|
this.cache.delete(key);
|
|
813
1085
|
if (this.storagePrefix) {
|
|
814
1086
|
remove(this.storagePrefix + key);
|
|
815
1087
|
}
|
|
816
1088
|
return null;
|
|
817
1089
|
}
|
|
1090
|
+
// Past freshness — retained for stale-serve only
|
|
1091
|
+
if (now >= entry.expiresAt) {
|
|
1092
|
+
return null;
|
|
1093
|
+
}
|
|
818
1094
|
// Move to end of Map for pseudo-LRU freshness
|
|
819
1095
|
this.cache.delete(key);
|
|
820
1096
|
this.cache.set(key, entry);
|
|
821
1097
|
return entry.authorization;
|
|
822
1098
|
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Gets an expired-but-graced authorization for stale-serve, flagged with isStale.
|
|
1101
|
+
* Only entries whose signature was validated against the server response are
|
|
1102
|
+
* eligible — unverified data must never be served past its freshness deadline.
|
|
1103
|
+
*
|
|
1104
|
+
* @param licenseId The license identifier.
|
|
1105
|
+
* @param serviceKeyId The service key identifier.
|
|
1106
|
+
* @param apiKey The effective API key the authorization was fetched with.
|
|
1107
|
+
* @returns A flagged copy of the stale authorization, or null when none is eligible.
|
|
1108
|
+
*/
|
|
1109
|
+
getStale(licenseId, serviceKeyId, apiKey) {
|
|
1110
|
+
const key = this.buildKey(licenseId, serviceKeyId, apiKey);
|
|
1111
|
+
const entry = this.cache.get(key);
|
|
1112
|
+
if (!entry)
|
|
1113
|
+
return null;
|
|
1114
|
+
const now = Date.now();
|
|
1115
|
+
if (now >= entry.staleUntil) {
|
|
1116
|
+
this.cache.delete(key);
|
|
1117
|
+
if (this.storagePrefix) {
|
|
1118
|
+
remove(this.storagePrefix + key);
|
|
1119
|
+
}
|
|
1120
|
+
return null;
|
|
1121
|
+
}
|
|
1122
|
+
if (entry.authorization.signatureValidated !== true) {
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
1125
|
+
// Still fresh — serve unflagged
|
|
1126
|
+
if (now < entry.expiresAt) {
|
|
1127
|
+
return entry.authorization;
|
|
1128
|
+
}
|
|
1129
|
+
return { ...entry.authorization, isStale: true };
|
|
1130
|
+
}
|
|
823
1131
|
/**
|
|
824
1132
|
* Stores an authorization in the cache.
|
|
825
1133
|
* Evicts the oldest entry if max size is reached.
|
|
826
1134
|
*
|
|
1135
|
+
* Freshness is min(license expiry, now + 24h) — default 1h when the
|
|
1136
|
+
* authorization has no expiry. The stale window extends freshness by graceMs
|
|
1137
|
+
* but never past the license's own expiry.
|
|
1138
|
+
*
|
|
827
1139
|
* @param licenseId The license identifier.
|
|
828
1140
|
* @param serviceKeyId The service key identifier.
|
|
829
1141
|
* @param authorization The license authorization to cache.
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
|
|
1142
|
+
* @param apiKey The effective API key the authorization was fetched with.
|
|
1143
|
+
*/
|
|
1144
|
+
set(licenseId, serviceKeyId, authorization, apiKey) {
|
|
1145
|
+
const key = this.buildKey(licenseId, serviceKeyId, apiKey);
|
|
1146
|
+
const now = Date.now();
|
|
1147
|
+
const hardExpiry = authorization.expiresAt ? authorization.expiresAt.getTime() : undefined;
|
|
1148
|
+
const expiresAt = Math.min(hardExpiry ?? (now + DEFAULT_TTL_MS), now + MAX_FRESH_TTL_MS);
|
|
1149
|
+
const staleUntil = Math.min(expiresAt + this.graceMs, hardExpiry ?? Number.MAX_SAFE_INTEGER);
|
|
837
1150
|
// If key already exists, delete first (so re-insert goes to end)
|
|
838
1151
|
if (this.cache.has(key)) {
|
|
839
1152
|
this.cache.delete(key);
|
|
@@ -848,9 +1161,10 @@ class AuthorizationCache {
|
|
|
848
1161
|
}
|
|
849
1162
|
}
|
|
850
1163
|
}
|
|
851
|
-
|
|
1164
|
+
const entry = { authorization, expiresAt, staleUntil };
|
|
1165
|
+
this.cache.set(key, entry);
|
|
852
1166
|
if (this.storagePrefix) {
|
|
853
|
-
save(this.storagePrefix + key, this.serializeEntry(
|
|
1167
|
+
save(this.storagePrefix + key, this.serializeEntry(entry));
|
|
854
1168
|
}
|
|
855
1169
|
}
|
|
856
1170
|
/** Removes all cached entries */
|
|
@@ -897,7 +1211,7 @@ class AuthorizationCache {
|
|
|
897
1211
|
else {
|
|
898
1212
|
try {
|
|
899
1213
|
const stored = JSON.parse(newValue);
|
|
900
|
-
if (Date.now() >= stored.expiresAt) {
|
|
1214
|
+
if (Date.now() >= (stored.staleUntil ?? stored.expiresAt)) {
|
|
901
1215
|
this.cache.delete(cacheKey);
|
|
902
1216
|
return;
|
|
903
1217
|
}
|
|
@@ -933,6 +1247,7 @@ class AuthorizationCache {
|
|
|
933
1247
|
/**
|
|
934
1248
|
* Serializes a cache entry for localStorage storage.
|
|
935
1249
|
* Converts Date objects to epoch numbers for safe JSON round-tripping.
|
|
1250
|
+
* All authorization fields (including isStale when present) round-trip via spread.
|
|
936
1251
|
*/
|
|
937
1252
|
serializeEntry(entry) {
|
|
938
1253
|
const auth = entry.authorization;
|
|
@@ -942,11 +1257,13 @@ class AuthorizationCache {
|
|
|
942
1257
|
issuedAt: auth.issuedAt instanceof Date ? auth.issuedAt.getTime() : auth.issuedAt,
|
|
943
1258
|
expiresAt: auth.expiresAt instanceof Date ? auth.expiresAt.getTime() : auth.expiresAt
|
|
944
1259
|
},
|
|
945
|
-
expiresAt: entry.expiresAt
|
|
1260
|
+
expiresAt: entry.expiresAt,
|
|
1261
|
+
staleUntil: entry.staleUntil
|
|
946
1262
|
};
|
|
947
1263
|
}
|
|
948
1264
|
/**
|
|
949
1265
|
* Deserializes a cache entry from localStorage, reconstructing Date objects.
|
|
1266
|
+
* Entries persisted before staleUntil existed get no grace window.
|
|
950
1267
|
*/
|
|
951
1268
|
deserializeEntry(stored) {
|
|
952
1269
|
try {
|
|
@@ -956,7 +1273,11 @@ class AuthorizationCache {
|
|
|
956
1273
|
issuedAt: raw.issuedAt ? new Date(raw.issuedAt) : new Date(),
|
|
957
1274
|
expiresAt: raw.expiresAt ? new Date(raw.expiresAt) : undefined
|
|
958
1275
|
};
|
|
959
|
-
return {
|
|
1276
|
+
return {
|
|
1277
|
+
authorization,
|
|
1278
|
+
expiresAt: stored.expiresAt,
|
|
1279
|
+
staleUntil: stored.staleUntil ?? stored.expiresAt
|
|
1280
|
+
};
|
|
960
1281
|
}
|
|
961
1282
|
catch {
|
|
962
1283
|
return null;
|
|
@@ -1091,6 +1412,8 @@ class LicensingClient {
|
|
|
1091
1412
|
this.disposables = [];
|
|
1092
1413
|
this.contextResolved = false;
|
|
1093
1414
|
this.contextResolutionPromise = null;
|
|
1415
|
+
// MON-203: single-flight authorization fetches keyed on licenseId+serviceKeyId+apiKey.
|
|
1416
|
+
this.inFlightAuthorizations = new Map();
|
|
1094
1417
|
// Decode credential if provided
|
|
1095
1418
|
this.credential = config.encodedCredential
|
|
1096
1419
|
? decodeCredential(config.encodedCredential)
|
|
@@ -1112,7 +1435,7 @@ class LicensingClient {
|
|
|
1112
1435
|
// Initialize authorization cache (enabled by default) with localStorage persistence
|
|
1113
1436
|
this.cacheEnabled = config.cacheEnabled !== false;
|
|
1114
1437
|
this.authorizationCache = this.cacheEnabled
|
|
1115
|
-
? new AuthorizationCache(config.cacheMaxSize ?? 100, LicensingClient.AUTH_STORAGE_PREFIX)
|
|
1438
|
+
? new AuthorizationCache(config.cacheMaxSize ?? 100, LicensingClient.AUTH_STORAGE_PREFIX, config.staleGraceMs)
|
|
1116
1439
|
: null;
|
|
1117
1440
|
// Register cross-tab sync for authorization cache
|
|
1118
1441
|
if (this.authorizationCache) {
|
|
@@ -1170,7 +1493,11 @@ class LicensingClient {
|
|
|
1170
1493
|
}
|
|
1171
1494
|
/**
|
|
1172
1495
|
* Ensures context provider has been resolved before making API calls.
|
|
1173
|
-
*
|
|
1496
|
+
* Resolved once per client lifetime; concurrent callers share the same promise.
|
|
1497
|
+
*
|
|
1498
|
+
* MON-202: a failed attempt is not memoized — concurrent awaiters of the failed
|
|
1499
|
+
* attempt receive that attempt's rejection, and the next call starts a fresh
|
|
1500
|
+
* attempt. contextResolved only ever becomes true on success.
|
|
1174
1501
|
*/
|
|
1175
1502
|
async ensureContextResolved() {
|
|
1176
1503
|
if (this.contextResolved)
|
|
@@ -1183,7 +1510,7 @@ class LicensingClient {
|
|
|
1183
1510
|
this.contextResolved = true;
|
|
1184
1511
|
return;
|
|
1185
1512
|
}
|
|
1186
|
-
|
|
1513
|
+
const resolutionAttempt = (async () => {
|
|
1187
1514
|
const context = await this.config.contextProvider.resolveContext();
|
|
1188
1515
|
if (context) {
|
|
1189
1516
|
const hadCrypto = !!(this.config.serviceKeyId && this.config.serviceKeyPublicMember);
|
|
@@ -1209,7 +1536,17 @@ class LicensingClient {
|
|
|
1209
1536
|
}
|
|
1210
1537
|
this.contextResolved = true;
|
|
1211
1538
|
})();
|
|
1212
|
-
|
|
1539
|
+
this.contextResolutionPromise = resolutionAttempt;
|
|
1540
|
+
try {
|
|
1541
|
+
await resolutionAttempt;
|
|
1542
|
+
}
|
|
1543
|
+
catch (error) {
|
|
1544
|
+
// Identity-guarded: refresh() may have installed a newer attempt already.
|
|
1545
|
+
if (this.contextResolutionPromise === resolutionAttempt) {
|
|
1546
|
+
this.contextResolutionPromise = null;
|
|
1547
|
+
}
|
|
1548
|
+
throw error;
|
|
1549
|
+
}
|
|
1213
1550
|
}
|
|
1214
1551
|
/**
|
|
1215
1552
|
* Builds the cryptography configuration from the resolved config values
|
|
@@ -1243,6 +1580,13 @@ class LicensingClient {
|
|
|
1243
1580
|
* If cryptographic configuration is provided, the authorization signature will be
|
|
1244
1581
|
* validated to ensure integrity. This mirrors the .NET LicensingService behavior.
|
|
1245
1582
|
*
|
|
1583
|
+
* Resilience (MON-203): transient failures (408/429/5xx/network) are retried with
|
|
1584
|
+
* exponential backoff and jitter within the config.timeout budget, honoring
|
|
1585
|
+
* Retry-After. Concurrent calls for the same license/key/apiKey share one
|
|
1586
|
+
* in-flight request. When retries are exhausted on a transient failure, an
|
|
1587
|
+
* expired-but-graced cached authorization is served flagged isStale — only if
|
|
1588
|
+
* its signature was validated; never on 401/403/404 or any denial.
|
|
1589
|
+
*
|
|
1246
1590
|
* @param licenseId The unique identifier of the license (optional if configured via encodedCredential)
|
|
1247
1591
|
* @param apiKey Optional API key for authentication (overrides config default)
|
|
1248
1592
|
* @returns A promise resolving to the license authorization
|
|
@@ -1256,14 +1600,113 @@ class LicensingClient {
|
|
|
1256
1600
|
if (!effectiveLicenseId) {
|
|
1257
1601
|
throw new LicensingConfigurationException('License ID is required. Provide it as a parameter or via configuration.');
|
|
1258
1602
|
}
|
|
1603
|
+
const effectiveApiKey = apiKey || this.config.apiKey;
|
|
1604
|
+
const serviceKeyId = this.config.serviceKeyId ?? '';
|
|
1259
1605
|
// Check cache first
|
|
1260
1606
|
if (this.cacheEnabled && this.authorizationCache) {
|
|
1261
|
-
const cached = this.authorizationCache.get(effectiveLicenseId,
|
|
1607
|
+
const cached = this.authorizationCache.get(effectiveLicenseId, serviceKeyId, effectiveApiKey);
|
|
1262
1608
|
if (cached)
|
|
1263
1609
|
return cached;
|
|
1264
1610
|
}
|
|
1611
|
+
// In-flight dedup — concurrent callers share one request (and its rejection)
|
|
1612
|
+
const flightKey = `${effectiveLicenseId}.${serviceKeyId}.${effectiveApiKey ?? ''}`;
|
|
1613
|
+
const existing = this.inFlightAuthorizations.get(flightKey);
|
|
1614
|
+
if (existing)
|
|
1615
|
+
return existing;
|
|
1616
|
+
const flight = this.executeAuthorizationFlight(effectiveLicenseId, effectiveApiKey);
|
|
1617
|
+
this.inFlightAuthorizations.set(flightKey, flight);
|
|
1618
|
+
try {
|
|
1619
|
+
return await flight;
|
|
1620
|
+
}
|
|
1621
|
+
finally {
|
|
1622
|
+
if (this.inFlightAuthorizations.get(flightKey) === flight) {
|
|
1623
|
+
this.inFlightAuthorizations.delete(flightKey);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
/**
|
|
1628
|
+
* Runs the retrying fetch and, when it fails transiently, falls back to a
|
|
1629
|
+
* stale-but-graced cached authorization. Non-transient failures (denials,
|
|
1630
|
+
* signature failures) propagate untouched — they must never be masked.
|
|
1631
|
+
*/
|
|
1632
|
+
async executeAuthorizationFlight(effectiveLicenseId, effectiveApiKey) {
|
|
1633
|
+
try {
|
|
1634
|
+
return await this.fetchAuthorizationWithRetry(effectiveLicenseId, effectiveApiKey);
|
|
1635
|
+
}
|
|
1636
|
+
catch (error) {
|
|
1637
|
+
if (LicensingClient.isTransientError(error) && this.cacheEnabled && this.authorizationCache) {
|
|
1638
|
+
const stale = this.authorizationCache.getStale(effectiveLicenseId, this.config.serviceKeyId ?? '', effectiveApiKey);
|
|
1639
|
+
if (stale)
|
|
1640
|
+
return stale;
|
|
1641
|
+
}
|
|
1642
|
+
throw error;
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Determines whether an authorization/poll error is transient and safe to retry:
|
|
1647
|
+
* network failures (no status), request timeouts (408), throttling (429), and
|
|
1648
|
+
* server errors (5xx). Denials and client errors (401/403/404/4xx) are not.
|
|
1649
|
+
*/
|
|
1650
|
+
static isTransientError(error) {
|
|
1651
|
+
if (!(error instanceof LicensingError))
|
|
1652
|
+
return false;
|
|
1653
|
+
const status = error.statusCode;
|
|
1654
|
+
if (status === undefined)
|
|
1655
|
+
return true;
|
|
1656
|
+
return status === 408 || status === 429 || status >= 500;
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* Parses a Retry-After header value (delta-seconds or HTTP-date) into milliseconds.
|
|
1660
|
+
*/
|
|
1661
|
+
static parseRetryAfter(headerValue) {
|
|
1662
|
+
if (!headerValue)
|
|
1663
|
+
return undefined;
|
|
1664
|
+
const seconds = Number(headerValue);
|
|
1665
|
+
if (!Number.isNaN(seconds) && seconds >= 0) {
|
|
1666
|
+
return seconds * 1000;
|
|
1667
|
+
}
|
|
1668
|
+
const date = Date.parse(headerValue);
|
|
1669
|
+
if (!Number.isNaN(date)) {
|
|
1670
|
+
return Math.max(0, date - Date.now());
|
|
1671
|
+
}
|
|
1672
|
+
return undefined;
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* Retries transient failures with exponential backoff and jitter, honoring
|
|
1676
|
+
* Retry-After when the server supplied one. config.timeout is the overall
|
|
1677
|
+
* budget — a retry whose delay would cross the deadline is not attempted.
|
|
1678
|
+
*/
|
|
1679
|
+
async fetchAuthorizationWithRetry(effectiveLicenseId, effectiveApiKey) {
|
|
1680
|
+
const deadline = Date.now() + (this.config.timeout ?? 30000);
|
|
1681
|
+
let lastError;
|
|
1682
|
+
for (let attempt = 0; attempt < LicensingClient.RETRY_MAX_ATTEMPTS; attempt++) {
|
|
1683
|
+
if (attempt > 0) {
|
|
1684
|
+
const retryAfterMs = lastError instanceof LicensingError ? lastError.retryAfterMs : undefined;
|
|
1685
|
+
const backoffCap = Math.min(LicensingClient.RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1), LicensingClient.RETRY_MAX_DELAY_MS);
|
|
1686
|
+
// Full backoff with jitter in [cap/2, cap]; Retry-After overrides the backoff.
|
|
1687
|
+
const delayMs = retryAfterMs ?? Math.floor(backoffCap / 2 + Math.random() * (backoffCap / 2));
|
|
1688
|
+
if (Date.now() + delayMs >= deadline)
|
|
1689
|
+
break;
|
|
1690
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1691
|
+
}
|
|
1692
|
+
try {
|
|
1693
|
+
return await this.fetchAuthorization(effectiveLicenseId, effectiveApiKey);
|
|
1694
|
+
}
|
|
1695
|
+
catch (error) {
|
|
1696
|
+
if (!LicensingClient.isTransientError(error)) {
|
|
1697
|
+
throw error;
|
|
1698
|
+
}
|
|
1699
|
+
lastError = error;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
throw lastError;
|
|
1703
|
+
}
|
|
1704
|
+
/**
|
|
1705
|
+
* Performs a single authorization fetch attempt, validates the response
|
|
1706
|
+
* signature against the exact raw response bytes, maps and caches the result.
|
|
1707
|
+
*/
|
|
1708
|
+
async fetchAuthorization(effectiveLicenseId, effectiveApiKey) {
|
|
1265
1709
|
const url = `${this.config.licenseServiceUri}/GenerateLicenseAuthorization`;
|
|
1266
|
-
const effectiveApiKey = apiKey || this.config.apiKey;
|
|
1267
1710
|
const headers = {
|
|
1268
1711
|
'Content-Type': 'application/json',
|
|
1269
1712
|
};
|
|
@@ -1291,16 +1734,21 @@ class LicensingClient {
|
|
|
1291
1734
|
clearTimeout(timeoutId);
|
|
1292
1735
|
if (!response.ok) {
|
|
1293
1736
|
const errorText = await response.text().catch(() => 'Unknown error');
|
|
1294
|
-
|
|
1737
|
+
const licensingError = new LicensingError(`Authorization failed: ${response.statusText}`, response.status, errorText);
|
|
1738
|
+
licensingError.retryAfterMs = LicensingClient.parseRetryAfter(response.headers?.get?.('Retry-After'));
|
|
1739
|
+
throw licensingError;
|
|
1295
1740
|
}
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
//
|
|
1741
|
+
// MON-205: capture the exact raw bytes; parse only after validation.
|
|
1742
|
+
const rawText = await response.text();
|
|
1743
|
+
// Validate signature FIRST against the raw response text with the signature
|
|
1744
|
+
// member textually removed — the exact bytes the server signed. Never a
|
|
1745
|
+
// re-serialization: JSON.stringify does not round-trip byte-identically.
|
|
1299
1746
|
let signatureValidated = false;
|
|
1300
1747
|
if (this.validateSignatures) {
|
|
1301
|
-
const validationResult = await this.signatureValidator.
|
|
1748
|
+
const validationResult = await this.signatureValidator.validateResponseText(rawText, { throwOnInvalid: true, required: this.signatureValidator.isConfigured });
|
|
1302
1749
|
signatureValidated = validationResult.isValid && !validationResult.skipped;
|
|
1303
1750
|
}
|
|
1751
|
+
const data = JSON.parse(rawText);
|
|
1304
1752
|
// Map server response to client model
|
|
1305
1753
|
// Note: Server uses PascalCase, we convert to camelCase
|
|
1306
1754
|
// Signature is returned as base64 string in __sidub_entitySignature from .NET
|
|
@@ -1331,9 +1779,10 @@ class LicensingClient {
|
|
|
1331
1779
|
if (metadataChanged) {
|
|
1332
1780
|
this.persistFeatureMetadata();
|
|
1333
1781
|
}
|
|
1334
|
-
// Store in cache
|
|
1782
|
+
// Store in cache — validation already happened above; the effective apiKey
|
|
1783
|
+
// scopes the entry so an override can never read another tenant's cache.
|
|
1335
1784
|
if (this.cacheEnabled && this.authorizationCache) {
|
|
1336
|
-
this.authorizationCache.set(effectiveLicenseId, this.config.serviceKeyId ?? '', authorization);
|
|
1785
|
+
this.authorizationCache.set(effectiveLicenseId, this.config.serviceKeyId ?? '', authorization, effectiveApiKey);
|
|
1337
1786
|
}
|
|
1338
1787
|
return authorization;
|
|
1339
1788
|
}
|
|
@@ -1536,6 +1985,8 @@ class LicensingClient {
|
|
|
1536
1985
|
// Reset context resolution so ensureContextResolved() re-runs with new config
|
|
1537
1986
|
this.contextResolved = false;
|
|
1538
1987
|
this.contextResolutionPromise = null;
|
|
1988
|
+
// Pre-refresh in-flight authorization fetches must not satisfy post-refresh calls
|
|
1989
|
+
this.inFlightAuthorizations.clear();
|
|
1539
1990
|
}
|
|
1540
1991
|
assertLicense(assertion, authorization) {
|
|
1541
1992
|
// Auto-inject feature states for RateLimitAssertion
|
|
@@ -1601,9 +2052,14 @@ class LicensingClient {
|
|
|
1601
2052
|
* Reports license feature consumption/usage to the server.
|
|
1602
2053
|
* This is used for metering and billing purposes.
|
|
1603
2054
|
*
|
|
2055
|
+
* Billability is derived from the resolved authorization's license classification:
|
|
2056
|
+
* only Subscription licenses meter billable usage (mirrors .NET
|
|
2057
|
+
* LicensingService.PerformOperation). Each report carries a freshly minted
|
|
2058
|
+
* SubmissionId so the server can deduplicate retried submissions.
|
|
2059
|
+
*
|
|
1604
2060
|
* @param operation The operation to report
|
|
1605
2061
|
* @returns A promise that resolves when the operation is reported
|
|
1606
|
-
* @throws {LicensingError} If the request fails
|
|
2062
|
+
* @throws {LicensingError} If the request fails or the authorization cannot be resolved
|
|
1607
2063
|
*/
|
|
1608
2064
|
async performOperation(operation) {
|
|
1609
2065
|
await this.ensureContextResolved();
|
|
@@ -1611,6 +2067,10 @@ class LicensingClient {
|
|
|
1611
2067
|
if (!this.config.consumptionServiceUri) {
|
|
1612
2068
|
return;
|
|
1613
2069
|
}
|
|
2070
|
+
// MON-184: billable if and only if the license classification is Subscription —
|
|
2071
|
+
// the authorization is resolved through the cache-aware path (fetch on miss).
|
|
2072
|
+
const authorization = await this.getAuthorization(operation.licenseId);
|
|
2073
|
+
const isBillable = authorization.classification === LicenseClassificationType.Subscription;
|
|
1614
2074
|
const url = `${this.config.consumptionServiceUri}/messages`;
|
|
1615
2075
|
const apiKey = this.config.apiKey;
|
|
1616
2076
|
const headers = {
|
|
@@ -1630,9 +2090,11 @@ class LicensingClient {
|
|
|
1630
2090
|
headers,
|
|
1631
2091
|
body: JSON.stringify({
|
|
1632
2092
|
LicenseId: operation.licenseId,
|
|
2093
|
+
// MON-187: per-call idempotency key — the server dedups on SubmissionId.
|
|
2094
|
+
SubmissionId: this.generateRequestId(),
|
|
1633
2095
|
BillableResourceId: this.config.billableResourceId ?? '00000000-0000-0000-0000-000000000000',
|
|
1634
2096
|
BillablePlanId: this.config.billablePlanId ?? '',
|
|
1635
|
-
IsBillable:
|
|
2097
|
+
IsBillable: isBillable,
|
|
1636
2098
|
LicenseOperation: {
|
|
1637
2099
|
LicenseFeature: {
|
|
1638
2100
|
FeatureKey: operation.feature.featureId
|
|
@@ -1703,6 +2165,8 @@ class LicensingClient {
|
|
|
1703
2165
|
: '';
|
|
1704
2166
|
const body = JSON.stringify({
|
|
1705
2167
|
LicenseId: effectiveLicenseId,
|
|
2168
|
+
// MON-187: per-call idempotency key — the server dedups on SubmissionId.
|
|
2169
|
+
SubmissionId: this.generateRequestId(),
|
|
1706
2170
|
BillableResourceId: this.config.billableResourceId ?? '00000000-0000-0000-0000-000000000000',
|
|
1707
2171
|
BillablePlanId: this.config.billablePlanId ?? '',
|
|
1708
2172
|
IsBillable: false,
|
|
@@ -1892,25 +2356,51 @@ class LicensingClient {
|
|
|
1892
2356
|
* Polls for a checkout session result with exponential backoff.
|
|
1893
2357
|
* Replaces the useCheckout hook pattern with an imperative API.
|
|
1894
2358
|
*
|
|
2359
|
+
* Resilience (MON-204): transient failures (408/429/5xx/network) inside the loop
|
|
2360
|
+
* are tolerated up to a consecutive-failure budget, with the backoff sleep also
|
|
2361
|
+
* running after failed attempts. Caller cancellation propagates immediately —
|
|
2362
|
+
* an aborted signal wins over a retryable 408, since getCheckoutResult converts
|
|
2363
|
+
* an aborted request into LicensingError('Request timeout', 408). Denials
|
|
2364
|
+
* (401/403/404) fail fast.
|
|
2365
|
+
*
|
|
1895
2366
|
* @param sessionId The session ID from createCheckoutSession()
|
|
1896
2367
|
* @param options Optional: apiKey override, AbortSignal for cancellation, maxAttempts (default 60)
|
|
1897
2368
|
* @returns The completed or failed checkout session result
|
|
1898
|
-
* @throws {LicensingError} If polling times out
|
|
2369
|
+
* @throws {LicensingError} If polling times out, is cancelled, or fails non-transiently
|
|
1899
2370
|
*/
|
|
1900
2371
|
async pollCheckoutResult(sessionId, options) {
|
|
1901
2372
|
const maxAttempts = options?.maxAttempts ?? 60;
|
|
1902
2373
|
const signal = options?.signal;
|
|
1903
2374
|
let delay = 1000;
|
|
1904
2375
|
const maxDelay = 15000;
|
|
2376
|
+
const consecutiveFailureBudget = 5;
|
|
2377
|
+
let consecutiveFailures = 0;
|
|
1905
2378
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1906
2379
|
if (signal?.aborted) {
|
|
1907
2380
|
throw new LicensingError('Checkout polling cancelled');
|
|
1908
2381
|
}
|
|
1909
|
-
|
|
1910
|
-
|
|
2382
|
+
let result = null;
|
|
2383
|
+
try {
|
|
2384
|
+
result = await this.getCheckoutResult(sessionId, options?.apiKey);
|
|
2385
|
+
consecutiveFailures = 0;
|
|
2386
|
+
}
|
|
2387
|
+
catch (error) {
|
|
2388
|
+
// Cancellation surfaces as an abort-derived 408 — it must never be retried.
|
|
2389
|
+
if (signal?.aborted) {
|
|
2390
|
+
throw new LicensingError('Checkout polling cancelled');
|
|
2391
|
+
}
|
|
2392
|
+
if (!LicensingClient.isTransientError(error)) {
|
|
2393
|
+
throw error;
|
|
2394
|
+
}
|
|
2395
|
+
consecutiveFailures++;
|
|
2396
|
+
if (consecutiveFailures >= consecutiveFailureBudget) {
|
|
2397
|
+
throw error;
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
if (result && (result.status === 'completed' || result.status === 'failed')) {
|
|
1911
2401
|
return result;
|
|
1912
2402
|
}
|
|
1913
|
-
// Wait with exponential backoff
|
|
2403
|
+
// Wait with exponential backoff — after failed attempts too
|
|
1914
2404
|
await new Promise((resolve) => {
|
|
1915
2405
|
const timer = setTimeout(resolve, delay);
|
|
1916
2406
|
if (signal) {
|
|
@@ -1933,6 +2423,11 @@ class LicensingClient {
|
|
|
1933
2423
|
LicensingClient.METADATA_STORAGE_KEY = 'sidub.licensing.metadata';
|
|
1934
2424
|
LicensingClient.CONSUMPTION_STORAGE_PREFIX = 'sidub.licensing.consumption.';
|
|
1935
2425
|
LicensingClient.AUTH_STORAGE_PREFIX = 'sidub.licensing.auth.';
|
|
2426
|
+
// MON-203: bounded retry policy for transient authorization failures. The overall
|
|
2427
|
+
// budget is config.timeout — retries never extend total time past it.
|
|
2428
|
+
LicensingClient.RETRY_MAX_ATTEMPTS = 4; // 1 initial + 3 retries
|
|
2429
|
+
LicensingClient.RETRY_BASE_DELAY_MS = 500;
|
|
2430
|
+
LicensingClient.RETRY_MAX_DELAY_MS = 8000;
|
|
1936
2431
|
|
|
1937
2432
|
/**
|
|
1938
2433
|
* Assertion that checks if a feature with a specific key exists in the authorization.
|
|
@@ -2111,10 +2606,17 @@ const LicensingProvider = ({ config, children }) => {
|
|
|
2111
2606
|
// Phase 11 D-11: when the encoded credential changes, invalidate client state and
|
|
2112
2607
|
// rebuild credential config without remounting the component tree.
|
|
2113
2608
|
// The `key={credential}` remount idiom is a deprecated fallback.
|
|
2609
|
+
// MON-189: the effect also fires on first mount — the client was just constructed
|
|
2610
|
+
// from this exact credential, so refresh() must only run on an actual change.
|
|
2611
|
+
const observedCredentialRef = useRef(null);
|
|
2114
2612
|
useEffect(() => {
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2613
|
+
const observed = observedCredentialRef.current;
|
|
2614
|
+
observedCredentialRef.current = { credential: config.encodedCredential };
|
|
2615
|
+
if (observed === null)
|
|
2616
|
+
return;
|
|
2617
|
+
if (observed.credential === config.encodedCredential)
|
|
2618
|
+
return;
|
|
2619
|
+
void value.client.refresh(config);
|
|
2118
2620
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2119
2621
|
}, [config.encodedCredential]);
|
|
2120
2622
|
return (React.createElement(LicensingContext.Provider, { value: value }, children));
|
|
@@ -2253,5 +2755,5 @@ class ConfigurationContextProvider {
|
|
|
2253
2755
|
}
|
|
2254
2756
|
}
|
|
2255
2757
|
|
|
2256
|
-
export { AuthorizationCache, BillingIntervalUnit, CompositeAssertion, ConfigurationContextProvider, CryptoError, CryptoService, FeatureExistsAssertion, LicenseAssertion, LicenseClassificationType, LicensingClient, LicensingConfigurationException, LicensingError, LicensingProvider, NotAssertion, RateLimitAssertion, RateLimitError, RateLimitFeatureState, ServiceAccessAssertion, ServiceAccessLevel, SignatureValidator, decodeCredential, encodeCredential, findFeatureByKey, getFeatureKey, hasFeature, hasValidCryptoConfig, licensingContextFromEncodedString, licensingContextToEncodedString, listKeys, load, matchesFeatureKey, normalizeBillingIntervalUnit, normalizeLicenseClassificationType, normalizeServiceAccessLevel, onStorageChange, remove, save, tryDecodeCredential, useLicensingContext, useLicensingContextValue };
|
|
2758
|
+
export { AuthorizationCache, BillingIntervalUnit, CompositeAssertion, ConfigurationContextProvider, CryptoError, CryptoService, FeatureExistsAssertion, LicenseAssertion, LicenseClassificationType, LicensingClient, LicensingConfigurationException, LicensingError, LicensingProvider, NotAssertion, RateLimitAssertion, RateLimitError, RateLimitFeatureState, ServiceAccessAssertion, ServiceAccessLevel, SignatureValidator, decodeCredential, encodeCredential, extractEntitySignatureMember, findFeatureByKey, getFeatureKey, hasFeature, hasValidCryptoConfig, licensingContextFromEncodedString, licensingContextToEncodedString, listKeys, load, matchesFeatureKey, normalizeBillingIntervalUnit, normalizeLicenseClassificationType, normalizeServiceAccessLevel, onStorageChange, remove, save, tryDecodeCredential, useLicensingContext, useLicensingContextValue };
|
|
2257
2759
|
//# sourceMappingURL=index.esm.js.map
|