@sidub-inc/licensing-client 1.5.170 → 1.6.0

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.cjs CHANGED
@@ -2,9 +2,36 @@
2
2
 
3
3
  var React = require('react');
4
4
 
5
+ /**
6
+ * Parses a Retry-After header value (delta-seconds or HTTP-date) into
7
+ * milliseconds. Returns undefined when absent or unparseable.
8
+ */
9
+ function parseRetryAfter(headerValue) {
10
+ if (!headerValue)
11
+ return undefined;
12
+ const seconds = Number(headerValue);
13
+ if (!Number.isNaN(seconds) && seconds >= 0) {
14
+ return seconds * 1000;
15
+ }
16
+ const date = Date.parse(headerValue);
17
+ if (!Number.isNaN(date)) {
18
+ return Math.max(0, date - Date.now());
19
+ }
20
+ return undefined;
21
+ }
22
+ function asOptionalString(value) {
23
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
24
+ }
5
25
  /**
6
26
  * Error class for licensing-related errors.
7
27
  * Thrown when API requests fail, authorization is denied, or network errors occur.
28
+ *
29
+ * When the failure came from the licensing service over HTTP, the server's
30
+ * shared ApiError contract {Code, Message, CorrelationId, Details[]} is parsed
31
+ * onto the error (MON-334 SR2): branch on {@link code} for machine handling and
32
+ * quote {@link correlationId} in support requests. Both are undefined only for
33
+ * failures that never reached the server (network errors, timeouts) or when the
34
+ * server did not supply them.
8
35
  */
9
36
  class LicensingError extends Error {
10
37
  /**
@@ -19,6 +46,61 @@ class LicensingError extends Error {
19
46
  this.response = response;
20
47
  this.name = 'LicensingError';
21
48
  }
49
+ /**
50
+ * Builds a LicensingError from a non-OK HTTP response, parsing the server's
51
+ * shared ApiError contract {Code, Message, CorrelationId, Details[]} plus the
52
+ * X-Correlation-Id and Retry-After response headers (MON-334 SR2).
53
+ *
54
+ * The machine code and correlation id are never dropped: when the body is not
55
+ * a parseable ApiError the correlation id still comes from the header, and
56
+ * the raw body text is always preserved on {@link response}. The message
57
+ * prefers the server's human-readable ApiError.Message and falls back to the
58
+ * HTTP status text only when no server message is available.
59
+ *
60
+ * @param operation Human-readable operation name used as the message prefix
61
+ * (e.g. 'Authorization' → 'Authorization failed: ...').
62
+ * @param response The non-OK fetch response (or a structural equivalent).
63
+ * @returns The constructed error, ready to throw.
64
+ */
65
+ static async fromHttpResponse(operation, response) {
66
+ const rawBody = response.text
67
+ ? await response.text().catch(() => 'Unknown error')
68
+ : 'Unknown error';
69
+ let code;
70
+ let serverMessage;
71
+ let bodyCorrelationId;
72
+ let details;
73
+ try {
74
+ const parsed = JSON.parse(rawBody);
75
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
76
+ code = asOptionalString(parsed.Code ?? parsed.code);
77
+ serverMessage = asOptionalString(parsed.Message ?? parsed.message);
78
+ bodyCorrelationId = asOptionalString(parsed.CorrelationId ?? parsed.correlationId);
79
+ const rawDetails = parsed.Details ?? parsed.details;
80
+ if (Array.isArray(rawDetails) && rawDetails.length > 0) {
81
+ details = rawDetails.map((d) => {
82
+ const detail = (d ?? {});
83
+ return {
84
+ code: asOptionalString(detail.Code ?? detail.code),
85
+ message: asOptionalString(detail.Message ?? detail.message),
86
+ field: asOptionalString(detail.Field ?? detail.field),
87
+ targetId: asOptionalString(detail.TargetId ?? detail.targetId)
88
+ };
89
+ });
90
+ }
91
+ }
92
+ }
93
+ catch {
94
+ // Body is not JSON — the raw text is still preserved on `response`.
95
+ }
96
+ const headerCorrelationId = asOptionalString(response.headers?.get?.('X-Correlation-Id') ?? undefined);
97
+ const error = new LicensingError(`${operation} failed: ${serverMessage ?? response.statusText ?? 'HTTP error'}`, response.status, rawBody);
98
+ error.code = code;
99
+ error.correlationId = bodyCorrelationId ?? headerCorrelationId;
100
+ error.details = details;
101
+ error.retryAfterMs = parseRetryAfter(response.headers?.get?.('Retry-After'));
102
+ return error;
103
+ }
22
104
  }
23
105
 
24
106
  /**
@@ -127,6 +209,16 @@ const CURRENT_VERSION = 1;
127
209
  * Encodes a licensing credential to a portable string.
128
210
  * This matches the .NET LicensingCredential.ToEncodedString() format.
129
211
  *
212
+ * SECURITY (MON-334 react-08): the encoded string is base64-OBFUSCATED, not
213
+ * encrypted — it contains the apiAccessKey in recoverable form. Do not store
214
+ * it anywhere page JavaScript can read (localStorage, sessionStorage,
215
+ * non-httpOnly cookies): any XSS on the page reads the credential. For
216
+ * browser apps prefer a backend-held credential with a short-lived token.
217
+ * The SDK itself never writes this value (or the apiAccessKey) to
218
+ * localStorage — it persists only authorization payloads and consumption
219
+ * counters, and the apiAccessKey appears in storage key names only as a
220
+ * non-reversible hash.
221
+ *
130
222
  * @param credential The credential to encode
131
223
  * @returns A portable encoded string prefixed with SIDUB_LIC_
132
224
  */
@@ -221,7 +313,8 @@ function hasValidCryptoConfig(credential) {
221
313
 
222
314
  /**
223
315
  * Gets the normalized feature key from a feature object.
224
- * Handles both client-side (featureId) and server-side (FeatureKey) naming conventions.
316
+ * Handles both the SDK model (featureKey) and raw server (FeatureKey) naming.
317
+ * The legacy `featureId` alias was removed in 2.0 (MON-334 SR4).
225
318
  *
226
319
  * @param feature The feature object to extract the key from
227
320
  * @returns The feature key, or empty string if not found
@@ -230,12 +323,12 @@ function getFeatureKey(feature) {
230
323
  if (!feature) {
231
324
  return '';
232
325
  }
233
- // Check both camelCase (client) and PascalCase (server) conventions
234
- return feature.featureId || feature.FeatureKey || '';
326
+ // Check both camelCase (SDK model) and PascalCase (raw server) conventions
327
+ return feature.featureKey || feature.FeatureKey || '';
235
328
  }
236
329
  /**
237
330
  * Checks if a feature matches the specified feature key.
238
- * Handles both client-side (featureId) and server-side (FeatureKey) naming conventions.
331
+ * Handles both the SDK model (featureKey) and raw server (FeatureKey) naming.
239
332
  *
240
333
  * @param feature The feature to check
241
334
  * @param featureKey The key to match against
@@ -403,7 +496,7 @@ class RateLimitAssertion extends LicenseAssertion {
403
496
  this.featureKey = featureKeyOrFeature;
404
497
  }
405
498
  else {
406
- this.featureKey = featureKeyOrFeature.featureId;
499
+ this.featureKey = featureKeyOrFeature.featureKey;
407
500
  }
408
501
  }
409
502
  /**
@@ -746,86 +839,17 @@ class SignatureValidator {
746
839
  await this.cryptoService.initialize();
747
840
  this.initialized = true;
748
841
  }
749
- /**
750
- * Validates the signature of a license authorization using a PARSED server response.
751
- *
752
- * The validation process:
753
- * 1. Extracts the signature from __sidub_entitySignature
754
- * 2. Re-serializes the raw response data (excluding signature field)
755
- * 3. Verifies the signature using the configured public key
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
- *
764
- * @param rawResponse The raw server response (before client mapping)
765
- * @param options Validation options
766
- * @returns Validation result
767
- * @throws {CryptoError} If throwOnInvalid is true and validation fails
768
- */
769
- async validateRawResponse(rawResponse, options = {}) {
770
- const { throwOnInvalid = true, required = this.isConfigured } = options;
771
- // If no crypto service configured, skip validation
772
- if (!this.cryptoService) {
773
- if (required) {
774
- const error = 'Signature validation is required but no cryptography configuration was provided.';
775
- if (throwOnInvalid) {
776
- throw new CryptoError(error, 'NOT_INITIALIZED');
777
- }
778
- return { isValid: false, skipped: false, error };
779
- }
780
- return { isValid: true, skipped: true };
781
- }
782
- // Ensure initialized
783
- await this.initialize();
784
- // Check if response has a signature
785
- const signature = rawResponse.__sidub_entitySignature;
786
- if (!signature) {
787
- if (required) {
788
- const error = 'Authorization does not contain a signature.';
789
- if (throwOnInvalid) {
790
- throw new CryptoError(error, 'INVALID_SIGNATURE');
791
- }
792
- return { isValid: false, skipped: false, error };
793
- }
794
- return { isValid: true, skipped: true };
795
- }
796
- try {
797
- // Serialize the raw response data for verification (excluding signature)
798
- const dataToVerify = this.serializeRawForVerification(rawResponse);
799
- const dataBytes = new TextEncoder().encode(dataToVerify);
800
- const isValid = await this.cryptoService.verifySignature(dataBytes.buffer, signature);
801
- if (!isValid) {
802
- const error = 'Authorization signature verification failed. The authorization may have been tampered with.';
803
- if (throwOnInvalid) {
804
- throw new CryptoError(error, 'INVALID_SIGNATURE');
805
- }
806
- return { isValid: false, skipped: false, error };
807
- }
808
- return { isValid: true, skipped: false };
809
- }
810
- catch (error) {
811
- if (error instanceof CryptoError) {
812
- throw error;
813
- }
814
- const message = `Signature validation error: ${error instanceof Error ? error.message : 'Unknown error'}`;
815
- if (throwOnInvalid) {
816
- throw new CryptoError(message, 'VERIFICATION_FAILED');
817
- }
818
- return { isValid: false, skipped: false, error: message };
819
- }
820
- }
821
842
  /**
822
843
  * Validates the signature of a license authorization against the exact raw
823
844
  * response text (MON-205).
824
845
  *
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.
846
+ * This is the ONLY validation entry point. The deprecated
847
+ * `validateRawResponse` which re-serialized a parsed object and was
848
+ * therefore sensitive to property-order and formatting differences, letting
849
+ * genuine authorizations fail verification was removed in 2.0 (MON-334
850
+ * react-10). This method verifies against the original bytes: the
851
+ * `__sidub_entitySignature` member is removed textually and the remaining
852
+ * text is what the server actually signed.
829
853
  *
830
854
  * @param rawText The exact response body text as received from the server.
831
855
  * @param options Validation options (same required/throwOnInvalid semantics).
@@ -881,16 +905,6 @@ class SignatureValidator {
881
905
  return { isValid: false, skipped: false, error: message };
882
906
  }
883
907
  }
884
- /**
885
- * Serializes the raw server response for signature verification.
886
- * Removes the __sidub_entitySignature field and serializes to JSON.
887
- */
888
- serializeRawForVerification(rawResponse) {
889
- // Create a copy without the signature field
890
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
891
- const { __sidub_entitySignature, ...dataWithoutSignature } = rawResponse;
892
- return JSON.stringify(dataWithoutSignature);
893
- }
894
908
  }
895
909
 
896
910
  /**
@@ -1028,13 +1042,18 @@ class AuthorizationCache {
1028
1042
  * @param storagePrefix Optional localStorage key prefix for persisting entries across page refreshes.
1029
1043
  * @param graceMs Stale-serve grace window in milliseconds beyond the freshness deadline.
1030
1044
  * Defaults to 24h; capped at 7 days.
1045
+ * @param freshTtlMs Upper bound in milliseconds on how long an entry stays fresh
1046
+ * before revalidation (MON-334 react-09). Defaults to 24h (the historical cap);
1047
+ * lower values tighten the revocation window at the cost of more server calls.
1048
+ * Capped at 24h; 0 means every read revalidates.
1031
1049
  */
1032
- constructor(maxSize = 100, storagePrefix, graceMs) {
1050
+ constructor(maxSize = 100, storagePrefix, graceMs, freshTtlMs) {
1033
1051
  this.cache = new Map();
1034
1052
  this.unsubscribe = null;
1035
1053
  this.maxSize = maxSize;
1036
1054
  this.storagePrefix = storagePrefix ?? null;
1037
1055
  this.graceMs = Math.min(Math.max(graceMs ?? DEFAULT_GRACE_MS, 0), MAX_GRACE_MS);
1056
+ this.freshTtlMs = Math.min(Math.max(freshTtlMs ?? MAX_FRESH_TTL_MS, 0), MAX_FRESH_TTL_MS);
1038
1057
  // Restore persisted entries, dropping ones past their stale window
1039
1058
  if (this.storagePrefix) {
1040
1059
  const keys = listKeys(this.storagePrefix);
@@ -1134,10 +1153,16 @@ class AuthorizationCache {
1134
1153
  * Stores an authorization in the cache.
1135
1154
  * Evicts the oldest entry if max size is reached.
1136
1155
  *
1137
- * Freshness is min(license expiry, now + 24h) — default 1h when the
1156
+ * Freshness is min(license expiry, now + freshTtlMs) — default 1h when the
1138
1157
  * authorization has no expiry. The stale window extends freshness by graceMs
1139
1158
  * but never past the license's own expiry.
1140
1159
  *
1160
+ * Revocation trade-off (MON-334 react-09): a revoked/denied license keeps
1161
+ * authorizing from cache until its freshness deadline — up to freshTtlMs
1162
+ * (default 24h) — plus, on transient outages only, the stale-serve grace.
1163
+ * Tighten freshTtlMs to shrink that window; denials (401/403/404) are never
1164
+ * stale-served.
1165
+ *
1141
1166
  * @param licenseId The license identifier.
1142
1167
  * @param serviceKeyId The service key identifier.
1143
1168
  * @param authorization The license authorization to cache.
@@ -1147,7 +1172,8 @@ class AuthorizationCache {
1147
1172
  const key = this.buildKey(licenseId, serviceKeyId, apiKey);
1148
1173
  const now = Date.now();
1149
1174
  const hardExpiry = authorization.expiresAt ? authorization.expiresAt.getTime() : undefined;
1150
- const expiresAt = Math.min(hardExpiry ?? (now + DEFAULT_TTL_MS), now + MAX_FRESH_TTL_MS);
1175
+ const noExpiryTtl = Math.min(DEFAULT_TTL_MS, this.freshTtlMs);
1176
+ const expiresAt = Math.min(hardExpiry ?? (now + noExpiryTtl), now + this.freshTtlMs);
1151
1177
  const staleUntil = Math.min(expiresAt + this.graceMs, hardExpiry ?? Number.MAX_SAFE_INTEGER);
1152
1178
  // If key already exists, delete first (so re-insert goes to end)
1153
1179
  if (this.cache.has(key)) {
@@ -1414,6 +1440,8 @@ class LicensingClient {
1414
1440
  this.disposables = [];
1415
1441
  this.contextResolved = false;
1416
1442
  this.contextResolutionPromise = null;
1443
+ // MON-334 react-03: the unsigned-mode warning fires at most once per client.
1444
+ this.signaturePostureWarned = false;
1417
1445
  // MON-203: single-flight authorization fetches keyed on licenseId+serviceKeyId+apiKey.
1418
1446
  this.inFlightAuthorizations = new Map();
1419
1447
  // Decode credential if provided
@@ -1437,7 +1465,7 @@ class LicensingClient {
1437
1465
  // Initialize authorization cache (enabled by default) with localStorage persistence
1438
1466
  this.cacheEnabled = config.cacheEnabled !== false;
1439
1467
  this.authorizationCache = this.cacheEnabled
1440
- ? new AuthorizationCache(config.cacheMaxSize ?? 100, LicensingClient.AUTH_STORAGE_PREFIX, config.staleGraceMs)
1468
+ ? new AuthorizationCache(config.cacheMaxSize ?? 100, LicensingClient.AUTH_STORAGE_PREFIX, config.staleGraceMs, config.cacheFreshTtlMs)
1441
1469
  : null;
1442
1470
  // Register cross-tab sync for authorization cache
1443
1471
  if (this.authorizationCache) {
@@ -1550,6 +1578,25 @@ class LicensingClient {
1550
1578
  throw error;
1551
1579
  }
1552
1580
  }
1581
+ /**
1582
+ * Applies the gateway credential headers to an outgoing request. Mirrors the
1583
+ * .NET SDK's GatewayAuthHeaders/GatewayAuthHeaderNames exactly: the ApiAccessKey
1584
+ * is sent as the branded `dub-apiKey` header (the gateway's configured
1585
+ * subscription-key name) and additionally as the platform-default
1586
+ * `Ocp-Apim-Subscription-Key` header, so the SDK keeps authenticating against a
1587
+ * gateway provisioned with the Azure default name (the MON-42 → MON-52
1588
+ * regression class). The gateway strips the redundant copy before forwarding.
1589
+ *
1590
+ * Header-only by design (MON-66 parity, MON-334 SR3): the key is never placed
1591
+ * in the URL query string, where it would leak into browser history, Referer
1592
+ * headers on redirects, and CDN/proxy/gateway access logs.
1593
+ */
1594
+ static applyAuthHeaders(headers, apiKey) {
1595
+ if (!apiKey)
1596
+ return;
1597
+ headers['dub-apiKey'] = apiKey;
1598
+ headers['Ocp-Apim-Subscription-Key'] = apiKey;
1599
+ }
1553
1600
  /**
1554
1601
  * Builds the cryptography configuration from the resolved config values
1555
1602
  */
@@ -1569,6 +1616,38 @@ class LicensingClient {
1569
1616
  get isSignatureValidationEnabled() {
1570
1617
  return this.validateSignatures && this.signatureValidator.isConfigured;
1571
1618
  }
1619
+ /**
1620
+ * Makes the signature-validation posture explicit (MON-334 react-03).
1621
+ * Runs after context resolution, before any authorization is accepted:
1622
+ *
1623
+ * - `validateSignatures: true` with no verification key is a contradiction —
1624
+ * the integrator demanded validation that cannot happen. Previously this
1625
+ * silently skipped; it now throws {@link LicensingConfigurationException}.
1626
+ * - Validation silently OFF (no keys configured, `validateSignatures` unset)
1627
+ * emits one prominent console warning per client. Setting
1628
+ * `validateSignatures: false` explicitly acknowledges unsigned mode and
1629
+ * suppresses the warning.
1630
+ *
1631
+ * Check {@link isSignatureValidationEnabled} at runtime to observe the
1632
+ * effective posture.
1633
+ */
1634
+ assertSignaturePosture() {
1635
+ if (this.validateSignatures && !this.signatureValidator.isConfigured) {
1636
+ throw new LicensingConfigurationException('Signature validation was requested (validateSignatures: true) but no verification key is configured. ' +
1637
+ 'Provide serviceKeyId and serviceKeyPublicMember (or an encodedCredential that carries them), ' +
1638
+ 'or set validateSignatures: false explicitly to accept unsigned authorizations.');
1639
+ }
1640
+ if (!this.validateSignatures &&
1641
+ this.config.validateSignatures === undefined &&
1642
+ !this.signaturePostureWarned) {
1643
+ this.signaturePostureWarned = true;
1644
+ console.warn('[Sidub.Licensing] Signature validation is DISABLED: no serviceKeyId/serviceKeyPublicMember is configured, ' +
1645
+ 'so license authorizations are accepted without cryptographic verification. ' +
1646
+ 'Configure an encodedCredential (which carries the verification key) to enable validation, ' +
1647
+ 'or set validateSignatures: false explicitly to acknowledge unsigned mode and silence this warning. ' +
1648
+ 'Inspect client.isSignatureValidationEnabled to observe the effective posture.');
1649
+ }
1650
+ }
1572
1651
  /**
1573
1652
  * Gets the configured license ID (from credential or explicit config)
1574
1653
  */
@@ -1597,6 +1676,9 @@ class LicensingClient {
1597
1676
  */
1598
1677
  async getAuthorization(licenseId, apiKey) {
1599
1678
  await this.ensureContextResolved();
1679
+ // react-03: surface the signature posture before any authorization —
1680
+ // cached or fetched — is accepted.
1681
+ this.assertSignaturePosture();
1600
1682
  // Use provided licenseId or fall back to configured value
1601
1683
  const effectiveLicenseId = licenseId || this.config.licenseId;
1602
1684
  if (!effectiveLicenseId) {
@@ -1657,22 +1739,6 @@ class LicensingClient {
1657
1739
  return true;
1658
1740
  return status === 408 || status === 429 || status >= 500;
1659
1741
  }
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
1742
  /**
1677
1743
  * Retries transient failures with exponential backoff and jitter, honoring
1678
1744
  * Retry-After when the server supplied one. config.timeout is the overall
@@ -1712,22 +1778,17 @@ class LicensingClient {
1712
1778
  const headers = {
1713
1779
  'Content-Type': 'application/json',
1714
1780
  };
1715
- // Add authentication headers if API key is provided
1716
- if (effectiveApiKey) {
1717
- headers['dub-issuerKey'] = effectiveApiKey;
1718
- }
1781
+ // Credential travels header-only (dub-apiKey + Ocp-Apim-Subscription-Key)
1782
+ // never in the URL. The dead dub-issuerKey header was removed in 2.0 (MON-334).
1783
+ LicensingClient.applyAuthHeaders(headers, effectiveApiKey);
1719
1784
  const parameters = {
1720
1785
  RequestId: this.generateRequestId(),
1721
1786
  LicenseId: effectiveLicenseId
1722
1787
  };
1723
- // Add query parameters for API key if provided
1724
- const queryParams = effectiveApiKey
1725
- ? `?subscription-key=${encodeURIComponent(effectiveApiKey)}`
1726
- : '';
1727
1788
  try {
1728
1789
  const controller = new AbortController();
1729
1790
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
1730
- const response = await fetch(`${url}${queryParams}`, {
1791
+ const response = await fetch(url, {
1731
1792
  method: 'POST',
1732
1793
  headers,
1733
1794
  body: JSON.stringify(parameters),
@@ -1735,10 +1796,9 @@ class LicensingClient {
1735
1796
  });
1736
1797
  clearTimeout(timeoutId);
1737
1798
  if (!response.ok) {
1738
- const errorText = await response.text().catch(() => 'Unknown error');
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;
1799
+ // SR2: parse the shared ApiError body + X-Correlation-Id header —
1800
+ // the machine code and correlation id must never be dropped.
1801
+ throw await LicensingError.fromHttpResponse('Authorization', response);
1742
1802
  }
1743
1803
  // MON-205: capture the exact raw bytes; parse only after validation.
1744
1804
  const rawText = await response.text();
@@ -1774,7 +1834,7 @@ class LicensingClient {
1774
1834
  const rf = f;
1775
1835
  const ss = rf.sampleSeconds ?? rf.SampleSeconds;
1776
1836
  if (ss !== undefined && typeof ss === 'number') {
1777
- this.featureMetadata.set(`${authorization.licenseId}.${f.featureId}`, ss);
1837
+ this.featureMetadata.set(`${authorization.licenseId}.${f.featureKey}`, ss);
1778
1838
  metadataChanged = true;
1779
1839
  }
1780
1840
  }
@@ -1816,10 +1876,12 @@ class LicensingClient {
1816
1876
  }
1817
1877
  return serverFeatures.map((f) => {
1818
1878
  const sf = f;
1819
- // Normalize featureId from PascalCase server response
1820
- const featureId = String(sf.FeatureKey ?? sf.featureKey ?? sf.featureId ?? '');
1879
+ // Normalize featureKey from the PascalCase server wire field (SR4:
1880
+ // wire names frozen the server sends FeatureKey; the SDK model
1881
+ // exposes camelCase featureKey).
1882
+ const featureKey = String(sf.FeatureKey ?? sf.featureKey ?? '');
1821
1883
  // Build normalized feature with camelCase properties only
1822
- const feature = { featureId };
1884
+ const feature = { featureKey };
1823
1885
  // ServiceAccessLevel (ServiceAccessLicenseFeature)
1824
1886
  const rawAccess = sf.ServiceAccessLevel ?? sf.serviceAccessLevel;
1825
1887
  if (rawAccess !== undefined) {
@@ -1899,14 +1961,14 @@ class LicensingClient {
1899
1961
  }
1900
1962
  const features = auth.features.map((f) => {
1901
1963
  const sf = f;
1902
- const featureKey = String(sf.featureId ?? '');
1964
+ const featureKey = String(sf.featureKey ?? '');
1903
1965
  const displayName = String(sf.displayName ?? sf.featureName ?? featureKey);
1904
1966
  return { featureKey, displayName };
1905
1967
  });
1906
1968
  const consumption = [];
1907
1969
  for (const f of auth.features) {
1908
1970
  const sf = f;
1909
- const featureKey = String(sf.featureId ?? '');
1971
+ const featureKey = String(sf.featureKey ?? '');
1910
1972
  const rateLimit = sf.rateLimit;
1911
1973
  const sampleSeconds = sf.sampleSeconds;
1912
1974
  if (rateLimit === undefined || sampleSeconds === undefined)
@@ -1987,6 +2049,8 @@ class LicensingClient {
1987
2049
  // Reset context resolution so ensureContextResolved() re-runs with new config
1988
2050
  this.contextResolved = false;
1989
2051
  this.contextResolutionPromise = null;
2052
+ // New credential epoch — the signature posture may have changed (react-03)
2053
+ this.signaturePostureWarned = false;
1990
2054
  // Pre-refresh in-flight authorization fetches must not satisfy post-refresh calls
1991
2055
  this.inFlightAuthorizations.clear();
1992
2056
  }
@@ -2078,16 +2142,11 @@ class LicensingClient {
2078
2142
  const headers = {
2079
2143
  'Content-Type': 'application/json',
2080
2144
  };
2081
- if (apiKey) {
2082
- headers['dub-apiKey'] = apiKey;
2083
- }
2084
- const queryParams = apiKey
2085
- ? `?subscription-key=${encodeURIComponent(apiKey)}`
2086
- : '';
2145
+ LicensingClient.applyAuthHeaders(headers, apiKey);
2087
2146
  try {
2088
2147
  const controller = new AbortController();
2089
2148
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
2090
- const response = await fetch(`${url}${queryParams}`, {
2149
+ const response = await fetch(url, {
2091
2150
  method: 'POST',
2092
2151
  headers,
2093
2152
  body: JSON.stringify({
@@ -2099,7 +2158,7 @@ class LicensingClient {
2099
2158
  IsBillable: isBillable,
2100
2159
  LicenseOperation: {
2101
2160
  LicenseFeature: {
2102
- FeatureKey: operation.feature.featureId
2161
+ FeatureKey: operation.feature.featureKey
2103
2162
  },
2104
2163
  Amount: operation.quantity ?? 1
2105
2164
  }
@@ -2108,11 +2167,10 @@ class LicensingClient {
2108
2167
  });
2109
2168
  clearTimeout(timeoutId);
2110
2169
  if (!response.ok) {
2111
- const errorText = await response.text().catch(() => 'Unknown error');
2112
- throw new LicensingError(`Consumption report failed: ${response.statusText}`, response.status, errorText);
2170
+ throw await LicensingError.fromHttpResponse('Consumption report', response);
2113
2171
  }
2114
2172
  // Update local feature state after successful server report (auto-create if needed)
2115
- const stateKey = `${operation.licenseId}.${operation.feature.featureId}`;
2173
+ const stateKey = `${operation.licenseId}.${operation.feature.featureKey}`;
2116
2174
  let state = this.featureStates.get(stateKey);
2117
2175
  if (!state) {
2118
2176
  const sampleSeconds = this.featureMetadata.get(stateKey) ?? 3600;
@@ -2159,12 +2217,7 @@ class LicensingClient {
2159
2217
  const headers = {
2160
2218
  'Content-Type': 'application/json',
2161
2219
  };
2162
- if (effectiveApiKey) {
2163
- headers['dub-apiKey'] = effectiveApiKey;
2164
- }
2165
- const queryParams = effectiveApiKey
2166
- ? `?subscription-key=${encodeURIComponent(effectiveApiKey)}`
2167
- : '';
2220
+ LicensingClient.applyAuthHeaders(headers, effectiveApiKey);
2168
2221
  const body = JSON.stringify({
2169
2222
  LicenseId: effectiveLicenseId,
2170
2223
  // MON-187: per-call idempotency key — the server dedups on SubmissionId.
@@ -2182,7 +2235,7 @@ class LicensingClient {
2182
2235
  try {
2183
2236
  const controller = new AbortController();
2184
2237
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
2185
- const response = await fetch(`${url}${queryParams}`, {
2238
+ const response = await fetch(url, {
2186
2239
  method: 'POST',
2187
2240
  headers,
2188
2241
  body,
@@ -2190,8 +2243,7 @@ class LicensingClient {
2190
2243
  });
2191
2244
  clearTimeout(timeoutId);
2192
2245
  if (!response.ok) {
2193
- const errorText = await response.text().catch(() => 'Unknown error');
2194
- throw new LicensingError(`Access check report failed: ${response.statusText}`, response.status, errorText);
2246
+ throw await LicensingError.fromHttpResponse('Access check report', response);
2195
2247
  }
2196
2248
  }
2197
2249
  catch (error) {
@@ -2224,12 +2276,7 @@ class LicensingClient {
2224
2276
  const headers = {
2225
2277
  'Content-Type': 'application/json',
2226
2278
  };
2227
- if (effectiveApiKey) {
2228
- headers['dub-apiKey'] = effectiveApiKey;
2229
- }
2230
- const queryParams = effectiveApiKey
2231
- ? `?subscription-key=${encodeURIComponent(effectiveApiKey)}`
2232
- : '';
2279
+ LicensingClient.applyAuthHeaders(headers, effectiveApiKey);
2233
2280
  // Send PascalCase body matching CreateCheckoutSessionParameters.cs
2234
2281
  const body = JSON.stringify({
2235
2282
  RequestId: this.generateRequestId(),
@@ -2243,7 +2290,7 @@ class LicensingClient {
2243
2290
  try {
2244
2291
  const controller = new AbortController();
2245
2292
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
2246
- const response = await fetch(`${url}${queryParams}`, {
2293
+ const response = await fetch(url, {
2247
2294
  method: 'POST',
2248
2295
  headers,
2249
2296
  body,
@@ -2251,8 +2298,7 @@ class LicensingClient {
2251
2298
  });
2252
2299
  clearTimeout(timeoutId);
2253
2300
  if (!response.ok) {
2254
- const errorText = await response.text().catch(() => 'Unknown error');
2255
- throw new LicensingError(`Checkout session creation failed: ${response.statusText}`, response.status, errorText);
2301
+ throw await LicensingError.fromHttpResponse('Checkout session creation', response);
2256
2302
  }
2257
2303
  const data = await response.json();
2258
2304
  // Map PascalCase server response to camelCase DTO
@@ -2291,12 +2337,7 @@ class LicensingClient {
2291
2337
  const headers = {
2292
2338
  'Content-Type': 'application/json',
2293
2339
  };
2294
- if (effectiveApiKey) {
2295
- headers['dub-apiKey'] = effectiveApiKey;
2296
- }
2297
- const queryParams = effectiveApiKey
2298
- ? `?subscription-key=${encodeURIComponent(effectiveApiKey)}`
2299
- : '';
2340
+ LicensingClient.applyAuthHeaders(headers, effectiveApiKey);
2300
2341
  // Send PascalCase body matching GetCheckoutSessionResultParameters.cs
2301
2342
  const body = JSON.stringify({
2302
2343
  SessionId: sessionId
@@ -2304,7 +2345,7 @@ class LicensingClient {
2304
2345
  try {
2305
2346
  const controller = new AbortController();
2306
2347
  const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
2307
- const response = await fetch(`${url}${queryParams}`, {
2348
+ const response = await fetch(url, {
2308
2349
  method: 'POST',
2309
2350
  headers,
2310
2351
  body,
@@ -2312,8 +2353,7 @@ class LicensingClient {
2312
2353
  });
2313
2354
  clearTimeout(timeoutId);
2314
2355
  if (!response.ok) {
2315
- const errorText = await response.text().catch(() => 'Unknown error');
2316
- throw new LicensingError(`Checkout result retrieval failed: ${response.statusText}`, response.status, errorText);
2356
+ throw await LicensingError.fromHttpResponse('Checkout result retrieval', response);
2317
2357
  }
2318
2358
  const data = await response.json();
2319
2359
  // Map PascalCase server response to camelCase DTO
@@ -2783,15 +2823,10 @@ exports.hasFeature = hasFeature;
2783
2823
  exports.hasValidCryptoConfig = hasValidCryptoConfig;
2784
2824
  exports.licensingContextFromEncodedString = licensingContextFromEncodedString;
2785
2825
  exports.licensingContextToEncodedString = licensingContextToEncodedString;
2786
- exports.listKeys = listKeys;
2787
- exports.load = load;
2788
2826
  exports.matchesFeatureKey = matchesFeatureKey;
2789
2827
  exports.normalizeBillingIntervalUnit = normalizeBillingIntervalUnit;
2790
2828
  exports.normalizeLicenseClassificationType = normalizeLicenseClassificationType;
2791
2829
  exports.normalizeServiceAccessLevel = normalizeServiceAccessLevel;
2792
- exports.onStorageChange = onStorageChange;
2793
- exports.remove = remove;
2794
- exports.save = save;
2795
2830
  exports.tryDecodeCredential = tryDecodeCredential;
2796
2831
  exports.useLicensingContext = useLicensingContext;
2797
2832
  exports.useLicensingContextValue = useLicensingContextValue;