@sidub-inc/licensing-client 1.4.0 → 1.5.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +181 -6
- package/dist/index.esm.js +516 -11
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +520 -9
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.esm.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { createContext, useMemo, useContext } from 'react';
|
|
1
|
+
import React, { createContext, useMemo, useEffect, useContext } from 'react';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Error class for licensing-related errors.
|
|
@@ -19,6 +19,29 @@ class LicensingError extends Error {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* SEM-03 / Phase 11 D-14: thrown by `LicensingClient.assertLicense` when a
|
|
24
|
+
* rate-limited feature's assertion evaluates false.
|
|
25
|
+
*
|
|
26
|
+
* Distinguishable from transient network errors by type alone:
|
|
27
|
+
* `catch (e) { if (e instanceof RateLimitError) { ... } }`
|
|
28
|
+
*
|
|
29
|
+
* Matches the .NET `Sidub.Licensing.Client.Exceptions.RateLimitException` shape.
|
|
30
|
+
* Minimal surface — only `featureKey` and `message`. No retry-after, no counters.
|
|
31
|
+
*/
|
|
32
|
+
class RateLimitError extends Error {
|
|
33
|
+
/**
|
|
34
|
+
* Creates a new RateLimitError.
|
|
35
|
+
* @param featureKey The key of the feature whose rate limit was exceeded.
|
|
36
|
+
* @param message A human-readable description of the violation.
|
|
37
|
+
*/
|
|
38
|
+
constructor(featureKey, message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.featureKey = featureKey;
|
|
41
|
+
this.name = 'RateLimitError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
22
45
|
/**
|
|
23
46
|
* License classification types defining the licensing model.
|
|
24
47
|
* Matches the .NET LicenseClassificationType enum.
|
|
@@ -640,18 +663,136 @@ class SignatureValidator {
|
|
|
640
663
|
}
|
|
641
664
|
|
|
642
665
|
/**
|
|
643
|
-
*
|
|
666
|
+
* Thin localStorage abstraction for persisting SDK state across page refreshes.
|
|
667
|
+
* All methods are SSR-safe — they no-op when `window`/`localStorage` is unavailable.
|
|
668
|
+
*
|
|
669
|
+
* Used by RateLimitFeatureState and AuthorizationCache for transparent persistence.
|
|
670
|
+
*/
|
|
671
|
+
const isBrowser = typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
|
672
|
+
/**
|
|
673
|
+
* Serializes and stores a value under the given key.
|
|
674
|
+
* No-op in SSR/Node environments.
|
|
675
|
+
*/
|
|
676
|
+
function save(key, data) {
|
|
677
|
+
if (!isBrowser)
|
|
678
|
+
return;
|
|
679
|
+
try {
|
|
680
|
+
window.localStorage.setItem(key, JSON.stringify(data));
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
// Quota exceeded or security restriction — silently degrade
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Loads and deserializes a value from the given key.
|
|
688
|
+
* Returns null if the key is missing, the value cannot be parsed, or in SSR.
|
|
689
|
+
*/
|
|
690
|
+
function load(key) {
|
|
691
|
+
if (!isBrowser)
|
|
692
|
+
return null;
|
|
693
|
+
try {
|
|
694
|
+
const raw = window.localStorage.getItem(key);
|
|
695
|
+
if (raw === null)
|
|
696
|
+
return null;
|
|
697
|
+
return JSON.parse(raw);
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Removes a value from localStorage.
|
|
705
|
+
* No-op in SSR/Node environments.
|
|
706
|
+
*/
|
|
707
|
+
function remove(key) {
|
|
708
|
+
if (!isBrowser)
|
|
709
|
+
return;
|
|
710
|
+
try {
|
|
711
|
+
window.localStorage.removeItem(key);
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
// Ignore
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Returns all localStorage keys that start with the given prefix.
|
|
719
|
+
* Returns an empty array in SSR/Node environments.
|
|
720
|
+
*/
|
|
721
|
+
function listKeys(prefix) {
|
|
722
|
+
if (!isBrowser)
|
|
723
|
+
return [];
|
|
724
|
+
const keys = [];
|
|
725
|
+
try {
|
|
726
|
+
for (let i = 0; i < window.localStorage.length; i++) {
|
|
727
|
+
const key = window.localStorage.key(i);
|
|
728
|
+
if (key !== null && key.startsWith(prefix)) {
|
|
729
|
+
keys.push(key);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
// Ignore
|
|
735
|
+
}
|
|
736
|
+
return keys;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Registers a listener for localStorage changes from OTHER tabs.
|
|
740
|
+
* Filters events by key prefix and calls the callback with the full key and new value.
|
|
741
|
+
*
|
|
742
|
+
* Returns an unsubscribe function that removes the listener.
|
|
743
|
+
* Returns a no-op in SSR/Node environments.
|
|
744
|
+
*/
|
|
745
|
+
function onStorageChange(prefix, callback) {
|
|
746
|
+
if (!isBrowser)
|
|
747
|
+
return () => { };
|
|
748
|
+
const handler = (event) => {
|
|
749
|
+
if (event.key !== null && event.key.startsWith(prefix)) {
|
|
750
|
+
callback(event.key, event.newValue);
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
window.addEventListener('storage', handler);
|
|
754
|
+
return () => window.removeEventListener('storage', handler);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Authorization cache with TTL-based expiry, pseudo-LRU eviction, and localStorage persistence.
|
|
644
759
|
* Cache key format: {licenseId}.{serviceKeyId}
|
|
645
760
|
* One cache instance per LicensingClient — no singletons.
|
|
761
|
+
*
|
|
762
|
+
* When a storagePrefix is provided, entries are persisted to localStorage and restored
|
|
763
|
+
* on construction, surviving page refreshes. Cross-tab sync is available via onSync().
|
|
646
764
|
*/
|
|
647
765
|
class AuthorizationCache {
|
|
648
766
|
/**
|
|
649
767
|
* Creates a new authorization cache.
|
|
650
768
|
* @param maxSize The maximum number of entries to store before evicting the oldest. Defaults to 100.
|
|
769
|
+
* @param storagePrefix Optional localStorage key prefix for persisting entries across page refreshes.
|
|
651
770
|
*/
|
|
652
|
-
constructor(maxSize = 100) {
|
|
771
|
+
constructor(maxSize = 100, storagePrefix) {
|
|
653
772
|
this.cache = new Map();
|
|
773
|
+
this.unsubscribe = null;
|
|
654
774
|
this.maxSize = maxSize;
|
|
775
|
+
this.storagePrefix = storagePrefix ?? null;
|
|
776
|
+
// Restore persisted entries, filtering out expired ones
|
|
777
|
+
if (this.storagePrefix) {
|
|
778
|
+
const keys = listKeys(this.storagePrefix);
|
|
779
|
+
for (const fullKey of keys) {
|
|
780
|
+
if (this.cache.size >= this.maxSize)
|
|
781
|
+
break;
|
|
782
|
+
const stored = load(fullKey);
|
|
783
|
+
if (!stored)
|
|
784
|
+
continue;
|
|
785
|
+
if (Date.now() >= stored.expiresAt) {
|
|
786
|
+
remove(fullKey);
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
const cacheKey = fullKey.substring(this.storagePrefix.length);
|
|
790
|
+
const entry = this.deserializeEntry(stored);
|
|
791
|
+
if (entry) {
|
|
792
|
+
this.cache.set(cacheKey, entry);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
655
796
|
}
|
|
656
797
|
/**
|
|
657
798
|
* Gets a cached authorization if it exists and has not expired.
|
|
@@ -669,6 +810,9 @@ class AuthorizationCache {
|
|
|
669
810
|
// Lazy TTL — delete expired entries on access
|
|
670
811
|
if (Date.now() >= entry.expiresAt) {
|
|
671
812
|
this.cache.delete(key);
|
|
813
|
+
if (this.storagePrefix) {
|
|
814
|
+
remove(this.storagePrefix + key);
|
|
815
|
+
}
|
|
672
816
|
return null;
|
|
673
817
|
}
|
|
674
818
|
// Move to end of Map for pseudo-LRU freshness
|
|
@@ -699,12 +843,24 @@ class AuthorizationCache {
|
|
|
699
843
|
const oldestKey = this.cache.keys().next().value;
|
|
700
844
|
if (oldestKey !== undefined) {
|
|
701
845
|
this.cache.delete(oldestKey);
|
|
846
|
+
if (this.storagePrefix) {
|
|
847
|
+
remove(this.storagePrefix + oldestKey);
|
|
848
|
+
}
|
|
702
849
|
}
|
|
703
850
|
}
|
|
704
851
|
this.cache.set(key, { authorization, expiresAt });
|
|
852
|
+
if (this.storagePrefix) {
|
|
853
|
+
save(this.storagePrefix + key, this.serializeEntry({ authorization, expiresAt }));
|
|
854
|
+
}
|
|
705
855
|
}
|
|
706
856
|
/** Removes all cached entries */
|
|
707
857
|
clearCache() {
|
|
858
|
+
if (this.storagePrefix) {
|
|
859
|
+
const keys = listKeys(this.storagePrefix);
|
|
860
|
+
for (const fullKey of keys) {
|
|
861
|
+
remove(fullKey);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
708
864
|
this.cache.clear();
|
|
709
865
|
}
|
|
710
866
|
/**
|
|
@@ -716,9 +872,96 @@ class AuthorizationCache {
|
|
|
716
872
|
for (const key of Array.from(this.cache.keys())) {
|
|
717
873
|
if (key.startsWith(prefix)) {
|
|
718
874
|
this.cache.delete(key);
|
|
875
|
+
if (this.storagePrefix) {
|
|
876
|
+
remove(this.storagePrefix + key);
|
|
877
|
+
}
|
|
719
878
|
}
|
|
720
879
|
}
|
|
721
880
|
}
|
|
881
|
+
/**
|
|
882
|
+
* Registers a cross-tab sync listener. When another tab modifies auth cache entries,
|
|
883
|
+
* the in-memory cache is updated and the callback is invoked.
|
|
884
|
+
*
|
|
885
|
+
* @param callback Invoked after in-memory state is updated from another tab.
|
|
886
|
+
* @returns An unsubscribe function to remove the listener.
|
|
887
|
+
*/
|
|
888
|
+
onSync(callback) {
|
|
889
|
+
if (!this.storagePrefix)
|
|
890
|
+
return () => { };
|
|
891
|
+
this.unsubscribe = onStorageChange(this.storagePrefix, (fullKey, newValue) => {
|
|
892
|
+
const cacheKey = fullKey.substring(this.storagePrefix.length);
|
|
893
|
+
if (newValue === null) {
|
|
894
|
+
// Entry was removed in another tab
|
|
895
|
+
this.cache.delete(cacheKey);
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
try {
|
|
899
|
+
const stored = JSON.parse(newValue);
|
|
900
|
+
if (Date.now() >= stored.expiresAt) {
|
|
901
|
+
this.cache.delete(cacheKey);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
const entry = this.deserializeEntry(stored);
|
|
905
|
+
if (entry) {
|
|
906
|
+
// Respect maxSize — evict oldest if needed
|
|
907
|
+
if (!this.cache.has(cacheKey) && this.cache.size >= this.maxSize) {
|
|
908
|
+
const oldestKey = this.cache.keys().next().value;
|
|
909
|
+
if (oldestKey !== undefined) {
|
|
910
|
+
this.cache.delete(oldestKey);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
this.cache.set(cacheKey, entry);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
catch {
|
|
917
|
+
// Ignore malformed data
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
callback();
|
|
921
|
+
});
|
|
922
|
+
return this.unsubscribe;
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Removes the cross-tab sync listener if active.
|
|
926
|
+
*/
|
|
927
|
+
dispose() {
|
|
928
|
+
if (this.unsubscribe) {
|
|
929
|
+
this.unsubscribe();
|
|
930
|
+
this.unsubscribe = null;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Serializes a cache entry for localStorage storage.
|
|
935
|
+
* Converts Date objects to epoch numbers for safe JSON round-tripping.
|
|
936
|
+
*/
|
|
937
|
+
serializeEntry(entry) {
|
|
938
|
+
const auth = entry.authorization;
|
|
939
|
+
return {
|
|
940
|
+
authorization: {
|
|
941
|
+
...auth,
|
|
942
|
+
issuedAt: auth.issuedAt instanceof Date ? auth.issuedAt.getTime() : auth.issuedAt,
|
|
943
|
+
expiresAt: auth.expiresAt instanceof Date ? auth.expiresAt.getTime() : auth.expiresAt
|
|
944
|
+
},
|
|
945
|
+
expiresAt: entry.expiresAt
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Deserializes a cache entry from localStorage, reconstructing Date objects.
|
|
950
|
+
*/
|
|
951
|
+
deserializeEntry(stored) {
|
|
952
|
+
try {
|
|
953
|
+
const raw = stored.authorization;
|
|
954
|
+
const authorization = {
|
|
955
|
+
...raw,
|
|
956
|
+
issuedAt: raw.issuedAt ? new Date(raw.issuedAt) : new Date(),
|
|
957
|
+
expiresAt: raw.expiresAt ? new Date(raw.expiresAt) : undefined
|
|
958
|
+
};
|
|
959
|
+
return { authorization, expiresAt: stored.expiresAt };
|
|
960
|
+
}
|
|
961
|
+
catch {
|
|
962
|
+
return null;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
722
965
|
}
|
|
723
966
|
|
|
724
967
|
/**
|
|
@@ -727,15 +970,31 @@ class AuthorizationCache {
|
|
|
727
970
|
*
|
|
728
971
|
* Entries older than sampleSeconds are pruned on every getConsumption() call.
|
|
729
972
|
* Local state is additive/conservative — may over-count (safer than under-counting).
|
|
973
|
+
*
|
|
974
|
+
* When a storageKey is provided, metrics are persisted to localStorage and restored
|
|
975
|
+
* on construction, surviving page refreshes. Cross-tab sync is available via onSync().
|
|
730
976
|
*/
|
|
731
977
|
class RateLimitFeatureState {
|
|
732
978
|
/**
|
|
733
979
|
* Creates a new rate limit feature state tracker.
|
|
734
980
|
* @param sampleSeconds The sliding window duration in seconds for rate limit evaluation.
|
|
981
|
+
* @param storageKey Optional localStorage key for persisting metrics across page refreshes.
|
|
735
982
|
*/
|
|
736
|
-
constructor(sampleSeconds) {
|
|
983
|
+
constructor(sampleSeconds, storageKey) {
|
|
737
984
|
this.metrics = [];
|
|
985
|
+
this.unsubscribe = null;
|
|
738
986
|
this.sampleSeconds = sampleSeconds;
|
|
987
|
+
this.storageKey = storageKey ?? null;
|
|
988
|
+
// Restore persisted metrics, pruning stale entries
|
|
989
|
+
if (this.storageKey) {
|
|
990
|
+
const stored = load(this.storageKey);
|
|
991
|
+
if (stored && Array.isArray(stored)) {
|
|
992
|
+
const cutoff = Date.now() - (this.sampleSeconds * 1000);
|
|
993
|
+
this.metrics = stored.filter(m => m.timestamp >= cutoff);
|
|
994
|
+
// Persist the pruned list back
|
|
995
|
+
save(this.storageKey, this.metrics);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
739
998
|
}
|
|
740
999
|
/**
|
|
741
1000
|
* Records a consumption event with the current timestamp.
|
|
@@ -743,6 +1002,9 @@ class RateLimitFeatureState {
|
|
|
743
1002
|
*/
|
|
744
1003
|
consumeRate(amount) {
|
|
745
1004
|
this.metrics.push({ timestamp: Date.now(), amount });
|
|
1005
|
+
if (this.storageKey) {
|
|
1006
|
+
save(this.storageKey, this.metrics);
|
|
1007
|
+
}
|
|
746
1008
|
}
|
|
747
1009
|
/**
|
|
748
1010
|
* Returns total consumption within the sample window, pruning stale entries.
|
|
@@ -751,8 +1013,50 @@ class RateLimitFeatureState {
|
|
|
751
1013
|
getConsumption() {
|
|
752
1014
|
const cutoff = Date.now() - (this.sampleSeconds * 1000);
|
|
753
1015
|
this.metrics = this.metrics.filter(m => m.timestamp >= cutoff);
|
|
1016
|
+
if (this.storageKey) {
|
|
1017
|
+
save(this.storageKey, this.metrics);
|
|
1018
|
+
}
|
|
754
1019
|
return this.metrics.reduce((sum, m) => sum + m.amount, 0);
|
|
755
1020
|
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Registers a cross-tab sync listener. When another tab modifies the same storageKey,
|
|
1023
|
+
* the in-memory metrics are replaced with the new values and the callback is invoked.
|
|
1024
|
+
*
|
|
1025
|
+
* @param callback Invoked after in-memory state is updated from another tab.
|
|
1026
|
+
* @returns An unsubscribe function to remove the listener.
|
|
1027
|
+
*/
|
|
1028
|
+
onSync(callback) {
|
|
1029
|
+
if (!this.storageKey)
|
|
1030
|
+
return () => { };
|
|
1031
|
+
this.unsubscribe = onStorageChange(this.storageKey, (_key, newValue) => {
|
|
1032
|
+
if (newValue === null) {
|
|
1033
|
+
this.metrics = [];
|
|
1034
|
+
}
|
|
1035
|
+
else {
|
|
1036
|
+
try {
|
|
1037
|
+
const parsed = JSON.parse(newValue);
|
|
1038
|
+
if (Array.isArray(parsed)) {
|
|
1039
|
+
const cutoff = Date.now() - (this.sampleSeconds * 1000);
|
|
1040
|
+
this.metrics = parsed.filter(m => m.timestamp >= cutoff);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
catch {
|
|
1044
|
+
// Ignore malformed data
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
callback();
|
|
1048
|
+
});
|
|
1049
|
+
return this.unsubscribe;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Removes the cross-tab sync listener if active.
|
|
1053
|
+
*/
|
|
1054
|
+
dispose() {
|
|
1055
|
+
if (this.unsubscribe) {
|
|
1056
|
+
this.unsubscribe();
|
|
1057
|
+
this.unsubscribe = null;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
756
1060
|
}
|
|
757
1061
|
|
|
758
1062
|
/**
|
|
@@ -784,6 +1088,7 @@ class LicensingClient {
|
|
|
784
1088
|
constructor(config) {
|
|
785
1089
|
this.featureStates = new Map();
|
|
786
1090
|
this.featureMetadata = new Map(); // stateKey -> sampleSeconds
|
|
1091
|
+
this.disposables = [];
|
|
787
1092
|
this.contextResolved = false;
|
|
788
1093
|
this.contextResolutionPromise = null;
|
|
789
1094
|
// Decode credential if provided
|
|
@@ -804,11 +1109,64 @@ class LicensingClient {
|
|
|
804
1109
|
this.signatureValidator = new SignatureValidator(cryptoConfig);
|
|
805
1110
|
// Determine if signature validation should be performed
|
|
806
1111
|
this.validateSignatures = config.validateSignatures ?? (cryptoConfig !== null);
|
|
807
|
-
// Initialize authorization cache (enabled by default)
|
|
1112
|
+
// Initialize authorization cache (enabled by default) with localStorage persistence
|
|
808
1113
|
this.cacheEnabled = config.cacheEnabled !== false;
|
|
809
1114
|
this.authorizationCache = this.cacheEnabled
|
|
810
|
-
? new AuthorizationCache(config.cacheMaxSize ?? 100)
|
|
1115
|
+
? new AuthorizationCache(config.cacheMaxSize ?? 100, LicensingClient.AUTH_STORAGE_PREFIX)
|
|
811
1116
|
: null;
|
|
1117
|
+
// Register cross-tab sync for authorization cache
|
|
1118
|
+
if (this.authorizationCache) {
|
|
1119
|
+
this.disposables.push(this.authorizationCache.onSync(() => { }));
|
|
1120
|
+
}
|
|
1121
|
+
// Restore featureMetadata from localStorage
|
|
1122
|
+
const storedMetadata = load(LicensingClient.METADATA_STORAGE_KEY);
|
|
1123
|
+
if (storedMetadata && typeof storedMetadata === 'object') {
|
|
1124
|
+
for (const [key, value] of Object.entries(storedMetadata)) {
|
|
1125
|
+
if (typeof value === 'number') {
|
|
1126
|
+
this.featureMetadata.set(key, value);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
// Cross-tab sync for featureMetadata
|
|
1131
|
+
this.disposables.push(onStorageChange(LicensingClient.METADATA_STORAGE_KEY, (_key, newValue) => {
|
|
1132
|
+
this.featureMetadata.clear();
|
|
1133
|
+
if (newValue) {
|
|
1134
|
+
try {
|
|
1135
|
+
const parsed = JSON.parse(newValue);
|
|
1136
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
1137
|
+
if (typeof v === 'number') {
|
|
1138
|
+
this.featureMetadata.set(k, v);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
catch {
|
|
1143
|
+
// Ignore malformed data
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}));
|
|
1147
|
+
// Restore persisted featureStates from localStorage
|
|
1148
|
+
this.restoreFeatureStates();
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Restores RateLimitFeatureState instances from localStorage for all known features.
|
|
1152
|
+
*/
|
|
1153
|
+
restoreFeatureStates() {
|
|
1154
|
+
for (const [stateKey, sampleSeconds] of this.featureMetadata.entries()) {
|
|
1155
|
+
const storageKey = LicensingClient.CONSUMPTION_STORAGE_PREFIX + stateKey;
|
|
1156
|
+
const state = new RateLimitFeatureState(sampleSeconds, storageKey);
|
|
1157
|
+
this.featureStates.set(stateKey, state);
|
|
1158
|
+
this.disposables.push(state.onSync(() => { }));
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Persists the featureMetadata map to localStorage.
|
|
1163
|
+
*/
|
|
1164
|
+
persistFeatureMetadata() {
|
|
1165
|
+
const obj = {};
|
|
1166
|
+
for (const [key, value] of this.featureMetadata.entries()) {
|
|
1167
|
+
obj[key] = value;
|
|
1168
|
+
}
|
|
1169
|
+
save(LicensingClient.METADATA_STORAGE_KEY, obj);
|
|
812
1170
|
}
|
|
813
1171
|
/**
|
|
814
1172
|
* Ensures context provider has been resolved before making API calls.
|
|
@@ -961,13 +1319,18 @@ class LicensingClient {
|
|
|
961
1319
|
signatureValidated
|
|
962
1320
|
};
|
|
963
1321
|
// Cache feature sampleSeconds for auto-creating RateLimitFeatureState in performOperation
|
|
1322
|
+
let metadataChanged = false;
|
|
964
1323
|
for (const f of authorization.features) {
|
|
965
1324
|
const rf = f;
|
|
966
1325
|
const ss = rf.sampleSeconds ?? rf.SampleSeconds;
|
|
967
1326
|
if (ss !== undefined && typeof ss === 'number') {
|
|
968
1327
|
this.featureMetadata.set(`${authorization.licenseId}.${f.featureId}`, ss);
|
|
1328
|
+
metadataChanged = true;
|
|
969
1329
|
}
|
|
970
1330
|
}
|
|
1331
|
+
if (metadataChanged) {
|
|
1332
|
+
this.persistFeatureMetadata();
|
|
1333
|
+
}
|
|
971
1334
|
// Store in cache
|
|
972
1335
|
if (this.cacheEnabled && this.authorizationCache) {
|
|
973
1336
|
this.authorizationCache.set(effectiveLicenseId, this.config.serviceKeyId ?? '', authorization);
|
|
@@ -1070,10 +1433,121 @@ class LicensingClient {
|
|
|
1070
1433
|
* @param authorization The license authorization to check against.
|
|
1071
1434
|
* @returns True if the assertion is satisfied, false otherwise.
|
|
1072
1435
|
*/
|
|
1436
|
+
/**
|
|
1437
|
+
* Phase 11 D-01 (LCYC-02): Composes an in-memory LicenseStateView from the current
|
|
1438
|
+
* authorization. Returns null when no license is configured or the authorization cannot
|
|
1439
|
+
* be retrieved.
|
|
1440
|
+
*/
|
|
1441
|
+
async getState() {
|
|
1442
|
+
let auth;
|
|
1443
|
+
try {
|
|
1444
|
+
auth = await this.getAuthorization();
|
|
1445
|
+
}
|
|
1446
|
+
catch {
|
|
1447
|
+
return null;
|
|
1448
|
+
}
|
|
1449
|
+
const features = auth.features.map((f) => {
|
|
1450
|
+
const sf = f;
|
|
1451
|
+
const featureKey = String(sf.featureId ?? '');
|
|
1452
|
+
const displayName = String(sf.displayName ?? sf.featureName ?? featureKey);
|
|
1453
|
+
return { featureKey, displayName };
|
|
1454
|
+
});
|
|
1455
|
+
const consumption = [];
|
|
1456
|
+
for (const f of auth.features) {
|
|
1457
|
+
const sf = f;
|
|
1458
|
+
const featureKey = String(sf.featureId ?? '');
|
|
1459
|
+
const rateLimit = sf.rateLimit;
|
|
1460
|
+
const sampleSeconds = sf.sampleSeconds;
|
|
1461
|
+
if (rateLimit === undefined || sampleSeconds === undefined)
|
|
1462
|
+
continue;
|
|
1463
|
+
const licenseId = auth.licenseId;
|
|
1464
|
+
const stateKey = `${licenseId}.${featureKey}`;
|
|
1465
|
+
const localState = this.featureStates.get(stateKey);
|
|
1466
|
+
const currentUsage = localState?.getConsumption() ?? sf.currentConsumption ?? 0;
|
|
1467
|
+
const percentage = rateLimit > 0 ? (currentUsage / rateLimit) * 100 : 0;
|
|
1468
|
+
consumption.push({
|
|
1469
|
+
featureKey,
|
|
1470
|
+
currentUsage,
|
|
1471
|
+
limit: rateLimit,
|
|
1472
|
+
windowSeconds: sampleSeconds,
|
|
1473
|
+
percentage
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
return {
|
|
1477
|
+
licenseId: auth.licenseId,
|
|
1478
|
+
features,
|
|
1479
|
+
consumption,
|
|
1480
|
+
expiry: auth.expiresAt ?? null
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Phase 11 D-11 (LCYC-06): Invalidates all cached state and rebuilds internal credential
|
|
1485
|
+
* configuration from a new encodedCredential — without requiring a React component remount.
|
|
1486
|
+
*
|
|
1487
|
+
* Call this from a useEffect keyed on the credential string; the `key={credential}` idiom
|
|
1488
|
+
* is a deprecated fallback.
|
|
1489
|
+
*
|
|
1490
|
+
* @param newConfig A LicensingConfig carrying the updated encodedCredential (all other
|
|
1491
|
+
* fields are merged; explicit fields on newConfig take precedence).
|
|
1492
|
+
*/
|
|
1493
|
+
async refresh(newConfig) {
|
|
1494
|
+
// Decode new credential
|
|
1495
|
+
const newCredential = newConfig.encodedCredential
|
|
1496
|
+
? decodeCredential(newConfig.encodedCredential)
|
|
1497
|
+
: null;
|
|
1498
|
+
this.credential = newCredential;
|
|
1499
|
+
// Rebuild merged config
|
|
1500
|
+
this.config = {
|
|
1501
|
+
...newConfig,
|
|
1502
|
+
timeout: newConfig.timeout || 30000,
|
|
1503
|
+
apiKey: newConfig.apiKey || newCredential?.apiAccessKey,
|
|
1504
|
+
licenseId: newConfig.licenseId || newCredential?.licenseId,
|
|
1505
|
+
serviceKeyId: newConfig.serviceKeyId || newCredential?.serviceKeyId,
|
|
1506
|
+
serviceKeyPublicMember: newConfig.serviceKeyPublicMember || newCredential?.serviceKeyPublicMember
|
|
1507
|
+
};
|
|
1508
|
+
// Rebuild crypto + signature validator
|
|
1509
|
+
const cryptoConfig = this.buildCryptoConfig();
|
|
1510
|
+
this.signatureValidator = new SignatureValidator(cryptoConfig);
|
|
1511
|
+
this.validateSignatures = newConfig.validateSignatures ?? (cryptoConfig !== null);
|
|
1512
|
+
// Invalidate authorization cache
|
|
1513
|
+
this.authorizationCache?.clearCache();
|
|
1514
|
+
// Dispose + clear per-feature rate-limit state
|
|
1515
|
+
for (const state of this.featureStates.values()) {
|
|
1516
|
+
state.dispose();
|
|
1517
|
+
}
|
|
1518
|
+
this.featureStates.clear();
|
|
1519
|
+
this.featureMetadata.clear();
|
|
1520
|
+
// Remove persisted state from localStorage
|
|
1521
|
+
remove(LicensingClient.METADATA_STORAGE_KEY);
|
|
1522
|
+
// AUTH_STORAGE_PREFIX keys are managed by the AuthorizationCache; clearCache() handles them.
|
|
1523
|
+
// CONSUMPTION keys are keyed per stateKey — clear all known prefixed entries.
|
|
1524
|
+
if (typeof localStorage !== 'undefined') {
|
|
1525
|
+
const consumptionKeys = [];
|
|
1526
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
1527
|
+
const k = localStorage.key(i);
|
|
1528
|
+
if (k && k.startsWith(LicensingClient.CONSUMPTION_STORAGE_PREFIX)) {
|
|
1529
|
+
consumptionKeys.push(k);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
for (const k of consumptionKeys) {
|
|
1533
|
+
localStorage.removeItem(k);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
// Reset context resolution so ensureContextResolved() re-runs with new config
|
|
1537
|
+
this.contextResolved = false;
|
|
1538
|
+
this.contextResolutionPromise = null;
|
|
1539
|
+
}
|
|
1073
1540
|
assertLicense(assertion, authorization) {
|
|
1074
1541
|
// Auto-inject feature states for RateLimitAssertion
|
|
1075
1542
|
if (assertion instanceof RateLimitAssertion) {
|
|
1076
|
-
|
|
1543
|
+
const satisfied = assertion.isSatisfied(authorization, this.featureStates);
|
|
1544
|
+
if (!satisfied) {
|
|
1545
|
+
// Phase 11 D-14 / D-16: emit warning unconditionally even when consumer catches
|
|
1546
|
+
const msg = `Rate limit exceeded for feature '${assertion.featureKey}'.`;
|
|
1547
|
+
console.warn(`[Sidub.Licensing] ${msg}`);
|
|
1548
|
+
throw new RateLimitError(assertion.featureKey, msg);
|
|
1549
|
+
}
|
|
1550
|
+
return true;
|
|
1077
1551
|
}
|
|
1078
1552
|
return assertion.isSatisfied(authorization);
|
|
1079
1553
|
}
|
|
@@ -1090,6 +1564,21 @@ class LicensingClient {
|
|
|
1090
1564
|
invalidateCache(licenseId) {
|
|
1091
1565
|
this.authorizationCache?.invalidate(licenseId);
|
|
1092
1566
|
}
|
|
1567
|
+
/**
|
|
1568
|
+
* Releases all resources held by this client instance:
|
|
1569
|
+
* removes cross-tab StorageEvent listeners and disposes cache/state objects.
|
|
1570
|
+
* Call this when the client is no longer needed (e.g., on component unmount).
|
|
1571
|
+
*/
|
|
1572
|
+
dispose() {
|
|
1573
|
+
for (const unsub of this.disposables) {
|
|
1574
|
+
unsub();
|
|
1575
|
+
}
|
|
1576
|
+
this.disposables.length = 0;
|
|
1577
|
+
for (const state of this.featureStates.values()) {
|
|
1578
|
+
state.dispose();
|
|
1579
|
+
}
|
|
1580
|
+
this.authorizationCache?.dispose();
|
|
1581
|
+
}
|
|
1093
1582
|
/**
|
|
1094
1583
|
* Gets the local feature state for a license+feature combination.
|
|
1095
1584
|
* @param licenseId The license identifier.
|
|
@@ -1163,8 +1652,10 @@ class LicensingClient {
|
|
|
1163
1652
|
let state = this.featureStates.get(stateKey);
|
|
1164
1653
|
if (!state) {
|
|
1165
1654
|
const sampleSeconds = this.featureMetadata.get(stateKey) ?? 3600;
|
|
1166
|
-
|
|
1655
|
+
const storageKey = LicensingClient.CONSUMPTION_STORAGE_PREFIX + stateKey;
|
|
1656
|
+
state = new RateLimitFeatureState(sampleSeconds, storageKey);
|
|
1167
1657
|
this.featureStates.set(stateKey, state);
|
|
1658
|
+
this.disposables.push(state.onSync(() => { }));
|
|
1168
1659
|
}
|
|
1169
1660
|
state.consumeRate(operation.quantity ?? 1);
|
|
1170
1661
|
}
|
|
@@ -1439,6 +1930,9 @@ class LicensingClient {
|
|
|
1439
1930
|
return { ...this.config };
|
|
1440
1931
|
}
|
|
1441
1932
|
}
|
|
1933
|
+
LicensingClient.METADATA_STORAGE_KEY = 'sidub.licensing.metadata';
|
|
1934
|
+
LicensingClient.CONSUMPTION_STORAGE_PREFIX = 'sidub.licensing.consumption.';
|
|
1935
|
+
LicensingClient.AUTH_STORAGE_PREFIX = 'sidub.licensing.auth.';
|
|
1442
1936
|
|
|
1443
1937
|
/**
|
|
1444
1938
|
* Assertion that checks if a feature with a specific key exists in the authorization.
|
|
@@ -1593,7 +2087,9 @@ const LicensingContext = createContext(undefined);
|
|
|
1593
2087
|
* ```
|
|
1594
2088
|
*/
|
|
1595
2089
|
const LicensingProvider = ({ config, children }) => {
|
|
1596
|
-
//
|
|
2090
|
+
// Phase 11 D-11: useMemo no longer depends on encodedCredential.
|
|
2091
|
+
// Client is created once from structural config; credential changes are handled
|
|
2092
|
+
// by the useEffect below via client.refresh() — no remount required.
|
|
1597
2093
|
const value = useMemo(() => {
|
|
1598
2094
|
const client = new LicensingClient(config);
|
|
1599
2095
|
return {
|
|
@@ -1606,12 +2102,21 @@ const LicensingProvider = ({ config, children }) => {
|
|
|
1606
2102
|
config.consumptionServiceUri,
|
|
1607
2103
|
config.apiKey,
|
|
1608
2104
|
config.timeout,
|
|
1609
|
-
config.encodedCredential,
|
|
1610
2105
|
config.serviceKeyId,
|
|
1611
2106
|
config.serviceKeyPublicMember,
|
|
1612
2107
|
config.licenseId,
|
|
1613
2108
|
config.validateSignatures
|
|
2109
|
+
// NOTE: encodedCredential intentionally omitted — changes are handled via refresh() below
|
|
1614
2110
|
]);
|
|
2111
|
+
// Phase 11 D-11: when the encoded credential changes, invalidate client state and
|
|
2112
|
+
// rebuild credential config without remounting the component tree.
|
|
2113
|
+
// The `key={credential}` remount idiom is a deprecated fallback.
|
|
2114
|
+
useEffect(() => {
|
|
2115
|
+
if (config.encodedCredential !== undefined) {
|
|
2116
|
+
void value.client.refresh(config);
|
|
2117
|
+
}
|
|
2118
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2119
|
+
}, [config.encodedCredential]);
|
|
1615
2120
|
return (React.createElement(LicensingContext.Provider, { value: value }, children));
|
|
1616
2121
|
};
|
|
1617
2122
|
/**
|
|
@@ -1748,5 +2253,5 @@ class ConfigurationContextProvider {
|
|
|
1748
2253
|
}
|
|
1749
2254
|
}
|
|
1750
2255
|
|
|
1751
|
-
export { AuthorizationCache, BillingIntervalUnit, CompositeAssertion, ConfigurationContextProvider, CryptoError, CryptoService, FeatureExistsAssertion, LicenseAssertion, LicenseClassificationType, LicensingClient, LicensingConfigurationException, LicensingError, LicensingProvider, NotAssertion, RateLimitAssertion, RateLimitFeatureState, ServiceAccessAssertion, ServiceAccessLevel, SignatureValidator, decodeCredential, encodeCredential, findFeatureByKey, getFeatureKey, hasFeature, hasValidCryptoConfig, licensingContextFromEncodedString, licensingContextToEncodedString, matchesFeatureKey, normalizeBillingIntervalUnit, normalizeLicenseClassificationType, normalizeServiceAccessLevel, tryDecodeCredential, useLicensingContext, useLicensingContextValue };
|
|
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 };
|
|
1752
2257
|
//# sourceMappingURL=index.esm.js.map
|