@ozura/elements 1.1.0 → 1.2.0-next.27

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.
Files changed (45) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +73 -71
  3. package/dist/frame/element-frame.js +48 -3
  4. package/dist/frame/element-frame.js.map +1 -1
  5. package/dist/frame/tokenizer-frame.html +1 -1
  6. package/dist/frame/tokenizer-frame.js +113 -52
  7. package/dist/frame/tokenizer-frame.js.map +1 -1
  8. package/dist/oz-elements.esm.js +363 -311
  9. package/dist/oz-elements.esm.js.map +1 -1
  10. package/dist/oz-elements.umd.js +364 -311
  11. package/dist/oz-elements.umd.js.map +1 -1
  12. package/dist/react/frame/elementFrame.d.ts +7 -0
  13. package/dist/react/frame/tokenizerFrame.d.ts +4 -1
  14. package/dist/react/index.cjs.js +239 -180
  15. package/dist/react/index.cjs.js.map +1 -1
  16. package/dist/react/index.esm.js +238 -180
  17. package/dist/react/index.esm.js.map +1 -1
  18. package/dist/react/react/index.d.ts +50 -29
  19. package/dist/react/sdk/OzElement.d.ts +3 -1
  20. package/dist/react/sdk/OzVault.d.ts +9 -14
  21. package/dist/react/sdk/createSessionFetcher.d.ts +29 -0
  22. package/dist/react/sdk/index.d.ts +6 -26
  23. package/dist/react/server/index.d.ts +126 -74
  24. package/dist/react/types/index.d.ts +70 -41
  25. package/dist/server/frame/elementFrame.d.ts +7 -0
  26. package/dist/server/frame/tokenizerFrame.d.ts +4 -1
  27. package/dist/server/index.cjs.js +188 -90
  28. package/dist/server/index.cjs.js.map +1 -1
  29. package/dist/server/index.esm.js +187 -91
  30. package/dist/server/index.esm.js.map +1 -1
  31. package/dist/server/sdk/OzElement.d.ts +3 -1
  32. package/dist/server/sdk/OzVault.d.ts +9 -14
  33. package/dist/server/sdk/createSessionFetcher.d.ts +29 -0
  34. package/dist/server/sdk/index.d.ts +6 -26
  35. package/dist/server/server/index.d.ts +126 -74
  36. package/dist/server/types/index.d.ts +70 -41
  37. package/dist/types/frame/elementFrame.d.ts +7 -0
  38. package/dist/types/frame/tokenizerFrame.d.ts +4 -1
  39. package/dist/types/sdk/OzElement.d.ts +3 -1
  40. package/dist/types/sdk/OzVault.d.ts +9 -14
  41. package/dist/types/sdk/createSessionFetcher.d.ts +29 -0
  42. package/dist/types/sdk/index.d.ts +6 -26
  43. package/dist/types/server/index.d.ts +126 -74
  44. package/dist/types/types/index.d.ts +70 -41
  45. package/package.json +1 -1
@@ -326,7 +326,7 @@ function sanitizeOptions(options) {
326
326
  * it never holds raw card data — all sensitive values live in the iframe.
327
327
  */
328
328
  class OzElement {
329
- constructor(elementType, options, vaultId, frameBaseUrl, fonts = [], appearanceStyle) {
329
+ constructor(elementType, options, vaultId, frameBaseUrl, fonts = [], appearanceStyle, onDestroy) {
330
330
  this.iframe = null;
331
331
  this._frameWindow = null;
332
332
  this._ready = false;
@@ -342,6 +342,7 @@ class OzElement {
342
342
  this.fonts = fonts;
343
343
  this.appearanceStyle = appearanceStyle;
344
344
  this.frameId = `oz-${elementType}-${uuid()}`;
345
+ this._onDestroy = onDestroy;
345
346
  }
346
347
  /** The element type this proxy represents. */
347
348
  get type() {
@@ -497,9 +498,14 @@ class OzElement {
497
498
  * and prevents future use. Distinct from `unmount()` which allows re-mounting.
498
499
  */
499
500
  destroy() {
501
+ var _a;
500
502
  this.unmount();
501
503
  this.handlers.clear();
502
504
  this._destroyed = true;
505
+ // Notify OzVault so it can prune the stale frameId entry from its elements
506
+ // and completionState maps. Without this, manually calling el.destroy() leaks
507
+ // map entries that grow unboundedly in SPA scenarios with repeated mount/unmount.
508
+ (_a = this._onDestroy) === null || _a === void 0 ? void 0 : _a.call(this);
503
509
  }
504
510
  // ─── Called by OzVault ───────────────────────────────────────────────────
505
511
  /**
@@ -840,13 +846,116 @@ function validateBilling(billing) {
840
846
  */
841
847
  const PROTOCOL_VERSION = 1;
842
848
 
849
+ /**
850
+ * Creates a `getSessionKey` callback for `OzVault.create()` and `<OzElements>`.
851
+ *
852
+ * This is the recommended way to wire the SDK to your backend session endpoint.
853
+ * If you don't need custom headers or auth logic, pass `sessionUrl` directly to
854
+ * `OzVault.create()` or `<OzElements>` — it calls this helper internally.
855
+ *
856
+ * The callback POSTs `{ sessionId }` to `url` and reads `sessionKey` (or the
857
+ * legacy `waxKey`) from the JSON response, so it is compatible with both the
858
+ * new `createSessionMiddleware` and the old `createMintWaxMiddleware` backends.
859
+ *
860
+ * Each call enforces a **10-second timeout**. On pure network failures
861
+ * (offline, DNS, connection refused) the request is retried **once after 750ms**.
862
+ * HTTP 4xx/5xx errors are never retried — they indicate misconfiguration.
863
+ *
864
+ * @param url - Absolute or relative URL of your session endpoint, e.g. `'/api/oz-session'`.
865
+ *
866
+ * @example
867
+ * // Simplest — just pass sessionUrl, no need to call this directly
868
+ * const vault = await OzVault.create({ pubKey: 'pk_live_...', sessionUrl: '/api/oz-session' });
869
+ *
870
+ * @example
871
+ * // Manual — use when you need custom headers
872
+ * const vault = await OzVault.create({
873
+ * pubKey: 'pk_live_...',
874
+ * getSessionKey: createSessionFetcher('/api/oz-session'),
875
+ * });
876
+ */
877
+ function createSessionFetcher(url) {
878
+ const TIMEOUT_MS = 10000;
879
+ // Each attempt gets its own AbortController so a timeout on attempt 1 does
880
+ // not bleed into the retry.
881
+ const attemptFetch = (sessionId) => {
882
+ const controller = new AbortController();
883
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
884
+ return fetch(url, {
885
+ method: 'POST',
886
+ headers: { 'Content-Type': 'application/json' },
887
+ body: JSON.stringify({ sessionId }),
888
+ signal: controller.signal,
889
+ }).finally(() => clearTimeout(timer));
890
+ };
891
+ return async (sessionId) => {
892
+ let res;
893
+ try {
894
+ res = await attemptFetch(sessionId);
895
+ }
896
+ catch (firstErr) {
897
+ // Timeout/abort — don't retry, we already waited the full duration.
898
+ if (firstErr instanceof Error && (firstErr.name === 'AbortError' || firstErr.name === 'TimeoutError')) {
899
+ throw new OzError(`Session endpoint timed out after ${TIMEOUT_MS / 1000}s (${url})`, undefined, 'timeout');
900
+ }
901
+ // Pure network error — retry once after a short pause.
902
+ await new Promise(resolve => setTimeout(resolve, 750));
903
+ try {
904
+ res = await attemptFetch(sessionId);
905
+ }
906
+ catch (retryErr) {
907
+ const msg = retryErr instanceof Error ? retryErr.message : 'Network error';
908
+ throw new OzError(`Could not reach session endpoint (${url}): ${msg}`, undefined, 'network');
909
+ }
910
+ }
911
+ // Parse JSON separately from the ok-check so that a non-JSON error body
912
+ // (HTML error page, WAF block, CDN 503) produces the right error code.
913
+ // Previously res.json() was attempted before res.ok was checked; a parse
914
+ // failure on a 5xx HTML body would fall through as {} and produce a
915
+ // misleading 'validation' code when the real cause is a server/network issue.
916
+ let data = {};
917
+ try {
918
+ data = await res.json();
919
+ }
920
+ catch (_a) {
921
+ if (!res.ok) {
922
+ throw new OzError(`Session endpoint returned HTTP ${res.status} with a non-JSON body`, undefined, res.status >= 500 ? 'server' : res.status === 401 || res.status === 403 ? 'auth' : 'validation');
923
+ }
924
+ // HTTP 200 but body isn't JSON — this is a misconfigured session endpoint.
925
+ throw new OzError('Session endpoint returned HTTP 200 but the response body is not valid JSON. Check your /api/oz-session implementation.', undefined, 'validation');
926
+ }
927
+ if (!res.ok) {
928
+ throw new OzError(typeof data.error === 'string' && data.error
929
+ ? data.error
930
+ : `Session endpoint returned HTTP ${res.status}`, undefined, res.status >= 500 ? 'server' : res.status === 401 || res.status === 403 ? 'auth' : 'validation');
931
+ }
932
+ // Accept both new `sessionKey` and legacy `waxKey` for backward compatibility
933
+ // with backends that haven't migrated to createSessionMiddleware yet.
934
+ const key = (typeof data.sessionKey === 'string' ? data.sessionKey : '') ||
935
+ (typeof data.waxKey === 'string' ? data.waxKey : '');
936
+ if (!key.trim()) {
937
+ throw new OzError('Session endpoint response is missing sessionKey. Check your /api/oz-session implementation.', undefined, 'validation');
938
+ }
939
+ return key;
940
+ };
941
+ }
942
+
843
943
  function isCardMetadata(v) {
844
- return !!v && typeof v === 'object' && typeof v.last4 === 'string';
944
+ if (!v || typeof v !== 'object')
945
+ return false;
946
+ const r = v;
947
+ return (typeof r.last4 === 'string' &&
948
+ typeof r.brand === 'string' &&
949
+ typeof r.expMonth === 'string' &&
950
+ typeof r.expYear === 'string');
845
951
  }
846
952
  function isBankAccountMetadata(v) {
847
- return !!v && typeof v === 'object' && typeof v.last4 === 'string';
953
+ if (!v || typeof v !== 'object')
954
+ return false;
955
+ const r = v;
956
+ return typeof r.last4 === 'string' && typeof r.routingNumberLast4 === 'string';
848
957
  }
849
- const DEFAULT_FRAME_BASE_URL = "https://elements.ozura.com";
958
+ const DEFAULT_FRAME_BASE_URL = "https://lively-hill-097170c0f.4.azurestaticapps.net";
850
959
  /**
851
960
  * The main entry point for OzElements. Creates and manages iframe-based
852
961
  * card input elements that keep raw card data isolated from the merchant page.
@@ -854,16 +963,10 @@ const DEFAULT_FRAME_BASE_URL = "https://elements.ozura.com";
854
963
  * Use the static `OzVault.create()` factory — do not call `new OzVault()` directly.
855
964
  *
856
965
  * @example
966
+ * // Recommended — pass sessionUrl and let the SDK call your backend automatically
857
967
  * const vault = await OzVault.create({
858
- * pubKey: 'pk_live_...',
859
- * fetchWaxKey: async (sessionId) => {
860
- * // Call your backend — which calls ozura.mintWaxKey() from @ozura/elements/server
861
- * const { waxKey } = await fetch('/api/mint-wax', {
862
- * method: 'POST',
863
- * body: JSON.stringify({ sessionId }),
864
- * }).then(r => r.json());
865
- * return waxKey;
866
- * },
968
+ * pubKey: 'pk_prod_...', // or 'pk_test_...' for test mode
969
+ * sessionUrl: '/api/oz-session', // backend endpoint that calls ozura.createSession()
867
970
  * });
868
971
  * const cardNum = vault.createElement('cardNumber');
869
972
  * cardNum.mount('#card-number');
@@ -879,7 +982,7 @@ class OzVault {
879
982
  * @internal
880
983
  */
881
984
  constructor(options, waxKey, tokenizationSessionId) {
882
- var _a, _b, _c, _d;
985
+ var _a, _b, _c, _d, _e;
883
986
  this.elements = new Map();
884
987
  this.elementsByType = new Map();
885
988
  this.bankElementsByType = new Map();
@@ -913,7 +1016,12 @@ class OzVault {
913
1016
  this.fonts = (_a = options.fonts) !== null && _a !== void 0 ? _a : [];
914
1017
  this.resolvedAppearance = resolveAppearance(options.appearance);
915
1018
  this.vaultId = `vault-${uuid()}`;
916
- this._maxTokenizeCalls = (_b = options.maxTokenizeCalls) !== null && _b !== void 0 ? _b : 3;
1019
+ // sessionLimit takes precedence over legacy maxTokenizeCalls.
1020
+ // null means unlimited — use Infinity so the ">=" check never triggers.
1021
+ const rawLimit = options.sessionLimit !== undefined
1022
+ ? options.sessionLimit
1023
+ : ((_b = options.maxTokenizeCalls) !== null && _b !== void 0 ? _b : 3);
1024
+ this._maxTokenizeCalls = rawLimit === null ? Infinity : rawLimit;
917
1025
  this._debug = (_c = options.debug) !== null && _c !== void 0 ? _c : false;
918
1026
  this.boundHandleMessage = this.handleMessage.bind(this);
919
1027
  window.addEventListener('message', this.boundHandleMessage);
@@ -929,7 +1037,8 @@ class OzVault {
929
1037
  }
930
1038
  }, timeout);
931
1039
  }
932
- this._onWaxRefresh = options.onWaxRefresh;
1040
+ // onSessionRefresh takes precedence over legacy onWaxRefresh
1041
+ this._onWaxRefresh = (_e = options.onSessionRefresh) !== null && _e !== void 0 ? _e : options.onWaxRefresh;
933
1042
  this._onReady = options.onReady;
934
1043
  this.log('vault created', { vaultId: this.vaultId, frameBaseUrl: this.frameBaseUrl, maxTokenizeCalls: this._maxTokenizeCalls });
935
1044
  }
@@ -937,51 +1046,63 @@ class OzVault {
937
1046
  * Creates and returns a ready `OzVault` instance.
938
1047
  *
939
1048
  * Internally this:
940
- * 1. Generates a `tokenizationSessionId` (UUID).
1049
+ * 1. Generates a session UUID.
941
1050
  * 2. Starts loading the hidden tokenizer iframe immediately.
942
- * 3. Calls `options.fetchWaxKey(tokenizationSessionId)` concurrently — your
943
- * backend mints a session-bound wax key from the vault and returns it.
944
- * 4. Resolves with the vault instance once the wax key is stored. The iframe
1051
+ * 3. Fetches a session key from your backend concurrently — either via
1052
+ * `sessionUrl` (simplest), `getSessionKey` (custom headers/auth), or the
1053
+ * deprecated `fetchWaxKey` callback.
1054
+ * 4. Resolves with the vault instance once the session key is stored. The iframe
945
1055
  * has been loading the whole time, so `isReady` may already be true or
946
1056
  * will fire shortly after.
947
1057
  *
948
1058
  * The returned vault is ready to create elements immediately. `createToken()`
949
1059
  * additionally requires `vault.isReady` (tokenizer iframe loaded).
950
1060
  *
951
- * @throws {OzError} if `fetchWaxKey` throws, returns a non-string value, or returns an empty/whitespace-only string.
1061
+ * @throws {OzError} if the session fetch fails, times out, or returns an empty string.
952
1062
  */
953
1063
  static async create(options, signal) {
954
1064
  if (!options.pubKey || !options.pubKey.trim()) {
955
1065
  throw new OzError('pubKey is required in options. Obtain your public key from the Ozura admin.');
956
1066
  }
957
- if (typeof options.fetchWaxKey !== 'function') {
958
- throw new OzError('fetchWaxKey must be a function. See OzVault.create() docs for the expected signature.');
1067
+ // Normalize the session callback. Priority: sessionUrl > getSessionKey > fetchWaxKey (deprecated).
1068
+ // This allows merchants to use the clean new API without touching legacy code.
1069
+ let resolvedFetchKey;
1070
+ if (options.sessionUrl) {
1071
+ resolvedFetchKey = createSessionFetcher(options.sessionUrl);
1072
+ }
1073
+ else if (typeof options.getSessionKey === 'function') {
1074
+ resolvedFetchKey = options.getSessionKey;
1075
+ }
1076
+ else if (typeof options.fetchWaxKey === 'function') {
1077
+ resolvedFetchKey = options.fetchWaxKey;
1078
+ }
1079
+ else {
1080
+ throw new OzError('A session URL or callback is required. Pass sessionUrl, getSessionKey, or fetchWaxKey to OzVault.create().');
959
1081
  }
960
1082
  const tokenizationSessionId = uuid();
961
1083
  // Construct the vault immediately — this mounts the tokenizer iframe so it
962
- // starts loading while fetchWaxKey is in flight. The waxKey field starts
963
- // empty and is set below before create() returns.
1084
+ // starts loading while the session fetch is in flight.
964
1085
  const vault = new OzVault(options, '', tokenizationSessionId);
965
1086
  // If the caller provides an AbortSignal (e.g. React useEffect cleanup),
966
1087
  // destroy the vault immediately on abort so the tokenizer iframe and message
967
- // listener are removed synchronously rather than waiting for fetchWaxKey to
968
- // settle. This eliminates the brief double-iframe window in React StrictMode.
1088
+ // listener are removed synchronously rather than waiting for the session fetch
1089
+ // to settle. This eliminates the brief double-iframe window in React StrictMode.
969
1090
  const onAbort = () => vault.destroy();
970
1091
  signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', onAbort, { once: true });
971
1092
  let waxKey;
972
1093
  try {
973
- waxKey = await options.fetchWaxKey(tokenizationSessionId);
1094
+ waxKey = await resolvedFetchKey(tokenizationSessionId);
974
1095
  }
975
1096
  catch (err) {
976
1097
  signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', onAbort);
977
1098
  vault.destroy();
978
1099
  if (signal === null || signal === void 0 ? void 0 : signal.aborted)
979
1100
  throw new OzError('OzVault.create() was cancelled.');
980
- // Preserve errorCode/retryable from OzError (e.g. timeout/network from createFetchWaxKey)
1101
+ // Preserve errorCode/retryable from OzError (e.g. timeout/network from createSessionFetcher)
981
1102
  // so callers can distinguish transient failures from config errors.
982
1103
  const originalCode = err instanceof OzError ? err.errorCode : undefined;
983
1104
  const msg = err instanceof Error ? err.message : 'Unknown error';
984
- throw new OzError(`fetchWaxKey threw an error: ${msg}`, undefined, originalCode);
1105
+ throw new OzError(`Session fetch threw an error: ${msg}`, undefined, originalCode);
985
1106
  }
986
1107
  signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', onAbort);
987
1108
  if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
@@ -990,11 +1111,11 @@ class OzVault {
990
1111
  }
991
1112
  if (typeof waxKey !== 'string' || !waxKey.trim()) {
992
1113
  vault.destroy();
993
- throw new OzError('fetchWaxKey must return a non-empty wax key string. Check your mint endpoint.');
1114
+ throw new OzError('Session fetch returned an empty key. Check your session endpoint response — it must return { sessionKey: "..." }.');
994
1115
  }
995
1116
  // Static methods can access private fields of instances of the same class.
996
1117
  vault.waxKey = waxKey;
997
- vault._storedFetchWaxKey = options.fetchWaxKey;
1118
+ vault._storedFetchWaxKey = resolvedFetchKey;
998
1119
  // If the tokenizer iframe fired OZ_FRAME_READY before fetchWaxKey resolved,
999
1120
  // the OZ_INIT sent at that point had an empty waxKey. Send a follow-up now
1000
1121
  // so the tokenizer has the key stored before any createToken() call.
@@ -1081,7 +1202,12 @@ class OzVault {
1081
1202
  this.completionState.delete(existing.frameId);
1082
1203
  existing.destroy();
1083
1204
  }
1084
- const el = new OzElement(type, options, this.vaultId, this.frameBaseUrl, this.fonts, this.resolvedAppearance);
1205
+ const el = new OzElement(type, options, this.vaultId, this.frameBaseUrl, this.fonts, this.resolvedAppearance, () => {
1206
+ // Prune vault-level maps when the element is manually destroyed so they
1207
+ // don't grow unboundedly in SPA scenarios with repeated mount/unmount cycles.
1208
+ this.elements.delete(el.frameId);
1209
+ this.completionState.delete(el.frameId);
1210
+ });
1085
1211
  this.elements.set(el.frameId, el);
1086
1212
  typeMap.set(type, el);
1087
1213
  return el;
@@ -1660,9 +1786,9 @@ class OzVault {
1660
1786
  // key without waiting for a vault rejection.
1661
1787
  this._tokenizeSuccessCount++;
1662
1788
  if (this._tokenizeSuccessCount >= this._maxTokenizeCalls) {
1663
- this.log('proactive wax key refresh triggered', { tokenizeSuccessCount: this._tokenizeSuccessCount, maxTokenizeCalls: this._maxTokenizeCalls });
1789
+ this.log('proactive session key refresh triggered', { tokenizeSuccessCount: this._tokenizeSuccessCount, maxTokenizeCalls: this._maxTokenizeCalls });
1664
1790
  this.refreshWaxKey().catch((err) => {
1665
- console.warn('[OzVault] Post-budget wax key refresh failed:', err instanceof Error ? err.message : err);
1791
+ console.warn('[OzVault] Post-budget session key refresh failed:', err instanceof Error ? err.message : err);
1666
1792
  });
1667
1793
  }
1668
1794
  }
@@ -1738,61 +1864,65 @@ class OzVault {
1738
1864
  }
1739
1865
  pending.reject(new OzError(normalizeVaultError(raw), raw, errorCode));
1740
1866
  }
1741
- // Also check bank resolvers — both card and bank errors use OZ_TOKEN_ERROR
1742
- const bankPending = this.bankTokenizeResolvers.get(msg.requestId);
1743
- if (bankPending) {
1744
- this.bankTokenizeResolvers.delete(msg.requestId);
1745
- if (bankPending.timeoutId != null)
1746
- clearTimeout(bankPending.timeoutId);
1747
- if (this.isRefreshableAuthError(errorCode, raw) && !bankPending.retried && this._storedFetchWaxKey) {
1748
- const resetCountAtRetry = this._resetCount;
1749
- this.refreshWaxKey().then(() => {
1750
- if (this._destroyed) {
1751
- bankPending.reject(new OzError('Vault destroyed during wax key refresh.'));
1752
- return;
1753
- }
1754
- if (this._resetCount !== resetCountAtRetry) {
1755
- bankPending.reject(new OzError('Vault was reset while tokenization was in progress.'));
1756
- return;
1757
- }
1758
- const newRequestId = `req-${uuid()}`;
1759
- this.bankTokenizeResolvers.set(newRequestId, Object.assign(Object.assign({}, bankPending), { retried: true }));
1760
- try {
1761
- const retryBankChannels = bankPending.readyElements.map(() => new MessageChannel());
1762
- this.sendToTokenizer({
1763
- type: 'OZ_BANK_TOKENIZE',
1764
- requestId: newRequestId,
1765
- waxKey: this.waxKey,
1766
- tokenizationSessionId: this.tokenizationSessionId,
1767
- pubKey: this.pubKey,
1768
- firstName: bankPending.firstName,
1769
- lastName: bankPending.lastName,
1770
- fieldCount: bankPending.fieldCount,
1771
- }, retryBankChannels.map(ch => ch.port1));
1772
- bankPending.readyElements.forEach((el, i) => el.beginCollect(newRequestId, retryBankChannels[i].port2));
1773
- const retryBankTimeoutId = setTimeout(() => {
1774
- if (this.bankTokenizeResolvers.has(newRequestId)) {
1775
- this.bankTokenizeResolvers.delete(newRequestId);
1776
- this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId: newRequestId });
1777
- bankPending.reject(new OzError('Bank tokenization timed out after wax key refresh.', undefined, 'timeout'));
1778
- }
1779
- }, 30000);
1780
- const retryBankEntry = this.bankTokenizeResolvers.get(newRequestId);
1781
- if (retryBankEntry)
1782
- retryBankEntry.timeoutId = retryBankTimeoutId;
1783
- }
1784
- catch (setupErr) {
1785
- this.bankTokenizeResolvers.delete(newRequestId);
1786
- bankPending.reject(setupErr instanceof OzError ? setupErr : new OzError('Retry bank tokenization failed to start'));
1787
- }
1788
- }).catch((refreshErr) => {
1789
- const msg = refreshErr instanceof Error ? refreshErr.message : 'Wax key refresh failed';
1790
- bankPending.reject(new OzError(msg, undefined, 'auth'));
1791
- });
1792
- break;
1867
+ else {
1868
+ // Also check bank resolvers — both card and bank errors use OZ_TOKEN_ERROR.
1869
+ // Use else-if rather than sequential checks so a UUID collision (however
1870
+ // improbable) can never trigger double-rejection of two unrelated resolvers.
1871
+ const bankPending = this.bankTokenizeResolvers.get(msg.requestId);
1872
+ if (bankPending) {
1873
+ this.bankTokenizeResolvers.delete(msg.requestId);
1874
+ if (bankPending.timeoutId != null)
1875
+ clearTimeout(bankPending.timeoutId);
1876
+ if (this.isRefreshableAuthError(errorCode, raw) && !bankPending.retried && this._storedFetchWaxKey) {
1877
+ const resetCountAtRetry = this._resetCount;
1878
+ this.refreshWaxKey().then(() => {
1879
+ if (this._destroyed) {
1880
+ bankPending.reject(new OzError('Vault destroyed during wax key refresh.'));
1881
+ return;
1882
+ }
1883
+ if (this._resetCount !== resetCountAtRetry) {
1884
+ bankPending.reject(new OzError('Vault was reset while tokenization was in progress.'));
1885
+ return;
1886
+ }
1887
+ const newRequestId = `req-${uuid()}`;
1888
+ this.bankTokenizeResolvers.set(newRequestId, Object.assign(Object.assign({}, bankPending), { retried: true }));
1889
+ try {
1890
+ const retryBankChannels = bankPending.readyElements.map(() => new MessageChannel());
1891
+ this.sendToTokenizer({
1892
+ type: 'OZ_BANK_TOKENIZE',
1893
+ requestId: newRequestId,
1894
+ waxKey: this.waxKey,
1895
+ tokenizationSessionId: this.tokenizationSessionId,
1896
+ pubKey: this.pubKey,
1897
+ firstName: bankPending.firstName,
1898
+ lastName: bankPending.lastName,
1899
+ fieldCount: bankPending.fieldCount,
1900
+ }, retryBankChannels.map(ch => ch.port1));
1901
+ bankPending.readyElements.forEach((el, i) => el.beginCollect(newRequestId, retryBankChannels[i].port2));
1902
+ const retryBankTimeoutId = setTimeout(() => {
1903
+ if (this.bankTokenizeResolvers.has(newRequestId)) {
1904
+ this.bankTokenizeResolvers.delete(newRequestId);
1905
+ this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId: newRequestId });
1906
+ bankPending.reject(new OzError('Bank tokenization timed out after wax key refresh.', undefined, 'timeout'));
1907
+ }
1908
+ }, 30000);
1909
+ const retryBankEntry = this.bankTokenizeResolvers.get(newRequestId);
1910
+ if (retryBankEntry)
1911
+ retryBankEntry.timeoutId = retryBankTimeoutId;
1912
+ }
1913
+ catch (setupErr) {
1914
+ this.bankTokenizeResolvers.delete(newRequestId);
1915
+ bankPending.reject(setupErr instanceof OzError ? setupErr : new OzError('Retry bank tokenization failed to start'));
1916
+ }
1917
+ }).catch((refreshErr) => {
1918
+ const msg = refreshErr instanceof Error ? refreshErr.message : 'Wax key refresh failed';
1919
+ bankPending.reject(new OzError(msg, undefined, 'auth'));
1920
+ });
1921
+ break;
1922
+ }
1923
+ bankPending.reject(new OzError(normalizeBankVaultError(raw), raw, errorCode));
1793
1924
  }
1794
- bankPending.reject(new OzError(normalizeBankVaultError(raw), raw, errorCode));
1795
- }
1925
+ } // end else (bank path)
1796
1926
  break;
1797
1927
  }
1798
1928
  case 'OZ_BANK_TOKEN_RESULT': {
@@ -1820,9 +1950,9 @@ class OzVault {
1820
1950
  // Same proactive refresh logic as card tokenization.
1821
1951
  this._tokenizeSuccessCount++;
1822
1952
  if (this._tokenizeSuccessCount >= this._maxTokenizeCalls) {
1823
- this.log('proactive wax key refresh triggered', { tokenizeSuccessCount: this._tokenizeSuccessCount, maxTokenizeCalls: this._maxTokenizeCalls });
1953
+ this.log('proactive session key refresh triggered', { tokenizeSuccessCount: this._tokenizeSuccessCount, maxTokenizeCalls: this._maxTokenizeCalls });
1824
1954
  this.refreshWaxKey().catch((err) => {
1825
- console.warn('[OzVault] Post-budget wax key refresh failed:', err instanceof Error ? err.message : err);
1955
+ console.warn('[OzVault] Post-budget session key refresh failed:', err instanceof Error ? err.message : err);
1826
1956
  });
1827
1957
  }
1828
1958
  }
@@ -1908,84 +2038,6 @@ class OzVault {
1908
2038
  }
1909
2039
  }
1910
2040
 
1911
- /**
1912
- * Creates a ready-to-use `fetchWaxKey` callback for `OzVault.create()` and `<OzElements>`.
1913
- *
1914
- * Calls your backend mint endpoint with `{ sessionId }` and returns the wax key string.
1915
- * Throws on non-OK responses or a missing `waxKey` field so the vault can surface the
1916
- * error through its normal error path.
1917
- *
1918
- * Each call enforces a 10-second per-attempt timeout. On a pure network-level
1919
- * failure (connection refused, DNS failure, etc.) the call is retried once after
1920
- * 750ms before throwing. HTTP errors (4xx/5xx) are never retried — they indicate
1921
- * an endpoint misconfiguration or an invalid key, not a transient failure.
1922
- *
1923
- * The mint endpoint is typically the one-line `createMintWaxHandler` / `createMintWaxMiddleware`
1924
- * from `@ozura/elements/server`.
1925
- *
1926
- * @param mintUrl - Absolute or relative URL of your wax-key mint endpoint, e.g. `'/api/mint-wax'`.
1927
- *
1928
- * @example
1929
- * // Vanilla JS
1930
- * const vault = await OzVault.create({
1931
- * pubKey: 'pk_live_...',
1932
- * fetchWaxKey: createFetchWaxKey('/api/mint-wax'),
1933
- * });
1934
- *
1935
- * @example
1936
- * // React
1937
- * <OzElements pubKey="pk_live_..." fetchWaxKey={createFetchWaxKey('/api/mint-wax')}>
1938
- */
1939
- function createFetchWaxKey(mintUrl) {
1940
- const TIMEOUT_MS = 10000;
1941
- // Each attempt gets its own AbortController so a timeout on attempt 1 does
1942
- // not bleed into the retry. Uses AbortController + setTimeout instead of
1943
- // AbortSignal.timeout() to support environments without that API.
1944
- const attemptFetch = (sessionId) => {
1945
- const controller = new AbortController();
1946
- const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
1947
- return fetch(mintUrl, {
1948
- method: 'POST',
1949
- headers: { 'Content-Type': 'application/json' },
1950
- body: JSON.stringify({ sessionId }),
1951
- signal: controller.signal,
1952
- }).finally(() => clearTimeout(timer));
1953
- };
1954
- return async (sessionId) => {
1955
- let res;
1956
- try {
1957
- res = await attemptFetch(sessionId);
1958
- }
1959
- catch (firstErr) {
1960
- // Abort/timeout should not be retried — the server received nothing or
1961
- // we already waited the full timeout duration.
1962
- if (firstErr instanceof Error && (firstErr.name === 'AbortError' || firstErr.name === 'TimeoutError')) {
1963
- throw new OzError(`Wax key mint timed out after ${TIMEOUT_MS / 1000}s (${mintUrl})`, undefined, 'timeout');
1964
- }
1965
- // Pure network error (offline, DNS, connection refused) — retry once
1966
- // after a short pause in case of a transient blip.
1967
- await new Promise(resolve => setTimeout(resolve, 750));
1968
- try {
1969
- res = await attemptFetch(sessionId);
1970
- }
1971
- catch (retryErr) {
1972
- const msg = retryErr instanceof Error ? retryErr.message : 'Network error';
1973
- throw new OzError(`Could not reach wax key mint endpoint (${mintUrl}): ${msg}`, undefined, 'network');
1974
- }
1975
- }
1976
- const data = await res.json().catch(() => ({}));
1977
- if (!res.ok) {
1978
- throw new OzError(typeof data.error === 'string' && data.error
1979
- ? data.error
1980
- : `Wax key mint failed (HTTP ${res.status})`, undefined, res.status >= 500 ? 'server' : res.status === 401 || res.status === 403 ? 'auth' : 'validation');
1981
- }
1982
- if (typeof data.waxKey !== 'string' || !data.waxKey.trim()) {
1983
- throw new OzError('Mint endpoint response is missing waxKey. Check your /api/mint-wax implementation.', undefined, 'validation');
1984
- }
1985
- return data.waxKey;
1986
- };
1987
- }
1988
-
1989
2041
  const OzContext = react.createContext({
1990
2042
  vault: null,
1991
2043
  initError: null,
@@ -2002,7 +2054,7 @@ const OzContext = react.createContext({
2002
2054
  * All `<OzCardNumber />`, `<OzExpiry />`, and `<OzCvv />` children must be
2003
2055
  * rendered inside this provider.
2004
2056
  */
2005
- function OzElements({ fetchWaxKey, pubKey, frameBaseUrl, fonts, onLoadError, loadTimeoutMs, onWaxRefresh, onReady, appearance, maxTokenizeCalls, debug, children }) {
2057
+ function OzElements({ sessionUrl, getSessionKey, fetchWaxKey, pubKey, frameBaseUrl, fonts, onLoadError, loadTimeoutMs, onSessionRefresh, onWaxRefresh, onReady, appearance, sessionLimit, maxTokenizeCalls, debug, children }) {
2006
2058
  const [vault, setVault] = react.useState(null);
2007
2059
  const [initError, setInitError] = react.useState(null);
2008
2060
  const [mountedCount, setMountedCount] = react.useState(0);
@@ -2010,13 +2062,14 @@ function OzElements({ fetchWaxKey, pubKey, frameBaseUrl, fonts, onLoadError, loa
2010
2062
  const [tokenizeCount, setTokenizeCount] = react.useState(0);
2011
2063
  const onLoadErrorRef = react.useRef(onLoadError);
2012
2064
  onLoadErrorRef.current = onLoadError;
2013
- const onWaxRefreshRef = react.useRef(onWaxRefresh);
2014
- onWaxRefreshRef.current = onWaxRefresh;
2065
+ const onWaxRefreshRef = react.useRef(onSessionRefresh !== null && onSessionRefresh !== void 0 ? onSessionRefresh : onWaxRefresh);
2066
+ onWaxRefreshRef.current = onSessionRefresh !== null && onSessionRefresh !== void 0 ? onSessionRefresh : onWaxRefresh;
2015
2067
  const onReadyRef = react.useRef(onReady);
2016
2068
  onReadyRef.current = onReady;
2017
- // Keep a ref to fetchWaxKey so changes don't trigger vault recreation
2018
- const fetchWaxKeyRef = react.useRef(fetchWaxKey);
2019
- fetchWaxKeyRef.current = fetchWaxKey;
2069
+ // Keep a ref to the session callback so changes don't trigger vault recreation.
2070
+ // Priority mirrors OzVault.create(): sessionUrl > getSessionKey > fetchWaxKey.
2071
+ const getSessionKeyRef = react.useRef(getSessionKey !== null && getSessionKey !== void 0 ? getSessionKey : fetchWaxKey);
2072
+ getSessionKeyRef.current = getSessionKey !== null && getSessionKey !== void 0 ? getSessionKey : fetchWaxKey;
2020
2073
  const appearanceKey = react.useMemo(() => appearance ? JSON.stringify(appearance) : '', [appearance]);
2021
2074
  const fontsKey = react.useMemo(() => fonts ? JSON.stringify(fonts) : '', [fonts]);
2022
2075
  react.useEffect(() => {
@@ -2043,7 +2096,11 @@ function OzElements({ fetchWaxKey, pubKey, frameBaseUrl, fonts, onLoadError, loa
2043
2096
  // synchronously rather than waiting for the promise to settle. Without this,
2044
2097
  // two hidden iframes and two window listeners briefly coexist.
2045
2098
  const abortController = new AbortController();
2046
- OzVault.create(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ pubKey, fetchWaxKey: (sessionId) => fetchWaxKeyRef.current(sessionId) }, (frameBaseUrl ? { frameBaseUrl } : {})), (parsedFonts ? { fonts: parsedFonts } : {})), (parsedAppearance ? { appearance: parsedAppearance } : {})), (onLoadErrorRef.current ? { onLoadError: fireLoadError, loadTimeoutMs } : {})), {
2099
+ OzVault.create(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ pubKey }, (sessionUrl
2100
+ ? { sessionUrl }
2101
+ : getSessionKeyRef.current
2102
+ ? { getSessionKey: (sessionId) => getSessionKeyRef.current(sessionId) }
2103
+ : {})), (frameBaseUrl ? { frameBaseUrl } : {})), (parsedFonts ? { fonts: parsedFonts } : {})), (parsedAppearance ? { appearance: parsedAppearance } : {})), (onLoadErrorRef.current ? { onLoadError: fireLoadError, loadTimeoutMs } : {})), {
2047
2104
  // Always install onWaxRefresh internally so we can reset tokenizeCount
2048
2105
  // when any wax key refresh occurs (reactive TTL expiry, post-budget
2049
2106
  // proactive, or visibility-change proactive). Without this the React
@@ -2065,11 +2122,12 @@ function OzElements({ fetchWaxKey, pubKey, frameBaseUrl, fonts, onLoadError, loa
2065
2122
  // the retry tokenization completes (a full fetchWaxKey + tokenize round-
2066
2123
  // trip separates them), so the count correctly resets to 0 then rises to
2067
2124
  // 1 after the retry notifyTokenize fires.
2068
- onWaxRefresh: () => {
2125
+ onSessionRefresh: () => {
2069
2126
  var _a;
2070
2127
  Promise.resolve().then(() => setTokenizeCount(0));
2071
2128
  (_a = onWaxRefreshRef.current) === null || _a === void 0 ? void 0 : _a.call(onWaxRefreshRef);
2072
- }, onReady: () => { var _a; return (_a = onReadyRef.current) === null || _a === void 0 ? void 0 : _a.call(onReadyRef); } }), (maxTokenizeCalls !== undefined ? { maxTokenizeCalls } : {})), (debug ? { debug: true } : {})), abortController.signal).then(v => {
2129
+ }, onReady: () => { var _a; return (_a = onReadyRef.current) === null || _a === void 0 ? void 0 : _a.call(onReadyRef); } }), (sessionLimit !== undefined ? { sessionLimit }
2130
+ : maxTokenizeCalls !== undefined ? { maxTokenizeCalls } : {})), (debug ? { debug: true } : {})), abortController.signal).then(v => {
2073
2131
  if (cancelled) {
2074
2132
  v.destroy();
2075
2133
  return;
@@ -2102,7 +2160,7 @@ function OzElements({ fetchWaxKey, pubKey, frameBaseUrl, fonts, onLoadError, loa
2102
2160
  setVault(null);
2103
2161
  setInitError(null);
2104
2162
  };
2105
- }, [pubKey, frameBaseUrl, loadTimeoutMs, appearanceKey, fontsKey, maxTokenizeCalls, debug]);
2163
+ }, [pubKey, sessionUrl, frameBaseUrl, loadTimeoutMs, appearanceKey, fontsKey, sessionLimit, maxTokenizeCalls, debug]);
2106
2164
  const notifyMount = react.useCallback(() => setMountedCount(n => n + 1), []);
2107
2165
  const notifyReady = react.useCallback(() => setReadyCount(n => n + 1), []);
2108
2166
  const notifyUnmount = react.useCallback(() => {
@@ -2446,6 +2504,7 @@ exports.OzCardNumber = OzCardNumber;
2446
2504
  exports.OzCvv = OzCvv;
2447
2505
  exports.OzElements = OzElements;
2448
2506
  exports.OzExpiry = OzExpiry;
2449
- exports.createFetchWaxKey = createFetchWaxKey;
2507
+ exports.createFetchWaxKey = createSessionFetcher;
2508
+ exports.createSessionFetcher = createSessionFetcher;
2450
2509
  exports.useOzElements = useOzElements;
2451
2510
  //# sourceMappingURL=index.cjs.js.map