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