@ozura/elements 1.2.1 → 1.2.3

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 (60) hide show
  1. package/README.md +105 -0
  2. package/dist/frame/element-frame.js +49 -4
  3. package/dist/frame/element-frame.js.map +1 -1
  4. package/dist/frame/tokenizer-frame.js +145 -61
  5. package/dist/frame/tokenizer-frame.js.map +1 -1
  6. package/dist/oz-elements.esm.js +221 -97
  7. package/dist/oz-elements.esm.js.map +1 -1
  8. package/dist/oz-elements.umd.js +221 -97
  9. package/dist/oz-elements.umd.js.map +1 -1
  10. package/dist/react/frame/elementFrame.d.ts +9 -0
  11. package/dist/react/frame/tokenizerFrame.d.ts +17 -1
  12. package/dist/react/index.cjs.js +221 -97
  13. package/dist/react/index.cjs.js.map +1 -1
  14. package/dist/react/index.esm.js +221 -97
  15. package/dist/react/index.esm.js.map +1 -1
  16. package/dist/react/sdk/OzElement.d.ts +4 -1
  17. package/dist/react/sdk/OzVault.d.ts +3 -9
  18. package/dist/react/sdk/createSessionFetcher.d.ts +2 -2
  19. package/dist/react/server/index.d.ts +26 -0
  20. package/dist/react/types/index.d.ts +30 -11
  21. package/dist/react/utils/billingUtils.d.ts +2 -1
  22. package/dist/react/vue/index.d.ts +88 -0
  23. package/dist/server/frame/elementFrame.d.ts +9 -0
  24. package/dist/server/frame/tokenizerFrame.d.ts +17 -1
  25. package/dist/server/index.cjs.js +65 -24
  26. package/dist/server/index.cjs.js.map +1 -1
  27. package/dist/server/index.esm.js +65 -24
  28. package/dist/server/index.esm.js.map +1 -1
  29. package/dist/server/sdk/OzElement.d.ts +4 -1
  30. package/dist/server/sdk/OzVault.d.ts +3 -9
  31. package/dist/server/sdk/createSessionFetcher.d.ts +2 -2
  32. package/dist/server/server/index.d.ts +26 -0
  33. package/dist/server/types/index.d.ts +30 -11
  34. package/dist/server/utils/billingUtils.d.ts +2 -1
  35. package/dist/server/vue/index.d.ts +88 -0
  36. package/dist/types/frame/elementFrame.d.ts +9 -0
  37. package/dist/types/frame/tokenizerFrame.d.ts +17 -1
  38. package/dist/types/sdk/OzElement.d.ts +4 -1
  39. package/dist/types/sdk/OzVault.d.ts +3 -9
  40. package/dist/types/sdk/createSessionFetcher.d.ts +2 -2
  41. package/dist/types/server/index.d.ts +26 -0
  42. package/dist/types/types/index.d.ts +30 -11
  43. package/dist/types/utils/billingUtils.d.ts +2 -1
  44. package/dist/types/vue/index.d.ts +88 -0
  45. package/dist/vue/frame/protocol.d.ts +12 -0
  46. package/dist/vue/index.cjs.js +2335 -0
  47. package/dist/vue/index.cjs.js.map +1 -0
  48. package/dist/vue/index.esm.js +2327 -0
  49. package/dist/vue/index.esm.js.map +1 -0
  50. package/dist/vue/sdk/OzElement.d.ts +99 -0
  51. package/dist/vue/sdk/OzVault.d.ts +250 -0
  52. package/dist/vue/sdk/createSessionFetcher.d.ts +29 -0
  53. package/dist/vue/sdk/errors.d.ts +65 -0
  54. package/dist/vue/sdk/index.d.ts +14 -0
  55. package/dist/vue/types/index.d.ts +667 -0
  56. package/dist/vue/utils/appearance.d.ts +22 -0
  57. package/dist/vue/utils/billingUtils.d.ts +61 -0
  58. package/dist/vue/utils/uuid.d.ts +12 -0
  59. package/dist/vue/vue/index.d.ts +88 -0
  60. package/package.json +15 -3
@@ -204,7 +204,7 @@
204
204
  if (msg.includes('timeout') || msg.includes('timed out')) {
205
205
  return 'The request timed out. Please try again.';
206
206
  }
207
- if (msg.includes('http 5') || msg.includes('500') || msg.includes('502') || msg.includes('503')) {
207
+ if (msg.includes('http 5') || /\b5\d{2}\b/.test(msg)) {
208
208
  return 'A server error occurred. Please try again shortly.';
209
209
  }
210
210
  return null;
@@ -224,7 +224,7 @@
224
224
  if (msg.includes('cvv') || msg.includes('cvc') || msg.includes('security code')) {
225
225
  return 'The CVV code is invalid. Please check and try again.';
226
226
  }
227
- if (msg.includes('insufficient') || msg.includes('funds')) {
227
+ if (msg.includes('insufficient funds')) {
228
228
  return 'Your card has insufficient funds. Please use a different card.';
229
229
  }
230
230
  if (msg.includes('declined') || msg.includes('do not honor')) {
@@ -382,7 +382,7 @@
382
382
  * it never holds raw card data — all sensitive values live in the iframe.
383
383
  */
384
384
  class OzElement {
385
- constructor(elementType, options, vaultId, frameBaseUrl, fonts = [], appearanceStyle) {
385
+ constructor(elementType, options, vaultId, frameBaseUrl, fonts = [], appearanceStyle, onDestroy, debug = false) {
386
386
  this.iframe = null;
387
387
  this._frameWindow = null;
388
388
  this._ready = false;
@@ -390,6 +390,7 @@
390
390
  this._loadTimer = null;
391
391
  this.pendingMessages = [];
392
392
  this.handlers = new Map();
393
+ this.debug = false;
393
394
  this.elementType = elementType;
394
395
  this.options = sanitizeOptions(options);
395
396
  this.vaultId = vaultId;
@@ -398,6 +399,8 @@
398
399
  this.fonts = fonts;
399
400
  this.appearanceStyle = appearanceStyle;
400
401
  this.frameId = `oz-${elementType}-${uuid()}`;
402
+ this._onDestroy = onDestroy;
403
+ this.debug = debug;
401
404
  }
402
405
  /** The element type this proxy represents. */
403
406
  get type() {
@@ -437,11 +440,17 @@
437
440
  accountNumber: 'account number',
438
441
  routingNumber: 'routing number',
439
442
  }[this.elementType]) !== null && _a !== void 0 ? _a : this.elementType} input`;
440
- // Note: the `sandbox` attribute is intentionally NOT set. Field values are
441
- // delivered to the tokenizer iframe via a MessageChannel port (transferred
442
- // in OZ_BEGIN_COLLECT), so no window.parent named-frame lookup is needed.
443
- // The security boundary is the vault URL hardcoded at build time and the
444
- // origin checks on every postMessage, not the sandbox flag.
443
+ // sandbox="allow-scripts" gives correct iframe isolation:
444
+ // - Scripts run (allow-scripts), so the field JS executes normally.
445
+ // - NO allow-same-origin: the frame cannot access window.parent's DOM,
446
+ // localStorage, or cookies prevents sandbox escape even if served
447
+ // from the same origin.
448
+ // - NO allow-top-navigation: a rogue/compromised element frame cannot
449
+ // navigate window.top (clickjacking prevention).
450
+ // - NO allow-forms / allow-popups: reduces attack surface.
451
+ // Field values are delivered via postMessage, so no parent access is
452
+ // needed — allow-scripts alone is sufficient.
453
+ iframe.setAttribute('sandbox', 'allow-scripts');
445
454
  // Use hash instead of query string — survives clean-URL redirects from static servers.
446
455
  // parentOrigin lets the frame target postMessage to the merchant origin instead of '*'.
447
456
  const parentOrigin = typeof window !== 'undefined' ? window.location.origin : '';
@@ -553,9 +562,14 @@
553
562
  * and prevents future use. Distinct from `unmount()` which allows re-mounting.
554
563
  */
555
564
  destroy() {
565
+ var _a;
556
566
  this.unmount();
557
567
  this.handlers.clear();
558
568
  this._destroyed = true;
569
+ // Notify OzVault so it can prune the stale frameId entry from its elements
570
+ // and completionState maps. Without this, manually calling el.destroy() leaks
571
+ // map entries that grow unboundedly in SPA scenarios with repeated mount/unmount.
572
+ (_a = this._onDestroy) === null || _a === void 0 ? void 0 : _a.call(this);
559
573
  }
560
574
  // ─── Called by OzVault ───────────────────────────────────────────────────
561
575
  /**
@@ -592,7 +606,7 @@
592
606
  }
593
607
  this._frameWindow = (_b = (_a = this.iframe) === null || _a === void 0 ? void 0 : _a.contentWindow) !== null && _b !== void 0 ? _b : null;
594
608
  const mergedOptions = Object.assign(Object.assign({}, this.options), { style: mergeAppearanceWithElementStyle(this.appearanceStyle, this.options.style) });
595
- this.post(Object.assign({ type: 'OZ_INIT', elementType: this.elementType, options: sanitizeOptions(mergedOptions), frameId: this.frameId }, (this.fonts.length > 0 ? { fonts: this.fonts } : {})));
609
+ this.post(Object.assign({ type: 'OZ_INIT', elementType: this.elementType, options: sanitizeOptions(mergedOptions), frameId: this.frameId, debug: this.debug }, (this.fonts.length > 0 ? { fonts: this.fonts } : {})));
596
610
  this.pendingMessages.forEach(m => this.send(m));
597
611
  this.pendingMessages = [];
598
612
  this.emit('ready', undefined);
@@ -706,13 +720,14 @@
706
720
  // ─── Phone ───────────────────────────────────────────────────────────────────
707
721
  /**
708
722
  * Validates E.164 phone format: starts with +, 1–3 digit country code,
709
- * followed by 7–12 digits, total ≤50 characters.
723
+ * followed by 7–12 digits, max 15 digits total (E.164 spec cap = 16 chars
724
+ * including the leading +).
710
725
  *
711
726
  * Matches the output of checkout's formatPhoneForAPI() function.
712
727
  * Examples: "+15551234567", "+447911123456", "+61412345678"
713
728
  */
714
729
  function validateE164Phone(phone) {
715
- return /^\+[1-9]\d{6,49}$/.test(phone) && phone.length <= 50;
730
+ return /^\+[1-9]\d{6,14}$/.test(phone);
716
731
  }
717
732
  // ─── Field length ─────────────────────────────────────────────────────────────
718
733
  /** Returns true when the string is non-empty and ≤50 characters (cardSale schema). */
@@ -915,12 +930,12 @@
915
930
  *
916
931
  * @example
917
932
  * // Simplest — just pass sessionUrl, no need to call this directly
918
- * const vault = await OzVault.create({ pubKey: 'pk_live_...', sessionUrl: '/api/oz-session' });
933
+ * const vault = await OzVault.create({ pubKey: 'pk_prod_...', sessionUrl: '/api/oz-session' });
919
934
  *
920
935
  * @example
921
936
  * // Manual — use when you need custom headers
922
937
  * const vault = await OzVault.create({
923
- * pubKey: 'pk_live_...',
938
+ * pubKey: 'pk_prod_...',
924
939
  * getSessionKey: createSessionFetcher('/api/oz-session'),
925
940
  * });
926
941
  */
@@ -958,7 +973,22 @@
958
973
  throw new OzError(`Could not reach session endpoint (${url}): ${msg}`, undefined, 'network');
959
974
  }
960
975
  }
961
- const data = await res.json().catch(() => ({}));
976
+ // Parse JSON separately from the ok-check so that a non-JSON error body
977
+ // (HTML error page, WAF block, CDN 503) produces the right error code.
978
+ // Previously res.json() was attempted before res.ok was checked; a parse
979
+ // failure on a 5xx HTML body would fall through as {} and produce a
980
+ // misleading 'validation' code when the real cause is a server/network issue.
981
+ let data = {};
982
+ try {
983
+ data = await res.json();
984
+ }
985
+ catch (_a) {
986
+ if (!res.ok) {
987
+ 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');
988
+ }
989
+ // HTTP 200 but body isn't JSON — this is a misconfigured session endpoint.
990
+ throw new OzError('Session endpoint returned HTTP 200 but the response body is not valid JSON. Check your /api/oz-session implementation.', undefined, 'validation');
991
+ }
962
992
  if (!res.ok) {
963
993
  throw new OzError(typeof data.error === 'string' && data.error
964
994
  ? data.error
@@ -976,10 +1006,19 @@
976
1006
  }
977
1007
 
978
1008
  function isCardMetadata(v) {
979
- return !!v && typeof v === 'object' && typeof v.last4 === 'string';
1009
+ if (!v || typeof v !== 'object')
1010
+ return false;
1011
+ const r = v;
1012
+ return (typeof r.last4 === 'string' &&
1013
+ typeof r.brand === 'string' &&
1014
+ typeof r.expMonth === 'string' &&
1015
+ typeof r.expYear === 'string');
980
1016
  }
981
1017
  function isBankAccountMetadata(v) {
982
- return !!v && typeof v === 'object' && typeof v.last4 === 'string';
1018
+ if (!v || typeof v !== 'object')
1019
+ return false;
1020
+ const r = v;
1021
+ return typeof r.last4 === 'string' && typeof r.routingNumberLast4 === 'string';
983
1022
  }
984
1023
  const DEFAULT_FRAME_BASE_URL = "https://elements.ozura.com";
985
1024
  /**
@@ -989,16 +1028,10 @@
989
1028
  * Use the static `OzVault.create()` factory — do not call `new OzVault()` directly.
990
1029
  *
991
1030
  * @example
1031
+ * // Recommended — pass sessionUrl and let the SDK call your backend automatically
992
1032
  * const vault = await OzVault.create({
993
- * pubKey: 'pk_live_...',
994
- * fetchWaxKey: async (sessionId) => {
995
- * // Call your backend — which calls ozura.mintWaxKey() from @ozura/elements/server
996
- * const { waxKey } = await fetch('/api/mint-wax', {
997
- * method: 'POST',
998
- * body: JSON.stringify({ sessionId }),
999
- * }).then(r => r.json());
1000
- * return waxKey;
1001
- * },
1033
+ * pubKey: 'pk_prod_...', // or 'pk_test_...' for test mode
1034
+ * sessionUrl: '/api/oz-session', // backend endpoint that calls ozura.createSession()
1002
1035
  * });
1003
1036
  * const cardNum = vault.createElement('cardNumber');
1004
1037
  * cardNum.mount('#card-number');
@@ -1044,6 +1077,11 @@
1044
1077
  this.tokenizationSessionId = tokenizationSessionId;
1045
1078
  this.pubKey = options.pubKey;
1046
1079
  this.frameBaseUrl = options.frameBaseUrl || DEFAULT_FRAME_BASE_URL;
1080
+ // Validate immediately after assignment
1081
+ if (!this.frameBaseUrl.startsWith('https://') &&
1082
+ !this.frameBaseUrl.startsWith('http://localhost')) {
1083
+ throw new OzError('frameBaseUrl must use HTTPS (http://localhost is allowed for local development)', undefined, 'config');
1084
+ }
1047
1085
  this.frameOrigin = new URL(this.frameBaseUrl).origin;
1048
1086
  this.fonts = (_a = options.fonts) !== null && _a !== void 0 ? _a : [];
1049
1087
  this.resolvedAppearance = resolveAppearance(options.appearance);
@@ -1152,7 +1190,7 @@
1152
1190
  // the OZ_INIT sent at that point had an empty waxKey. Send a follow-up now
1153
1191
  // so the tokenizer has the key stored before any createToken() call.
1154
1192
  if (vault.tokenizerReady) {
1155
- vault.sendToTokenizer({ type: 'OZ_INIT', frameId: '__tokenizer__', waxKey });
1193
+ vault.sendToTokenizer({ type: 'OZ_INIT', frameId: '__tokenizer__', waxKey, debug: vault._debug });
1156
1194
  }
1157
1195
  vault.log('wax key received — vault ready');
1158
1196
  return vault;
@@ -1234,7 +1272,12 @@
1234
1272
  this.completionState.delete(existing.frameId);
1235
1273
  existing.destroy();
1236
1274
  }
1237
- const el = new OzElement(type, options, this.vaultId, this.frameBaseUrl, this.fonts, this.resolvedAppearance);
1275
+ const el = new OzElement(type, options, this.vaultId, this.frameBaseUrl, this.fonts, this.resolvedAppearance, () => {
1276
+ // Prune vault-level maps when the element is manually destroyed so they
1277
+ // don't grow unboundedly in SPA scenarios with repeated mount/unmount cycles.
1278
+ this.elements.delete(el.frameId);
1279
+ this.completionState.delete(el.frameId);
1280
+ }, this._debug);
1238
1281
  this.elements.set(el.frameId, el);
1239
1282
  typeMap.set(type, el);
1240
1283
  return el;
@@ -1317,7 +1360,6 @@
1317
1360
  this.sendToTokenizer({
1318
1361
  type: 'OZ_BANK_TOKENIZE',
1319
1362
  requestId,
1320
- waxKey: this.waxKey,
1321
1363
  tokenizationSessionId: this.tokenizationSessionId,
1322
1364
  pubKey: this.pubKey,
1323
1365
  firstName: options.firstName.trim(),
@@ -1325,7 +1367,10 @@
1325
1367
  fieldCount: readyBankElements.length,
1326
1368
  }, bankChannels.map(ch => ch.port1));
1327
1369
  this.log('OZ_BANK_TOKENIZE sent', { requestIdPrefix: `${requestId.slice(0, 12)}...`, fieldCount: readyBankElements.length });
1328
- readyBankElements.forEach((el, i) => el.beginCollect(requestId, bankChannels[i].port2));
1370
+ readyBankElements.forEach((el, i) => {
1371
+ this.log('OZ_BEGIN_COLLECT dispatched', { type: el.type, requestIdPrefix: `${requestId.slice(0, 12)}...` });
1372
+ el.beginCollect(requestId, bankChannels[i].port2);
1373
+ });
1329
1374
  const bankTimeoutId = setTimeout(() => {
1330
1375
  if (this.bankTokenizeResolvers.has(requestId)) {
1331
1376
  this.bankTokenizeResolvers.delete(requestId);
@@ -1409,7 +1454,11 @@
1409
1454
  }
1410
1455
  this._tokenizing = 'card';
1411
1456
  const requestId = `req-${uuid()}`;
1412
- this.log('createToken() called');
1457
+ this.log('createToken() called', {
1458
+ requestIdPrefix: requestId.slice(0, 12),
1459
+ fields: readyElements.map(el => el.type),
1460
+ billingPresent: Boolean(options.billing),
1461
+ });
1413
1462
  return new Promise((resolve, reject) => {
1414
1463
  // Capture the reset generation so cleanup() only zeros _tokenizing when it
1415
1464
  // still belongs to this invocation — not a newer one that started after a reset.
@@ -1434,7 +1483,6 @@
1434
1483
  this.sendToTokenizer({
1435
1484
  type: 'OZ_TOKENIZE',
1436
1485
  requestId,
1437
- waxKey: this.waxKey,
1438
1486
  tokenizationSessionId: this.tokenizationSessionId,
1439
1487
  pubKey: this.pubKey,
1440
1488
  firstName,
@@ -1447,7 +1495,10 @@
1447
1495
  if (cardEntry)
1448
1496
  cardEntry.tokenizeStartMs = tokenizeStartMs;
1449
1497
  // Tell each ready element frame to send its raw value to the tokenizer
1450
- readyElements.forEach((el, i) => el.beginCollect(requestId, cardChannels[i].port2));
1498
+ readyElements.forEach((el, i) => {
1499
+ this.log('OZ_BEGIN_COLLECT dispatched', { type: el.type, requestIdPrefix: `${requestId.slice(0, 12)}...` });
1500
+ el.beginCollect(requestId, cardChannels[i].port2);
1501
+ });
1451
1502
  const cardTimeoutId = setTimeout(() => {
1452
1503
  if (this.tokenizeResolvers.has(requestId)) {
1453
1504
  this.tokenizeResolvers.delete(requestId);
@@ -1500,6 +1551,7 @@
1500
1551
  this.tokenizeResolvers.forEach(({ reject, timeoutId }, requestId) => {
1501
1552
  if (timeoutId != null)
1502
1553
  clearTimeout(timeoutId);
1554
+ this.log('OZ_TOKENIZE_CANCEL sent (reset)', { requestIdPrefix: `${requestId.slice(0, 12)}...` });
1503
1555
  this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId });
1504
1556
  reject(new OzError('Vault was reset while tokenization was in progress.'));
1505
1557
  });
@@ -1507,6 +1559,7 @@
1507
1559
  this.bankTokenizeResolvers.forEach(({ reject, timeoutId }, requestId) => {
1508
1560
  if (timeoutId != null)
1509
1561
  clearTimeout(timeoutId);
1562
+ this.log('OZ_TOKENIZE_CANCEL sent (reset, bank)', { requestIdPrefix: `${requestId.slice(0, 12)}...` });
1510
1563
  this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId });
1511
1564
  reject(new OzError('Vault was reset while tokenization was in progress.'));
1512
1565
  });
@@ -1681,7 +1734,7 @@
1681
1734
  }
1682
1735
  }
1683
1736
  handleMessage(event) {
1684
- var _a;
1737
+ var _a, _b;
1685
1738
  if (this._destroyed)
1686
1739
  return;
1687
1740
  // Only accept messages from our frame origin (defense in depth; prevents
@@ -1696,6 +1749,15 @@
1696
1749
  this.handleTokenizerMessage(msg);
1697
1750
  return;
1698
1751
  }
1752
+ // OZ_TOKEN_ERROR can arrive from element frames when the MessagePort
1753
+ // transferred in OZ_BEGIN_COLLECT was dropped by the browser (e.g. the
1754
+ // frame navigated). These carry a requestId but no frameId — route them
1755
+ // through handleTokenizerMessage so the pending promise is rejected
1756
+ // immediately rather than waiting for the 30 s collect timeout.
1757
+ if (msg.type === 'OZ_TOKEN_ERROR') {
1758
+ this.handleTokenizerMessage(msg);
1759
+ return;
1760
+ }
1699
1761
  // Route to the matching element
1700
1762
  const frameId = msg.frameId;
1701
1763
  if (frameId) {
@@ -1713,6 +1775,23 @@
1713
1775
  }
1714
1776
  this.log('element iframe ready', { type: el.type, frameIdPrefix: frameId.slice(0, 8) });
1715
1777
  }
1778
+ // Relay debug/warning messages from element iframes into the parent
1779
+ // DevTools console. Element frames run in cross-origin iframes whose
1780
+ // console context is invisible to developers without a frame selector switch.
1781
+ // Errors are always surfaced (genuine failures). Warnings are only emitted
1782
+ // when debug mode is on — CSS var() and font-host warnings fire on every
1783
+ // style update and would pollute production consoles otherwise.
1784
+ if (msg.type === 'OZ_DEBUG_LOG') {
1785
+ const level = typeof msg.level === 'string' ? msg.level : 'warn';
1786
+ const message = typeof msg.message === 'string' ? msg.message : String((_b = msg.message) !== null && _b !== void 0 ? _b : '');
1787
+ if (level === 'error') {
1788
+ console.error(`[OzVault:${el.type}] ${message}`);
1789
+ }
1790
+ else if (this._debug) {
1791
+ console.warn(`[OzVault:${el.type}] ${message}`);
1792
+ }
1793
+ return;
1794
+ }
1716
1795
  // Intercept OZ_CHANGE before forwarding — handle auto-advance and CVV sync
1717
1796
  if (msg.type === 'OZ_CHANGE') {
1718
1797
  this.handleElementChange(msg, el);
@@ -1757,7 +1836,7 @@
1757
1836
  }
1758
1837
  }
1759
1838
  handleTokenizerMessage(msg) {
1760
- var _a, _b, _c, _d;
1839
+ var _a, _b, _c, _d, _e;
1761
1840
  switch (msg.type) {
1762
1841
  case 'OZ_FRAME_READY':
1763
1842
  if (msg.__ozVersion !== PROTOCOL_VERSION) {
@@ -1775,9 +1854,10 @@
1775
1854
  // Deliver the wax key via OZ_INIT so the tokenizer stores it internally.
1776
1855
  // If waxKey is still empty (fetchWaxKey hasn't resolved yet), it will be
1777
1856
  // sent again from create() once the key is available.
1778
- this.sendToTokenizer(Object.assign({ type: 'OZ_INIT', frameId: '__tokenizer__' }, (this.waxKey ? { waxKey: this.waxKey } : {})));
1857
+ this.sendToTokenizer(Object.assign(Object.assign({ type: 'OZ_INIT', frameId: '__tokenizer__' }, (this.waxKey ? { waxKey: this.waxKey } : {})), { debug: this._debug }));
1779
1858
  (_c = this._onReady) === null || _c === void 0 ? void 0 : _c.call(this);
1780
1859
  this.log('tokenizer iframe ready', { protocolVersion: (_d = msg.__ozVersion) !== null && _d !== void 0 ? _d : null });
1860
+ this.log('vault state', this.debugState());
1781
1861
  break;
1782
1862
  case 'OZ_TOKEN_RESULT': {
1783
1863
  if (typeof msg.requestId !== 'string' || !msg.requestId) {
@@ -1803,6 +1883,7 @@
1803
1883
  pending.resolve(Object.assign(Object.assign({ token,
1804
1884
  cvcSession }, (card ? { card } : {})), (pending.billing ? { billing: pending.billing } : {})));
1805
1885
  this.log('token received', {
1886
+ requestIdPrefix: msg.requestId.slice(0, 12),
1806
1887
  elapsedMs: pending.tokenizeStartMs != null ? Date.now() - pending.tokenizeStartMs : null,
1807
1888
  tokenPresent: true,
1808
1889
  cvcSessionPresent: true,
@@ -1834,7 +1915,7 @@
1834
1915
  if (pending.timeoutId != null)
1835
1916
  clearTimeout(pending.timeoutId);
1836
1917
  const willRefresh = this.isRefreshableAuthError(errorCode, raw) && !pending.retried && Boolean(this._storedFetchWaxKey);
1837
- this.log('token error', { errorCode, willRefresh });
1918
+ this.log('token error', { requestIdPrefix: msg.requestId.slice(0, 12), errorCode, willRefresh });
1838
1919
  // Auto-refresh: if the wax key expired or was consumed and we haven't
1839
1920
  // already retried for this request, transparently re-mint and retry.
1840
1921
  if (willRefresh) {
@@ -1852,6 +1933,17 @@
1852
1933
  pending.reject(new OzError('Vault was reset while tokenization was in progress.'));
1853
1934
  return;
1854
1935
  }
1936
+ // Verify all elements from the original call are still mounted and
1937
+ // ready. If any were destroyed (e.g. the merchant called
1938
+ // createElement() or destroy() during the async refresh), the
1939
+ // beginCollect() calls below would silently no-op — the tokenizer
1940
+ // would wait forever for field values that never arrive. Reject
1941
+ // immediately with a clear message instead of hanging 30 seconds.
1942
+ const allCardElementsStillReady = pending.readyElements.every(el => this.elements.has(el.frameId) && el.isReady);
1943
+ if (!allCardElementsStillReady) {
1944
+ pending.reject(new OzError('Card fields changed during session refresh. Please re-enter your card details.'));
1945
+ return;
1946
+ }
1855
1947
  const newRequestId = `req-${uuid()}`;
1856
1948
  // _tokenizing is still 'card' (cleanup() hasn't been called yet)
1857
1949
  this.tokenizeResolvers.set(newRequestId, Object.assign(Object.assign({}, pending), { retried: true }));
@@ -1860,14 +1952,16 @@
1860
1952
  this.sendToTokenizer({
1861
1953
  type: 'OZ_TOKENIZE',
1862
1954
  requestId: newRequestId,
1863
- waxKey: this.waxKey,
1864
1955
  tokenizationSessionId: this.tokenizationSessionId,
1865
1956
  pubKey: this.pubKey,
1866
1957
  firstName: pending.firstName,
1867
1958
  lastName: pending.lastName,
1868
1959
  fieldCount: pending.fieldCount,
1869
1960
  }, retryCardChannels.map(ch => ch.port1));
1870
- pending.readyElements.forEach((el, i) => el.beginCollect(newRequestId, retryCardChannels[i].port2));
1961
+ pending.readyElements.forEach((el, i) => {
1962
+ this.log('OZ_BEGIN_COLLECT dispatched (retry)', { type: el.type, requestIdPrefix: `${newRequestId.slice(0, 12)}...` });
1963
+ el.beginCollect(newRequestId, retryCardChannels[i].port2);
1964
+ });
1871
1965
  const retryCardTimeoutId = setTimeout(() => {
1872
1966
  if (this.tokenizeResolvers.has(newRequestId)) {
1873
1967
  this.tokenizeResolvers.delete(newRequestId);
@@ -1884,68 +1978,80 @@
1884
1978
  pending.reject(setupErr instanceof OzError ? setupErr : new OzError('Retry tokenization failed to start'));
1885
1979
  }
1886
1980
  }).catch((refreshErr) => {
1887
- const msg = refreshErr instanceof Error ? refreshErr.message : 'Wax key refresh failed';
1888
- pending.reject(new OzError(msg, undefined, 'auth'));
1981
+ const errMsg = refreshErr instanceof Error ? refreshErr.message : 'Wax key refresh failed';
1982
+ pending.reject(new OzError(errMsg, undefined, 'auth'));
1889
1983
  });
1890
1984
  break;
1891
1985
  }
1892
1986
  pending.reject(new OzError(normalizeVaultError(raw), raw, errorCode));
1893
1987
  }
1894
- // Also check bank resolvers — both card and bank errors use OZ_TOKEN_ERROR
1895
- const bankPending = this.bankTokenizeResolvers.get(msg.requestId);
1896
- if (bankPending) {
1897
- this.bankTokenizeResolvers.delete(msg.requestId);
1898
- if (bankPending.timeoutId != null)
1899
- clearTimeout(bankPending.timeoutId);
1900
- if (this.isRefreshableAuthError(errorCode, raw) && !bankPending.retried && this._storedFetchWaxKey) {
1901
- const resetCountAtRetry = this._resetCount;
1902
- this.refreshWaxKey().then(() => {
1903
- if (this._destroyed) {
1904
- bankPending.reject(new OzError('Vault destroyed during wax key refresh.'));
1905
- return;
1906
- }
1907
- if (this._resetCount !== resetCountAtRetry) {
1908
- bankPending.reject(new OzError('Vault was reset while tokenization was in progress.'));
1909
- return;
1910
- }
1911
- const newRequestId = `req-${uuid()}`;
1912
- this.bankTokenizeResolvers.set(newRequestId, Object.assign(Object.assign({}, bankPending), { retried: true }));
1913
- try {
1914
- const retryBankChannels = bankPending.readyElements.map(() => new MessageChannel());
1915
- this.sendToTokenizer({
1916
- type: 'OZ_BANK_TOKENIZE',
1917
- requestId: newRequestId,
1918
- waxKey: this.waxKey,
1919
- tokenizationSessionId: this.tokenizationSessionId,
1920
- pubKey: this.pubKey,
1921
- firstName: bankPending.firstName,
1922
- lastName: bankPending.lastName,
1923
- fieldCount: bankPending.fieldCount,
1924
- }, retryBankChannels.map(ch => ch.port1));
1925
- bankPending.readyElements.forEach((el, i) => el.beginCollect(newRequestId, retryBankChannels[i].port2));
1926
- const retryBankTimeoutId = setTimeout(() => {
1927
- if (this.bankTokenizeResolvers.has(newRequestId)) {
1928
- this.bankTokenizeResolvers.delete(newRequestId);
1929
- this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId: newRequestId });
1930
- bankPending.reject(new OzError('Bank tokenization timed out after wax key refresh.', undefined, 'timeout'));
1931
- }
1932
- }, 30000);
1933
- const retryBankEntry = this.bankTokenizeResolvers.get(newRequestId);
1934
- if (retryBankEntry)
1935
- retryBankEntry.timeoutId = retryBankTimeoutId;
1936
- }
1937
- catch (setupErr) {
1938
- this.bankTokenizeResolvers.delete(newRequestId);
1939
- bankPending.reject(setupErr instanceof OzError ? setupErr : new OzError('Retry bank tokenization failed to start'));
1940
- }
1941
- }).catch((refreshErr) => {
1942
- const msg = refreshErr instanceof Error ? refreshErr.message : 'Wax key refresh failed';
1943
- bankPending.reject(new OzError(msg, undefined, 'auth'));
1944
- });
1945
- break;
1988
+ else {
1989
+ // Also check bank resolvers — both card and bank errors use OZ_TOKEN_ERROR.
1990
+ // Use else-if rather than sequential checks so a UUID collision (however
1991
+ // improbable) can never trigger double-rejection of two unrelated resolvers.
1992
+ const bankPending = this.bankTokenizeResolvers.get(msg.requestId);
1993
+ if (bankPending) {
1994
+ this.bankTokenizeResolvers.delete(msg.requestId);
1995
+ if (bankPending.timeoutId != null)
1996
+ clearTimeout(bankPending.timeoutId);
1997
+ if (this.isRefreshableAuthError(errorCode, raw) && !bankPending.retried && this._storedFetchWaxKey) {
1998
+ const resetCountAtRetry = this._resetCount;
1999
+ this.refreshWaxKey().then(() => {
2000
+ if (this._destroyed) {
2001
+ bankPending.reject(new OzError('Vault destroyed during wax key refresh.'));
2002
+ return;
2003
+ }
2004
+ if (this._resetCount !== resetCountAtRetry) {
2005
+ bankPending.reject(new OzError('Vault was reset while tokenization was in progress.'));
2006
+ return;
2007
+ }
2008
+ // Same stale-element guard as the card retry path above.
2009
+ const allBankElementsStillReady = bankPending.readyElements.every(el => this.elements.has(el.frameId) && el.isReady);
2010
+ if (!allBankElementsStillReady) {
2011
+ bankPending.reject(new OzError('Bank fields changed during session refresh. Please re-enter your account details.'));
2012
+ return;
2013
+ }
2014
+ const newRequestId = `req-${uuid()}`;
2015
+ this.bankTokenizeResolvers.set(newRequestId, Object.assign(Object.assign({}, bankPending), { retried: true }));
2016
+ try {
2017
+ const retryBankChannels = bankPending.readyElements.map(() => new MessageChannel());
2018
+ this.sendToTokenizer({
2019
+ type: 'OZ_BANK_TOKENIZE',
2020
+ requestId: newRequestId,
2021
+ tokenizationSessionId: this.tokenizationSessionId,
2022
+ pubKey: this.pubKey,
2023
+ firstName: bankPending.firstName,
2024
+ lastName: bankPending.lastName,
2025
+ fieldCount: bankPending.fieldCount,
2026
+ }, retryBankChannels.map(ch => ch.port1));
2027
+ bankPending.readyElements.forEach((el, i) => {
2028
+ this.log('OZ_BEGIN_COLLECT dispatched (retry)', { type: el.type, requestIdPrefix: `${newRequestId.slice(0, 12)}...` });
2029
+ el.beginCollect(newRequestId, retryBankChannels[i].port2);
2030
+ });
2031
+ const retryBankTimeoutId = setTimeout(() => {
2032
+ if (this.bankTokenizeResolvers.has(newRequestId)) {
2033
+ this.bankTokenizeResolvers.delete(newRequestId);
2034
+ this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId: newRequestId });
2035
+ bankPending.reject(new OzError('Bank tokenization timed out after wax key refresh.', undefined, 'timeout'));
2036
+ }
2037
+ }, 30000);
2038
+ const retryBankEntry = this.bankTokenizeResolvers.get(newRequestId);
2039
+ if (retryBankEntry)
2040
+ retryBankEntry.timeoutId = retryBankTimeoutId;
2041
+ }
2042
+ catch (setupErr) {
2043
+ this.bankTokenizeResolvers.delete(newRequestId);
2044
+ bankPending.reject(setupErr instanceof OzError ? setupErr : new OzError('Retry bank tokenization failed to start'));
2045
+ }
2046
+ }).catch((refreshErr) => {
2047
+ const errMsg = refreshErr instanceof Error ? refreshErr.message : 'Wax key refresh failed';
2048
+ bankPending.reject(new OzError(errMsg, undefined, 'auth'));
2049
+ });
2050
+ break;
2051
+ }
2052
+ bankPending.reject(new OzError(normalizeBankVaultError(raw), raw, errorCode));
1946
2053
  }
1947
- bankPending.reject(new OzError(normalizeBankVaultError(raw), raw, errorCode));
1948
- }
2054
+ } // end else (bank path)
1949
2055
  break;
1950
2056
  }
1951
2057
  case 'OZ_BANK_TOKEN_RESULT': {
@@ -1981,6 +2087,23 @@
1981
2087
  }
1982
2088
  break;
1983
2089
  }
2090
+ case 'OZ_DEBUG_LOG': {
2091
+ // Relay warnings/errors from the tokenizer iframe into the parent page's
2092
+ // DevTools console. The tokenizer runs in a cross-origin iframe whose
2093
+ // console context is invisible to most developers unless they manually
2094
+ // switch the DevTools frame selector.
2095
+ // Errors are always surfaced; warnings only in debug mode to avoid
2096
+ // polluting production consoles.
2097
+ const level = typeof msg.level === 'string' ? msg.level : 'warn';
2098
+ const message = typeof msg.message === 'string' ? msg.message : String((_e = msg.message) !== null && _e !== void 0 ? _e : '');
2099
+ if (level === 'error') {
2100
+ console.error(`[OzVault:tokenizer] ${message}`);
2101
+ }
2102
+ else if (this._debug) {
2103
+ console.warn(`[OzVault:tokenizer] ${message}`);
2104
+ }
2105
+ break;
2106
+ }
1984
2107
  }
1985
2108
  }
1986
2109
  /**
@@ -2029,6 +2152,7 @@
2029
2152
  }
2030
2153
  const newSessionId = uuid();
2031
2154
  (_a = this._onWaxRefresh) === null || _a === void 0 ? void 0 : _a.call(this);
2155
+ const refreshStartMs = Date.now();
2032
2156
  this.log('wax key refresh started');
2033
2157
  this._waxRefreshing = this._storedFetchWaxKey(newSessionId)
2034
2158
  .then(newWaxKey => {
@@ -2041,12 +2165,12 @@
2041
2165
  this._tokenizeSuccessCount = 0;
2042
2166
  }
2043
2167
  if (!this._destroyed && this.tokenizerReady) {
2044
- this.sendToTokenizer({ type: 'OZ_INIT', frameId: '__tokenizer__', waxKey: newWaxKey });
2168
+ this.sendToTokenizer({ type: 'OZ_INIT', frameId: '__tokenizer__', waxKey: newWaxKey, debug: this._debug });
2045
2169
  }
2046
- this.log('wax key refresh succeeded');
2170
+ this.log('wax key refresh succeeded', { durationMs: Date.now() - refreshStartMs });
2047
2171
  })
2048
2172
  .catch((err) => {
2049
- this.log('wax key refresh failed', { error: err instanceof Error ? err.message : String(err) });
2173
+ this.log('wax key refresh failed', { error: err instanceof Error ? err.message : String(err), durationMs: Date.now() - refreshStartMs });
2050
2174
  throw err;
2051
2175
  })
2052
2176
  .finally(() => {