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