@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.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
- * In-memory authorization cache with TTL-based expiry and pseudo-LRU eviction.
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
- return assertion.isSatisfied(authorization, this.featureStates);
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
- state = new RateLimitFeatureState(sampleSeconds);
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
- // Create client with memoization - recreate only if config changes
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