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