@sidub-inc/licensing-client 1.5.59 → 1.5.73

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