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