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