@ozura/elements 0.1.0-beta.6 → 1.0.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.
Files changed (50) hide show
  1. package/README.md +1121 -720
  2. package/dist/frame/element-frame.js +77 -57
  3. package/dist/frame/element-frame.js.map +1 -1
  4. package/dist/frame/tokenizer-frame.html +1 -1
  5. package/dist/frame/tokenizer-frame.js +221 -74
  6. package/dist/frame/tokenizer-frame.js.map +1 -1
  7. package/dist/oz-elements.esm.js +870 -231
  8. package/dist/oz-elements.esm.js.map +1 -1
  9. package/dist/oz-elements.umd.js +870 -230
  10. package/dist/oz-elements.umd.js.map +1 -1
  11. package/dist/react/frame/tokenizerFrame.d.ts +32 -0
  12. package/dist/react/index.cjs.js +1045 -220
  13. package/dist/react/index.cjs.js.map +1 -1
  14. package/dist/react/index.esm.js +1042 -221
  15. package/dist/react/index.esm.js.map +1 -1
  16. package/dist/react/react/index.d.ts +165 -8
  17. package/dist/react/sdk/OzElement.d.ts +34 -3
  18. package/dist/react/sdk/OzVault.d.ts +104 -4
  19. package/dist/react/sdk/errors.d.ts +9 -0
  20. package/dist/react/sdk/index.d.ts +29 -0
  21. package/dist/react/server/index.d.ts +266 -2
  22. package/dist/react/types/index.d.ts +94 -16
  23. package/dist/react/utils/appearance.d.ts +9 -0
  24. package/dist/react/utils/cardUtils.d.ts +14 -0
  25. package/dist/react/utils/uuid.d.ts +12 -0
  26. package/dist/server/frame/tokenizerFrame.d.ts +32 -0
  27. package/dist/server/index.cjs.js +761 -30
  28. package/dist/server/index.cjs.js.map +1 -1
  29. package/dist/server/index.esm.js +757 -31
  30. package/dist/server/index.esm.js.map +1 -1
  31. package/dist/server/sdk/OzElement.d.ts +34 -3
  32. package/dist/server/sdk/OzVault.d.ts +104 -4
  33. package/dist/server/sdk/errors.d.ts +9 -0
  34. package/dist/server/sdk/index.d.ts +29 -0
  35. package/dist/server/server/index.d.ts +266 -2
  36. package/dist/server/types/index.d.ts +94 -16
  37. package/dist/server/utils/appearance.d.ts +9 -0
  38. package/dist/server/utils/cardUtils.d.ts +14 -0
  39. package/dist/server/utils/uuid.d.ts +12 -0
  40. package/dist/types/frame/tokenizerFrame.d.ts +32 -0
  41. package/dist/types/sdk/OzElement.d.ts +34 -3
  42. package/dist/types/sdk/OzVault.d.ts +104 -4
  43. package/dist/types/sdk/errors.d.ts +9 -0
  44. package/dist/types/sdk/index.d.ts +29 -0
  45. package/dist/types/server/index.d.ts +266 -2
  46. package/dist/types/types/index.d.ts +94 -16
  47. package/dist/types/utils/appearance.d.ts +9 -0
  48. package/dist/types/utils/cardUtils.d.ts +14 -0
  49. package/dist/types/utils/uuid.d.ts +12 -0
  50. package/package.json +7 -4
@@ -1,3 +1,144 @@
1
+ /**
2
+ * errors.ts — error types and normalisation for OzElements.
3
+ *
4
+ * Three normalisation functions:
5
+ * - normalizeVaultError — maps raw vault /tokenize errors to user-facing messages (card flows)
6
+ * - normalizeBankVaultError — maps raw vault /tokenize errors to user-facing messages (bank/ACH flows)
7
+ * - normalizeCardSaleError — maps raw cardSale API errors to user-facing messages
8
+ *
9
+ * Error keys in normalizeCardSaleError are taken directly from checkout's
10
+ * errorMapping.ts so the same error strings produce the same user-facing copy.
11
+ */
12
+ const OZ_ERROR_CODES = new Set(['network', 'timeout', 'auth', 'validation', 'server', 'config', 'unknown']);
13
+ /** Returns true and narrows to OzErrorCode when `value` is a valid member of the union. */
14
+ function isOzErrorCode(value) {
15
+ return typeof value === 'string' && OZ_ERROR_CODES.has(value);
16
+ }
17
+ class OzError extends Error {
18
+ constructor(message, raw, errorCode) {
19
+ super(message);
20
+ this.name = 'OzError';
21
+ this.raw = raw !== null && raw !== void 0 ? raw : message;
22
+ this.errorCode = errorCode !== null && errorCode !== void 0 ? errorCode : 'unknown';
23
+ this.retryable = this.errorCode === 'network' || this.errorCode === 'timeout' || this.errorCode === 'server';
24
+ }
25
+ }
26
+ /** Shared patterns that apply to both card and bank vault errors. */
27
+ function normalizeCommonVaultError(msg) {
28
+ if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('authentication') || msg.includes('forbidden')) {
29
+ return 'Authentication failed. Check your vault API key configuration.';
30
+ }
31
+ if (msg.includes('network') || msg.includes('failed to fetch') || msg.includes('networkerror')) {
32
+ return 'A network error occurred. Please check your connection and try again.';
33
+ }
34
+ if (msg.includes('timeout') || msg.includes('timed out')) {
35
+ return 'The request timed out. Please try again.';
36
+ }
37
+ if (msg.includes('http 5') || msg.includes('500') || msg.includes('502') || msg.includes('503')) {
38
+ return 'A server error occurred. Please try again shortly.';
39
+ }
40
+ return null;
41
+ }
42
+ /**
43
+ * Maps a raw vault /tokenize error string to a user-facing message for card flows.
44
+ * Falls back to the original string if no pattern matches.
45
+ */
46
+ function normalizeVaultError(raw) {
47
+ const msg = raw.toLowerCase();
48
+ if (msg.includes('card number') || msg.includes('invalid card') || msg.includes('luhn')) {
49
+ return 'The card number is invalid. Please check and try again.';
50
+ }
51
+ if (msg.includes('expir')) {
52
+ return 'The card expiration date is invalid or the card has expired.';
53
+ }
54
+ if (msg.includes('cvv') || msg.includes('cvc') || msg.includes('security code')) {
55
+ return 'The CVV code is invalid. Please check and try again.';
56
+ }
57
+ if (msg.includes('insufficient') || msg.includes('funds')) {
58
+ return 'Your card has insufficient funds. Please use a different card.';
59
+ }
60
+ if (msg.includes('declined') || msg.includes('do not honor')) {
61
+ return 'Your card was declined. Please try a different card or contact your bank.';
62
+ }
63
+ const common = normalizeCommonVaultError(msg);
64
+ if (common)
65
+ return common;
66
+ return raw;
67
+ }
68
+ /**
69
+ * Maps a raw vault /tokenize error string to a user-facing message for bank (ACH) flows.
70
+ * Uses bank-specific pattern matching so card-specific messages are never shown for
71
+ * bank errors. Falls back to the original string if no pattern matches.
72
+ */
73
+ function normalizeBankVaultError(raw) {
74
+ const msg = raw.toLowerCase();
75
+ if (msg.includes('account number') || msg.includes('account_number') || msg.includes('invalid account')) {
76
+ return 'The bank account number is invalid. Please check and try again.';
77
+ }
78
+ if (msg.includes('routing number') || msg.includes('routing_number') || msg.includes('invalid routing') || /\baba\b/.test(msg)) {
79
+ return 'The routing number is invalid. Please check and try again.';
80
+ }
81
+ const common = normalizeCommonVaultError(msg);
82
+ if (common)
83
+ return common;
84
+ return raw;
85
+ }
86
+ // ─── cardSale error map (mirrors checkout/src/utils/errorMapping.ts exactly) ─
87
+ const CARD_SALE_ERROR_MAP = [
88
+ ['Insufficient Funds', 'Your card has insufficient funds. Please use a different payment method.'],
89
+ ['Invalid card number', 'The card number you entered is invalid. Please check and try again.'],
90
+ ['Card expired', 'Your card has expired. Please use a different card.'],
91
+ ['CVV Verification Failed', 'The CVV code you entered is incorrect. Please check and try again.'],
92
+ ['Address Verification Failed', 'The billing address does not match your card. Please verify your address.'],
93
+ ['Do Not Honor', 'Your card was declined. Please contact your bank or use a different payment method.'],
94
+ ['Declined', 'Your card was declined. Please contact your bank or use a different payment method.'],
95
+ ['Surcharge is currently not supported', 'Surcharge fees are not supported at this time.'],
96
+ ['Surcharge percent must be between', 'Surcharge fees are not supported at this time.'],
97
+ ['Forbidden - API key', 'Authentication failed. Please refresh the page.'],
98
+ ['Api Key is invalid', 'Authentication failed. Please refresh the page.'],
99
+ ['API key not found', 'Authentication failed. Please refresh the page.'],
100
+ ['Access token expired', 'Your session has expired. Please refresh the page.'],
101
+ ['Access token is invalid', 'Authentication failed. Please refresh the page.'],
102
+ ['Unauthorized', 'Authentication failed. Please refresh the page.'],
103
+ ['Too Many Requests', 'Too many requests. Please wait a moment and try again.'],
104
+ ['Rate limit exceeded', 'System is busy. Please wait a moment and try again.'],
105
+ ['No processor integrations found', 'Payment processing is not configured for this merchant. Please contact the merchant for assistance.'],
106
+ ['processor integration', 'Payment processing is temporarily unavailable. Please try again later or contact the merchant.'],
107
+ ['Invalid zipcode', 'The ZIP code you entered is invalid. Please check and try again.'],
108
+ ['failed to save to database', 'Your payment was processed but we encountered an issue. Please contact support.'],
109
+ ['CASHBACK UNAVAIL', 'This transaction type is not supported. Please try again or use a different payment method.'],
110
+ ];
111
+ /**
112
+ * Maps a raw Ozura Pay API cardSale error string to a user-facing message.
113
+ *
114
+ * Uses the exact same error key table as checkout's `getUserFriendlyError()` in
115
+ * errorMapping.ts so both surfaces produce identical copy for the same errors.
116
+ *
117
+ * Falls back to the original string when it's under 100 characters, or to a
118
+ * generic message for long/opaque server errors — matching checkout's fallback
119
+ * behaviour exactly.
120
+ *
121
+ * **Trade-off:** Short unrecognised strings (e.g. processor codes like
122
+ * `"PROC_TIMEOUT"`) are passed through verbatim. This intentionally mirrors
123
+ * checkout so the same raw Pay API errors produce the same user-facing text on
124
+ * both surfaces. If the Pay API ever returns internal codes that should never
125
+ * reach the UI, the fix belongs in the Pay API error normalisation layer rather
126
+ * than here.
127
+ */
128
+ function normalizeCardSaleError(raw) {
129
+ if (!raw)
130
+ return 'Payment processing failed. Please try again.';
131
+ for (const [key, message] of CARD_SALE_ERROR_MAP) {
132
+ if (raw.toLowerCase().includes(key.toLowerCase())) {
133
+ return message;
134
+ }
135
+ }
136
+ // Checkout fallback: pass through short errors, genericise long ones
137
+ if (raw.length < 100)
138
+ return raw;
139
+ return 'Payment processing failed. Please try again or contact support.';
140
+ }
141
+
1
142
  const THEME_DEFAULT = {
2
143
  base: {
3
144
  color: '#1a1a2e',
@@ -128,6 +269,15 @@ function mergeStyleConfigs(a, b) {
128
269
  * Resolution order: theme defaults → variable overrides.
129
270
  * The returned config is then used as the "base appearance" that
130
271
  * per-element `style` overrides merge on top of.
272
+ *
273
+ * @remarks
274
+ * - `appearance: undefined` → no styles injected (element iframes use their
275
+ * own minimal built-in defaults).
276
+ * - `appearance: {}` or `appearance: { variables: {...} }` without an explicit
277
+ * `theme` → the `'default'` theme is used as the base. Omitting `theme`
278
+ * does NOT mean "no theme" — it means `theme: 'default'`. To opt out of
279
+ * the preset themes entirely, use per-element `style` overrides without
280
+ * passing an `appearance` prop at all.
131
281
  */
132
282
  function resolveAppearance(appearance) {
133
283
  var _a, _b;
@@ -153,7 +303,31 @@ function mergeAppearanceWithElementStyle(appearance, elementStyle) {
153
303
  return mergeStyleConfigs(appearance, elementStyle);
154
304
  }
155
305
 
156
- const BLOCKED_CSS_PATTERNS = /url\s*\(|expression\s*\(|javascript\s*:|vbscript\s*:|@import|behavior\s*:|binding\s*:|-moz-binding|-webkit-binding|<\s*script|<\s*style|\\[0-9a-fA-F]/i;
306
+ /**
307
+ * Generates a RFC 4122 v4 UUID.
308
+ *
309
+ * Uses `crypto.randomUUID()` when available (Chrome 92+, Firefox 95+,
310
+ * Safari 15.4+, Node 14.17+) and falls back to `crypto.getRandomValues()`
311
+ * for older environments (Safari 14, some embedded WebViews, older Node).
312
+ *
313
+ * Both paths use the same CSPRNG — the difference is only in API surface.
314
+ *
315
+ * @internal
316
+ */
317
+ function uuid() {
318
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
319
+ return crypto.randomUUID();
320
+ }
321
+ // Fallback: build UUID v4 from random bytes
322
+ const bytes = new Uint8Array(16);
323
+ crypto.getRandomValues(bytes);
324
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
325
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant RFC 4122
326
+ const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
327
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
328
+ }
329
+
330
+ const BLOCKED_CSS_PATTERNS = /url\s*\(|expression\s*\(|javascript\s*:|vbscript\s*:|@import|behavior\s*:|binding\s*:|-moz-binding|-webkit-binding|<\s*script|<\s*style|\\[0-9a-fA-F]|var\s*\(/i;
157
331
  const CSS_BREAKOUT = /[{};<>]/;
158
332
  const MAX_CSS_VALUE_LEN = 200;
159
333
  function sanitizeStyleObj(obj) {
@@ -185,6 +359,11 @@ function sanitizeStyles(style) {
185
359
  function sanitizeOptions(options) {
186
360
  var _a;
187
361
  const result = Object.assign(Object.assign({}, options), { placeholder: (_a = options.placeholder) === null || _a === void 0 ? void 0 : _a.slice(0, 100) });
362
+ // Coerce to boolean so a string "false" (truthy in JS) does not accidentally
363
+ // disable the input when the SDK is consumed from plain JavaScript.
364
+ if (options.disabled !== undefined) {
365
+ result.disabled = Boolean(options.disabled);
366
+ }
188
367
  // Only set style when provided; omitting it avoids clobbering existing style
189
368
  // when merging (e.g. update({ placeholder: 'new' }) must not overwrite style with undefined).
190
369
  if (options.style !== undefined) {
@@ -212,7 +391,7 @@ class OzElement {
212
391
  this.frameOrigin = new URL(frameBaseUrl).origin;
213
392
  this.fonts = fonts;
214
393
  this.appearanceStyle = appearanceStyle;
215
- this.frameId = `oz-${elementType}-${Math.random().toString(36).slice(2, 10)}`;
394
+ this.frameId = `oz-${elementType}-${uuid()}`;
216
395
  }
217
396
  /** The element type this proxy represents. */
218
397
  get type() {
@@ -230,22 +409,27 @@ class OzElement {
230
409
  mount(target) {
231
410
  var _a;
232
411
  if (this._destroyed)
233
- throw new Error('OzElements: cannot mount a destroyed element.');
412
+ throw new OzError('Cannot mount a destroyed element.');
234
413
  if (this.iframe)
235
414
  this.unmount();
236
415
  const container = typeof target === 'string'
237
416
  ? document.querySelector(target)
238
417
  : target;
239
418
  if (!container)
240
- throw new Error(typeof target === 'string'
241
- ? `OzElements: mount target not found — no element matches "${target}"`
242
- : `OzElements: mount target not found — the provided HTMLElement is null or undefined`);
419
+ throw new OzError(typeof target === 'string'
420
+ ? `Mount target not found — no element matches "${target}"`
421
+ : 'Mount target not found — the provided HTMLElement is null or undefined');
243
422
  const iframe = document.createElement('iframe');
244
423
  iframe.setAttribute('frameborder', '0');
245
424
  iframe.setAttribute('scrolling', 'no');
246
425
  iframe.setAttribute('allowtransparency', 'true');
247
426
  iframe.style.cssText = 'border:none;width:100%;height:46px;display:block;overflow:hidden;';
248
427
  iframe.title = `Secure ${this.elementType} input`;
428
+ // Note: the `sandbox` attribute is intentionally NOT set. Field values are
429
+ // delivered to the tokenizer iframe via a MessageChannel port (transferred
430
+ // in OZ_BEGIN_COLLECT), so no window.parent named-frame lookup is needed.
431
+ // The security boundary is the vault URL hardcoded at build time and the
432
+ // origin checks on every postMessage, not the sandbox flag.
249
433
  // Use hash instead of query string — survives clean-URL redirects from static servers.
250
434
  // parentOrigin lets the frame target postMessage to the merchant origin instead of '*'.
251
435
  const parentOrigin = typeof window !== 'undefined' ? window.location.origin : '';
@@ -261,6 +445,11 @@ class OzElement {
261
445
  }
262
446
  }, timeout);
263
447
  }
448
+ /**
449
+ * Subscribe to an element event. Returns `this` for chaining.
450
+ * @param event - Event name: `'change'`, `'focus'`, `'blur'`, `'ready'`, or `'loaderror'`.
451
+ * @param callback - Handler invoked with the event payload.
452
+ */
264
453
  on(event, callback) {
265
454
  if (this._destroyed)
266
455
  return this;
@@ -269,6 +458,10 @@ class OzElement {
269
458
  this.handlers.get(event).push(callback);
270
459
  return this;
271
460
  }
461
+ /**
462
+ * Remove a previously registered event handler.
463
+ * Has no effect if the handler is not registered.
464
+ */
272
465
  off(event, callback) {
273
466
  const list = this.handlers.get(event);
274
467
  if (list) {
@@ -278,6 +471,10 @@ class OzElement {
278
471
  }
279
472
  return this;
280
473
  }
474
+ /**
475
+ * Subscribe to an event for a single invocation. The handler is automatically
476
+ * removed after it fires once.
477
+ */
281
478
  once(event, callback) {
282
479
  const wrapper = (payload) => {
283
480
  this.off(event, wrapper);
@@ -285,13 +482,24 @@ class OzElement {
285
482
  };
286
483
  return this.on(event, wrapper);
287
484
  }
485
+ /**
486
+ * Dynamically update element options (placeholder, style, etc.) without
487
+ * re-mounting the iframe. Only the provided keys are merged; omitted keys
488
+ * retain their current values.
489
+ */
288
490
  update(options) {
289
491
  if (this._destroyed)
290
492
  return;
291
493
  const safe = sanitizeOptions(options);
494
+ // Re-merge vault appearance when style is updated so focus/invalid/complete/
495
+ // placeholder buckets from the theme are not stripped by a partial style object.
496
+ if (safe.style !== undefined) {
497
+ safe.style = mergeAppearanceWithElementStyle(this.appearanceStyle, safe.style);
498
+ }
292
499
  this.options = Object.assign(Object.assign({}, this.options), safe);
293
500
  this.post({ type: 'OZ_UPDATE', options: safe });
294
501
  }
502
+ /** Clear the current field value without removing the element from the DOM. */
295
503
  clear() {
296
504
  if (this._destroyed)
297
505
  return;
@@ -304,6 +512,8 @@ class OzElement {
304
512
  */
305
513
  unmount() {
306
514
  var _a;
515
+ if (this._destroyed)
516
+ return;
307
517
  if (this._loadTimer) {
308
518
  clearTimeout(this._loadTimer);
309
519
  this._loadTimer = null;
@@ -336,18 +546,31 @@ class OzElement {
336
546
  this._destroyed = true;
337
547
  }
338
548
  // ─── Called by OzVault ───────────────────────────────────────────────────
339
- setTokenizerName(tokenizerName) {
340
- this.post({ type: 'OZ_SET_TOKENIZER_NAME', tokenizerName });
341
- }
342
- beginCollect(requestId) {
343
- this.post({ type: 'OZ_BEGIN_COLLECT', requestId });
549
+ /**
550
+ * Sends OZ_BEGIN_COLLECT to the element iframe, transferring `port` so the
551
+ * iframe can post its field value directly to the tokenizer without going
552
+ * through the merchant page (no named-window lookup required).
553
+ * @internal
554
+ */
555
+ beginCollect(requestId, port) {
556
+ if (this._destroyed)
557
+ return;
558
+ this.sendWithTransfer({ type: 'OZ_BEGIN_COLLECT', requestId }, [port]);
344
559
  }
345
- /** Tell a CVV element how many digits to expect. Called automatically when card brand changes. */
560
+ /**
561
+ * Tell a CVV element how many digits to expect. Called automatically when card brand changes.
562
+ * @internal
563
+ */
346
564
  setCvvLength(length) {
565
+ if (this._destroyed)
566
+ return;
347
567
  this.post({ type: 'OZ_SET_CVV_LENGTH', length });
348
568
  }
569
+ /** @internal */
349
570
  handleMessage(msg) {
350
571
  var _a, _b;
572
+ if (this._destroyed)
573
+ return;
351
574
  switch (msg.type) {
352
575
  case 'OZ_FRAME_READY': {
353
576
  this._ready = true;
@@ -361,6 +584,19 @@ class OzElement {
361
584
  this.pendingMessages.forEach(m => this.send(m));
362
585
  this.pendingMessages = [];
363
586
  this.emit('ready', undefined);
587
+ // Warn if the mount container collapses to zero height — the input will
588
+ // be invisible but functional, which is hard to debug. Check after one
589
+ // animation frame so the browser has completed layout. Guard against
590
+ // non-rendering environments (jsdom, SSR) where all rects are zero.
591
+ if (typeof requestAnimationFrame !== 'undefined') {
592
+ requestAnimationFrame(() => {
593
+ const inRealBrowser = document.documentElement.getBoundingClientRect().height > 0;
594
+ if (inRealBrowser && this.iframe && this.iframe.getBoundingClientRect().height === 0) {
595
+ console.warn(`[OzElement] "${this.elementType}" mounted but has zero height. ` +
596
+ `Check that the container has a visible height (not overflow:hidden or height:0).`);
597
+ }
598
+ });
599
+ }
364
600
  break;
365
601
  }
366
602
  case 'OZ_CHANGE':
@@ -387,7 +623,12 @@ class OzElement {
387
623
  break;
388
624
  case 'OZ_RESIZE':
389
625
  if (this.iframe) {
390
- this.iframe.style.height = `${msg.height}px`;
626
+ // Clamp to a sensible range to prevent a rogue or compromised frame
627
+ // from zeroing out the input or stretching the layout.
628
+ const h = typeof msg.height === 'number' ? msg.height : 0;
629
+ if (h > 0 && h <= 300) {
630
+ this.iframe.style.height = `${h}px`;
631
+ }
391
632
  }
392
633
  break;
393
634
  }
@@ -395,8 +636,16 @@ class OzElement {
395
636
  // ─── Internal ────────────────────────────────────────────────────────────
396
637
  emit(event, payload) {
397
638
  const list = this.handlers.get(event);
398
- if (list)
399
- [...list].forEach(fn => fn(payload));
639
+ if (!list)
640
+ return;
641
+ [...list].forEach(fn => {
642
+ try {
643
+ fn(payload);
644
+ }
645
+ catch (err) {
646
+ console.error(`[OzElement] Unhandled error in '${event}' listener:`, err);
647
+ }
648
+ });
400
649
  }
401
650
  post(data) {
402
651
  const msg = Object.assign({ __oz: true, vaultId: this.vaultId }, data);
@@ -411,135 +660,18 @@ class OzElement {
411
660
  var _a;
412
661
  (_a = this._frameWindow) === null || _a === void 0 ? void 0 : _a.postMessage(msg, this.frameOrigin);
413
662
  }
414
- }
415
-
416
- /**
417
- * errors.ts — error types and normalisation for OzElements.
418
- *
419
- * Three normalisation functions:
420
- * - normalizeVaultError maps raw vault /tokenize errors to user-facing messages (card flows)
421
- * - normalizeBankVaultError — maps raw vault /tokenize errors to user-facing messages (bank/ACH flows)
422
- * - normalizeCardSaleError — maps raw cardSale API errors to user-facing messages
423
- *
424
- * Error keys in normalizeCardSaleError are taken directly from checkout's
425
- * errorMapping.ts so the same error strings produce the same user-facing copy.
426
- */
427
- class OzError extends Error {
428
- constructor(message, raw, errorCode) {
429
- super(message);
430
- this.name = 'OzError';
431
- this.raw = raw !== null && raw !== void 0 ? raw : message;
432
- this.errorCode = errorCode !== null && errorCode !== void 0 ? errorCode : 'unknown';
433
- this.retryable = this.errorCode === 'network' || this.errorCode === 'timeout' || this.errorCode === 'server';
434
- }
435
- }
436
- /** Shared patterns that apply to both card and bank vault errors. */
437
- function normalizeCommonVaultError(msg) {
438
- if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('authentication') || msg.includes('forbidden')) {
439
- return 'Authentication failed. Check your vault API key configuration.';
440
- }
441
- if (msg.includes('network') || msg.includes('failed to fetch') || msg.includes('networkerror')) {
442
- return 'A network error occurred. Please check your connection and try again.';
443
- }
444
- if (msg.includes('timeout') || msg.includes('timed out')) {
445
- return 'The request timed out. Please try again.';
446
- }
447
- if (msg.includes('http 5') || msg.includes('500') || msg.includes('502') || msg.includes('503')) {
448
- return 'A server error occurred. Please try again shortly.';
449
- }
450
- return null;
451
- }
452
- /**
453
- * Maps a raw vault /tokenize error string to a user-facing message for card flows.
454
- * Falls back to the original string if no pattern matches.
455
- */
456
- function normalizeVaultError(raw) {
457
- const msg = raw.toLowerCase();
458
- if (msg.includes('card number') || msg.includes('invalid card') || msg.includes('luhn')) {
459
- return 'The card number is invalid. Please check and try again.';
460
- }
461
- if (msg.includes('expir')) {
462
- return 'The card expiration date is invalid or the card has expired.';
463
- }
464
- if (msg.includes('cvv') || msg.includes('cvc') || msg.includes('security code')) {
465
- return 'The CVV code is invalid. Please check and try again.';
466
- }
467
- if (msg.includes('insufficient') || msg.includes('funds')) {
468
- return 'Your card has insufficient funds. Please use a different card.';
469
- }
470
- if (msg.includes('declined') || msg.includes('do not honor')) {
471
- return 'Your card was declined. Please try a different card or contact your bank.';
472
- }
473
- const common = normalizeCommonVaultError(msg);
474
- if (common)
475
- return common;
476
- return raw;
477
- }
478
- /**
479
- * Maps a raw vault /tokenize error string to a user-facing message for bank (ACH) flows.
480
- * Uses bank-specific pattern matching so card-specific messages are never shown for
481
- * bank errors. Falls back to the original string if no pattern matches.
482
- */
483
- function normalizeBankVaultError(raw) {
484
- const msg = raw.toLowerCase();
485
- if (msg.includes('account number') || msg.includes('account_number') || msg.includes('invalid account')) {
486
- return 'The bank account number is invalid. Please check and try again.';
487
- }
488
- if (msg.includes('routing number') || msg.includes('routing_number') || msg.includes('invalid routing') || /\baba\b/.test(msg)) {
489
- return 'The routing number is invalid. Please check and try again.';
490
- }
491
- const common = normalizeCommonVaultError(msg);
492
- if (common)
493
- return common;
494
- return raw;
495
- }
496
- // ─── cardSale error map (mirrors checkout/src/utils/errorMapping.ts exactly) ─
497
- const CARD_SALE_ERROR_MAP = [
498
- ['Insufficient Funds', 'Your card has insufficient funds. Please use a different payment method.'],
499
- ['Invalid card number', 'The card number you entered is invalid. Please check and try again.'],
500
- ['Card expired', 'Your card has expired. Please use a different card.'],
501
- ['CVV Verification Failed', 'The CVV code you entered is incorrect. Please check and try again.'],
502
- ['Address Verification Failed', 'The billing address does not match your card. Please verify your address.'],
503
- ['Do Not Honor', 'Your card was declined. Please contact your bank or use a different payment method.'],
504
- ['Declined', 'Your card was declined. Please contact your bank or use a different payment method.'],
505
- ['Surcharge is currently not supported', 'Surcharge fees are not supported at this time.'],
506
- ['Surcharge percent must be between', 'Surcharge fees are not supported at this time.'],
507
- ['Forbidden - API key', 'Authentication failed. Please refresh the page.'],
508
- ['Api Key is invalid', 'Authentication failed. Please refresh the page.'],
509
- ['API key not found', 'Authentication failed. Please refresh the page.'],
510
- ['Access token expired', 'Your session has expired. Please refresh the page.'],
511
- ['Access token is invalid', 'Authentication failed. Please refresh the page.'],
512
- ['Unauthorized', 'Authentication failed. Please refresh the page.'],
513
- ['Too Many Requests', 'Too many requests. Please wait a moment and try again.'],
514
- ['Rate limit exceeded', 'System is busy. Please wait a moment and try again.'],
515
- ['No processor integrations found', 'Payment processing is not configured for this merchant. Please contact the merchant for assistance.'],
516
- ['processor integration', 'Payment processing is temporarily unavailable. Please try again later or contact the merchant.'],
517
- ['Invalid zipcode', 'The ZIP code you entered is invalid. Please check and try again.'],
518
- ['failed to save to database', 'Your payment was processed but we encountered an issue. Please contact support.'],
519
- ['CASHBACK UNAVAIL', 'This transaction type is not supported. Please try again or use a different payment method.'],
520
- ];
521
- /**
522
- * Maps a raw Ozura Pay API cardSale error string to a user-facing message.
523
- *
524
- * Uses the exact same error key table as checkout's `getUserFriendlyError()` in
525
- * errorMapping.ts so both surfaces produce identical copy for the same errors.
526
- *
527
- * Falls back to the original string when it's under 100 characters, or to a
528
- * generic message for long/opaque server errors — matching checkout's fallback
529
- * behaviour exactly.
530
- */
531
- function normalizeCardSaleError(raw) {
532
- if (!raw)
533
- return 'Payment processing failed. Please try again.';
534
- for (const [key, message] of CARD_SALE_ERROR_MAP) {
535
- if (raw.toLowerCase().includes(key.toLowerCase())) {
536
- return message;
663
+ /** Posts a message with transferable objects (e.g. MessagePort). Bypasses the
664
+ * pending-message queue — only call when the frame is already ready. */
665
+ sendWithTransfer(data, transfer) {
666
+ if (this._destroyed)
667
+ return;
668
+ if (!this._frameWindow) {
669
+ console.error('[OzElement] sendWithTransfer called before frame window is available port will not be transferred. This is a bug.');
670
+ return;
537
671
  }
672
+ const msg = Object.assign({ __oz: true, vaultId: this.vaultId }, data);
673
+ this._frameWindow.postMessage(msg, this.frameOrigin, transfer);
538
674
  }
539
- // Checkout fallback: pass through short errors, genericise long ones
540
- if (raw.length < 100)
541
- return raw;
542
- return 'Payment processing failed. Please try again or contact support.';
543
675
  }
544
676
 
545
677
  /**
@@ -591,6 +723,14 @@ const US_STATES = {
591
723
  'south dakota': 'SD', tennessee: 'TN', texas: 'TX', utah: 'UT',
592
724
  vermont: 'VT', virginia: 'VA', washington: 'WA', 'west virginia': 'WV',
593
725
  wisconsin: 'WI', wyoming: 'WY',
726
+ // US territories
727
+ 'puerto rico': 'PR', guam: 'GU', 'virgin islands': 'VI',
728
+ 'us virgin islands': 'VI', 'u.s. virgin islands': 'VI',
729
+ 'american samoa': 'AS', 'northern mariana islands': 'MP',
730
+ 'commonwealth of the northern mariana islands': 'MP',
731
+ // Military / diplomatic addresses
732
+ 'armed forces europe': 'AE', 'armed forces pacific': 'AP',
733
+ 'armed forces americas': 'AA',
594
734
  };
595
735
  const US_ABBREVS = new Set(Object.values(US_STATES));
596
736
  const CA_PROVINCES = {
@@ -699,8 +839,15 @@ function validateBilling(billing) {
699
839
  errors.push('billing.address.line2 must be 1–50 characters if provided');
700
840
  if (!isValidBillingField(city))
701
841
  errors.push('billing.address.city must be 1–50 characters');
702
- if (!isValidBillingField(state))
842
+ if (!isValidBillingField(state)) {
703
843
  errors.push('billing.address.state must be 1–50 characters');
844
+ }
845
+ else if (country === 'US' && !US_ABBREVS.has(state)) {
846
+ errors.push(`billing.address.state "${state}" is not a recognized US state or territory abbreviation (e.g. "CA", "NY", "PR")`);
847
+ }
848
+ else if (country === 'CA' && !CA_ABBREVS.has(state)) {
849
+ errors.push(`billing.address.state "${state}" is not a recognized Canadian province or territory abbreviation (e.g. "ON", "BC", "QC")`);
850
+ }
704
851
  // cardSale backend uses strict enum validation on country — must be exactly 2 uppercase letters
705
852
  if (!/^[A-Z]{2}$/.test(country)) {
706
853
  errors.push('billing.address.country must be a 2-letter ISO 3166-1 alpha-2 code (e.g. "US", "CA", "GB")');
@@ -724,13 +871,31 @@ function validateBilling(billing) {
724
871
  return { valid: errors.length === 0, errors, normalized };
725
872
  }
726
873
 
727
- const DEFAULT_FRAME_BASE_URL = "https://lively-hill-097170c0f.4.azurestaticapps.net";
874
+ function isCardMetadata(v) {
875
+ return !!v && typeof v === 'object' && typeof v.last4 === 'string';
876
+ }
877
+ function isBankAccountMetadata(v) {
878
+ return !!v && typeof v === 'object' && typeof v.last4 === 'string';
879
+ }
880
+ const DEFAULT_FRAME_BASE_URL = "https://elements.ozura.com";
728
881
  /**
729
882
  * The main entry point for OzElements. Creates and manages iframe-based
730
883
  * card input elements that keep raw card data isolated from the merchant page.
731
884
  *
885
+ * Use the static `OzVault.create()` factory — do not call `new OzVault()` directly.
886
+ *
732
887
  * @example
733
- * const vault = new OzVault('your_vault_api_key');
888
+ * const vault = await OzVault.create({
889
+ * pubKey: 'pk_live_...',
890
+ * fetchWaxKey: async (sessionId) => {
891
+ * // Call your backend — which calls ozura.mintWaxKey() from @ozura/elements/server
892
+ * const { waxKey } = await fetch('/api/mint-wax', {
893
+ * method: 'POST',
894
+ * body: JSON.stringify({ sessionId }),
895
+ * }).then(r => r.json());
896
+ * return waxKey;
897
+ * },
898
+ * });
734
899
  * const cardNum = vault.createElement('cardNumber');
735
900
  * cardNum.mount('#card-number');
736
901
  * const { token, cvcSession } = await vault.createToken({
@@ -738,8 +903,14 @@ const DEFAULT_FRAME_BASE_URL = "https://lively-hill-097170c0f.4.azurestaticapps.
738
903
  * });
739
904
  */
740
905
  class OzVault {
741
- constructor(apiKey, options) {
742
- var _a, _b;
906
+ /**
907
+ * Internal constructor — use `OzVault.create()` instead.
908
+ * The constructor mounts the tokenizer iframe immediately so it can start
909
+ * loading in parallel while `fetchWaxKey` is being awaited.
910
+ * @internal
911
+ */
912
+ constructor(options, waxKey, tokenizationSessionId) {
913
+ var _a, _b, _c;
743
914
  this.elements = new Map();
744
915
  this.elementsByType = new Map();
745
916
  this.bankElementsByType = new Map();
@@ -752,24 +923,32 @@ class OzVault {
752
923
  this.tokenizerReady = false;
753
924
  this._tokenizing = null;
754
925
  this._destroyed = false;
926
+ // Tracks successful tokenizations against the per-key call budget so the SDK
927
+ // can proactively refresh the wax key after it has been consumed rather than
928
+ // waiting for the next createToken() call to fail.
929
+ this._tokenizeSuccessCount = 0;
755
930
  this._pendingMount = null;
931
+ this._storedFetchWaxKey = null;
932
+ this._waxRefreshing = null;
756
933
  this.loadErrorTimeoutId = null;
757
- if (!apiKey || !apiKey.trim()) {
758
- throw new OzError('A non-empty vault API key is required. Pass your key as the first argument to new OzVault().');
759
- }
760
- this.apiKey = apiKey;
934
+ // Proactive wax refresh on visibility restore after long idle
935
+ this._hiddenAt = null;
936
+ this.waxKey = waxKey;
937
+ this.tokenizationSessionId = tokenizationSessionId;
761
938
  this.pubKey = options.pubKey;
762
939
  this.frameBaseUrl = options.frameBaseUrl || DEFAULT_FRAME_BASE_URL;
763
940
  this.frameOrigin = new URL(this.frameBaseUrl).origin;
764
941
  this.fonts = (_a = options.fonts) !== null && _a !== void 0 ? _a : [];
765
942
  this.resolvedAppearance = resolveAppearance(options.appearance);
766
- this.vaultId = `vault-${crypto.randomUUID()}`;
767
- this.tokenizerName = `__oz_tok_${this.vaultId}`;
943
+ this.vaultId = `vault-${uuid()}`;
944
+ this._maxTokenizeCalls = (_b = options.maxTokenizeCalls) !== null && _b !== void 0 ? _b : 3;
768
945
  this.boundHandleMessage = this.handleMessage.bind(this);
769
946
  window.addEventListener('message', this.boundHandleMessage);
947
+ this.boundHandleVisibility = this.handleVisibilityChange.bind(this);
948
+ document.addEventListener('visibilitychange', this.boundHandleVisibility);
770
949
  this.mountTokenizerFrame();
771
950
  if (options.onLoadError) {
772
- const timeout = (_b = options.loadTimeoutMs) !== null && _b !== void 0 ? _b : 10000;
951
+ const timeout = (_c = options.loadTimeoutMs) !== null && _c !== void 0 ? _c : 10000;
773
952
  this.loadErrorTimeoutId = setTimeout(() => {
774
953
  this.loadErrorTimeoutId = null;
775
954
  if (!this._destroyed && !this.tokenizerReady) {
@@ -777,15 +956,113 @@ class OzVault {
777
956
  }
778
957
  }, timeout);
779
958
  }
959
+ this._onWaxRefresh = options.onWaxRefresh;
960
+ this._onReady = options.onReady;
961
+ }
962
+ /**
963
+ * Creates and returns a ready `OzVault` instance.
964
+ *
965
+ * Internally this:
966
+ * 1. Generates a `tokenizationSessionId` (UUID).
967
+ * 2. Starts loading the hidden tokenizer iframe immediately.
968
+ * 3. Calls `options.fetchWaxKey(tokenizationSessionId)` concurrently — your
969
+ * backend mints a session-bound wax key from the vault and returns it.
970
+ * 4. Resolves with the vault instance once the wax key is stored. The iframe
971
+ * has been loading the whole time, so `isReady` may already be true or
972
+ * will fire shortly after.
973
+ *
974
+ * The returned vault is ready to create elements immediately. `createToken()`
975
+ * additionally requires `vault.isReady` (tokenizer iframe loaded).
976
+ *
977
+ * @throws {OzError} if `fetchWaxKey` throws, returns a non-string value, or returns an empty/whitespace-only string.
978
+ */
979
+ static async create(options, signal) {
980
+ if (!options.pubKey || !options.pubKey.trim()) {
981
+ throw new OzError('pubKey is required in options. Obtain your public key from the Ozura admin.');
982
+ }
983
+ if (typeof options.fetchWaxKey !== 'function') {
984
+ throw new OzError('fetchWaxKey must be a function. See OzVault.create() docs for the expected signature.');
985
+ }
986
+ const tokenizationSessionId = uuid();
987
+ // Construct the vault immediately — this mounts the tokenizer iframe so it
988
+ // starts loading while fetchWaxKey is in flight. The waxKey field starts
989
+ // empty and is set below before create() returns.
990
+ const vault = new OzVault(options, '', tokenizationSessionId);
991
+ // If the caller provides an AbortSignal (e.g. React useEffect cleanup),
992
+ // destroy the vault immediately on abort so the tokenizer iframe and message
993
+ // listener are removed synchronously rather than waiting for fetchWaxKey to
994
+ // settle. This eliminates the brief double-iframe window in React StrictMode.
995
+ const onAbort = () => vault.destroy();
996
+ signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', onAbort, { once: true });
997
+ let waxKey;
998
+ try {
999
+ waxKey = await options.fetchWaxKey(tokenizationSessionId);
1000
+ }
1001
+ catch (err) {
1002
+ signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', onAbort);
1003
+ vault.destroy();
1004
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted)
1005
+ throw new OzError('OzVault.create() was cancelled.');
1006
+ // Preserve errorCode/retryable from OzError (e.g. timeout/network from createFetchWaxKey)
1007
+ // so callers can distinguish transient failures from config errors.
1008
+ const originalCode = err instanceof OzError ? err.errorCode : undefined;
1009
+ const msg = err instanceof Error ? err.message : 'Unknown error';
1010
+ throw new OzError(`fetchWaxKey threw an error: ${msg}`, undefined, originalCode);
1011
+ }
1012
+ signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', onAbort);
1013
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
1014
+ vault.destroy();
1015
+ throw new OzError('OzVault.create() was cancelled.');
1016
+ }
1017
+ if (typeof waxKey !== 'string' || !waxKey.trim()) {
1018
+ vault.destroy();
1019
+ throw new OzError('fetchWaxKey must return a non-empty wax key string. Check your mint endpoint.');
1020
+ }
1021
+ // Static methods can access private fields of instances of the same class.
1022
+ vault.waxKey = waxKey;
1023
+ vault._storedFetchWaxKey = options.fetchWaxKey;
1024
+ // If the tokenizer iframe fired OZ_FRAME_READY before fetchWaxKey resolved,
1025
+ // the OZ_INIT sent at that point had an empty waxKey. Send a follow-up now
1026
+ // so the tokenizer has the key stored before any createToken() call.
1027
+ if (vault.tokenizerReady) {
1028
+ vault.sendToTokenizer({ type: 'OZ_INIT', frameId: '__tokenizer__', waxKey });
1029
+ }
1030
+ return vault;
780
1031
  }
781
1032
  /**
782
1033
  * True once the hidden tokenizer iframe has loaded and signalled ready.
783
1034
  * Use this to gate the pay button when building custom UIs without React.
784
1035
  * React consumers should use the `ready` value returned by `useOzElements()`.
1036
+ *
1037
+ * Once `true`, remains `true` for the lifetime of this vault instance.
1038
+ * It only reverts to `false` after `vault.destroy()` is called, at which
1039
+ * point the vault is unusable and a new instance must be created.
1040
+ *
1041
+ * @remarks
1042
+ * This tracks **tokenizer readiness only** — it says nothing about whether
1043
+ * the individual element iframes (card number, CVV, etc.) have loaded.
1044
+ * A vault can be `isReady === true` while elements are still mounting.
1045
+ * To gate a submit button correctly in vanilla JS, wait for every element's
1046
+ * `'ready'` event in addition to this flag. In React, use the `ready` value
1047
+ * from `useOzElements()` instead, which combines both checks automatically.
785
1048
  */
786
1049
  get isReady() {
787
1050
  return this.tokenizerReady;
788
1051
  }
1052
+ /**
1053
+ * Number of successful tokenize calls made against the current wax key.
1054
+ *
1055
+ * Resets to `0` each time the wax key is refreshed (proactively or reactively).
1056
+ * Useful in vanilla JS integrations to display "attempts remaining" UI.
1057
+ * In React, use `tokenizeCount` from `useOzElements()` instead.
1058
+ *
1059
+ * @example
1060
+ * const remaining = 3 - vault.tokenizeCount;
1061
+ * payButton.textContent = remaining > 0 ? `Pay (${remaining} attempts left)` : 'Pay';
1062
+ */
1063
+ get tokenizeCount() {
1064
+ return this._tokenizeSuccessCount;
1065
+ }
789
1066
  /**
790
1067
  * Creates a new OzElement of the given type. Call `.mount(selector)` on the
791
1068
  * returned element to attach it to the DOM.
@@ -818,7 +1095,10 @@ class OzVault {
818
1095
  }
819
1096
  createElementInto(typeMap, type, options) {
820
1097
  if (this._destroyed) {
821
- throw new OzError('Cannot create elements on a destroyed vault. Create a new OzVault instance.');
1098
+ throw new OzError('Cannot create elements on a destroyed vault. Call await OzVault.create() to get a new instance.');
1099
+ }
1100
+ if (this._tokenizing) {
1101
+ throw new OzError('Cannot create or replace elements while a tokenization is in progress. Wait for the active createToken() / createBankToken() call to settle first.');
822
1102
  }
823
1103
  const existing = typeMap.get(type);
824
1104
  if (existing) {
@@ -850,7 +1130,7 @@ class OzVault {
850
1130
  async createBankToken(options) {
851
1131
  var _a, _b;
852
1132
  if (this._destroyed) {
853
- throw new OzError('Cannot tokenize on a destroyed vault. Create a new OzVault instance.');
1133
+ throw new OzError('Cannot tokenize on a destroyed vault. Call await OzVault.create() to get a new instance.');
854
1134
  }
855
1135
  if (!this.tokenizerReady) {
856
1136
  throw new OzError('Vault not ready. Ensure the page is fully loaded before calling createBankToken.');
@@ -867,10 +1147,10 @@ class OzVault {
867
1147
  throw new OzError('lastName is required for bank account tokenization.');
868
1148
  }
869
1149
  if (options.firstName.trim().length > 50) {
870
- throw new OzError('firstName must be 50 characters or fewer');
1150
+ throw new OzError('firstName must be 50 characters or fewer.');
871
1151
  }
872
1152
  if (options.lastName.trim().length > 50) {
873
- throw new OzError('lastName must be 50 characters or fewer');
1153
+ throw new OzError('lastName must be 50 characters or fewer.');
874
1154
  }
875
1155
  const accountEl = this.bankElementsByType.get('accountNumber');
876
1156
  const routingEl = this.bankElementsByType.get('routingNumber');
@@ -887,30 +1167,47 @@ class OzVault {
887
1167
  }
888
1168
  const readyBankElements = [accountEl, routingEl];
889
1169
  this._tokenizing = 'bank';
890
- const requestId = `req-${Math.random().toString(36).slice(2, 10)}`;
1170
+ const requestId = `req-${uuid()}`;
891
1171
  return new Promise((resolve, reject) => {
892
1172
  const cleanup = () => { this._tokenizing = null; };
893
1173
  this.bankTokenizeResolvers.set(requestId, {
894
1174
  resolve: (v) => { cleanup(); resolve(v); },
895
1175
  reject: (e) => { cleanup(); reject(e); },
896
- });
897
- this.sendToTokenizer({
898
- type: 'OZ_BANK_TOKENIZE',
899
- requestId,
900
- apiKey: this.apiKey,
901
- pubKey: this.pubKey,
902
1176
  firstName: options.firstName.trim(),
903
1177
  lastName: options.lastName.trim(),
1178
+ readyElements: readyBankElements,
904
1179
  fieldCount: readyBankElements.length,
905
1180
  });
906
- readyBankElements.forEach(el => el.beginCollect(requestId));
907
- setTimeout(() => {
908
- if (this.bankTokenizeResolvers.has(requestId)) {
909
- this.bankTokenizeResolvers.delete(requestId);
910
- cleanup();
911
- reject(new OzError('Bank tokenization timed out after 30 seconds', undefined, 'timeout'));
912
- }
913
- }, 30000);
1181
+ try {
1182
+ const bankChannels = readyBankElements.map(() => new MessageChannel());
1183
+ this.sendToTokenizer({
1184
+ type: 'OZ_BANK_TOKENIZE',
1185
+ requestId,
1186
+ waxKey: this.waxKey,
1187
+ tokenizationSessionId: this.tokenizationSessionId,
1188
+ pubKey: this.pubKey,
1189
+ firstName: options.firstName.trim(),
1190
+ lastName: options.lastName.trim(),
1191
+ fieldCount: readyBankElements.length,
1192
+ }, bankChannels.map(ch => ch.port1));
1193
+ readyBankElements.forEach((el, i) => el.beginCollect(requestId, bankChannels[i].port2));
1194
+ const bankTimeoutId = setTimeout(() => {
1195
+ if (this.bankTokenizeResolvers.has(requestId)) {
1196
+ this.bankTokenizeResolvers.delete(requestId);
1197
+ this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId });
1198
+ cleanup();
1199
+ reject(new OzError('Bank tokenization timed out after 30 seconds', undefined, 'timeout'));
1200
+ }
1201
+ }, 30000);
1202
+ const bankPendingEntry = this.bankTokenizeResolvers.get(requestId);
1203
+ if (bankPendingEntry)
1204
+ bankPendingEntry.timeoutId = bankTimeoutId;
1205
+ }
1206
+ catch (err) {
1207
+ this.bankTokenizeResolvers.delete(requestId);
1208
+ cleanup();
1209
+ reject(err instanceof OzError ? err : new OzError('Bank tokenization failed to start'));
1210
+ }
914
1211
  });
915
1212
  }
916
1213
  /**
@@ -922,7 +1219,7 @@ class OzVault {
922
1219
  async createToken(options = {}) {
923
1220
  var _a, _b;
924
1221
  if (this._destroyed) {
925
- throw new OzError('Cannot tokenize on a destroyed vault. Create a new OzVault instance.');
1222
+ throw new OzError('Cannot tokenize on a destroyed vault. Call await OzVault.create() to get a new instance.');
926
1223
  }
927
1224
  if (!this.tokenizerReady) {
928
1225
  throw new OzError('Vault not ready. Ensure the page is fully loaded before calling createToken.');
@@ -932,29 +1229,33 @@ class OzVault {
932
1229
  ? 'A bank tokenization is already in progress. Wait for it to complete before calling createToken().'
933
1230
  : 'A card tokenization is already in progress. Wait for it to complete before calling createToken() again.');
934
1231
  }
935
- this._tokenizing = 'card';
936
- const requestId = `req-${Math.random().toString(36).slice(2, 10)}`;
937
- // Only include card elements whose iframes have actually loaded an element
938
- // created but never mounted will never send OZ_FIELD_VALUE, which would
939
- // cause the tokenizer to hang waiting for a value that never arrives.
940
- // Explicitly exclude bank elements (accountNumber/routingNumber) that share
941
- // the same this.elements map; including them would inflate fieldCount and
942
- // allow the accountNumber iframe's last4 to overwrite cardMeta.last4.
943
- const cardElements = new Set(this.elementsByType.values());
944
- const readyElements = [...this.elements.values()].filter(el => el.isReady && cardElements.has(el));
945
- if (readyElements.length === 0) {
946
- this._tokenizing = null;
947
- throw new OzError('No elements are mounted and ready. Mount at least one element before calling createToken.');
1232
+ // All synchronous validation runs before _tokenizing is set so these throws
1233
+ // need no manual cleanup — _tokenizing is still null when they fire.
1234
+ // Collect all card elements that have been created (mounted or not) so we
1235
+ // can require every created field to be ready before proceeding. An element
1236
+ // that was created but whose iframe hasn't loaded yet will never send
1237
+ // OZ_FIELD_VALUE tokenizing without it would silently submit an empty or
1238
+ // incomplete card and produce an opaque vault rejection.
1239
+ // Bank elements (accountNumber/routingNumber) share this.elements but live
1240
+ // in elementsByType under bank-only keys, so they are excluded by the Set.
1241
+ const cardElements = [...this.elementsByType.values()];
1242
+ if (cardElements.length === 0) {
1243
+ throw new OzError('No card elements have been created. Call vault.createElement() for each field before calling createToken.');
1244
+ }
1245
+ const notReady = cardElements.filter(el => !el.isReady);
1246
+ if (notReady.length > 0) {
1247
+ throw new OzError(`Not all elements are ready. Wait for all fields to finish loading before calling createToken. ` +
1248
+ `Not yet ready: ${notReady.map(el => el.type).join(', ')}.`);
948
1249
  }
1250
+ const readyElements = cardElements;
949
1251
  // Validate billing details if provided and extract firstName/lastName for the vault payload.
950
1252
  // billing.firstName/lastName take precedence over the deprecated top-level params.
951
1253
  let normalizedBilling;
952
- let firstName = (_a = options.firstName) !== null && _a !== void 0 ? _a : '';
953
- let lastName = (_b = options.lastName) !== null && _b !== void 0 ? _b : '';
1254
+ let firstName = ((_a = options.firstName) !== null && _a !== void 0 ? _a : '').trim();
1255
+ let lastName = ((_b = options.lastName) !== null && _b !== void 0 ? _b : '').trim();
954
1256
  if (options.billing) {
955
1257
  const result = validateBilling(options.billing);
956
1258
  if (!result.valid) {
957
- this._tokenizing = null;
958
1259
  throw new OzError(`Invalid billing details: ${result.errors.join('; ')}`);
959
1260
  }
960
1261
  normalizedBilling = result.normalized;
@@ -963,40 +1264,57 @@ class OzVault {
963
1264
  }
964
1265
  else {
965
1266
  if (firstName.length > 50) {
966
- this._tokenizing = null;
967
- throw new OzError('firstName must be 50 characters or fewer');
1267
+ throw new OzError('firstName must be 50 characters or fewer.');
968
1268
  }
969
1269
  if (lastName.length > 50) {
970
- this._tokenizing = null;
971
- throw new OzError('lastName must be 50 characters or fewer');
1270
+ throw new OzError('lastName must be 50 characters or fewer.');
972
1271
  }
973
1272
  }
1273
+ this._tokenizing = 'card';
1274
+ const requestId = `req-${uuid()}`;
974
1275
  return new Promise((resolve, reject) => {
975
1276
  const cleanup = () => { this._tokenizing = null; };
976
1277
  this.tokenizeResolvers.set(requestId, {
977
1278
  resolve: (v) => { cleanup(); resolve(v); },
978
1279
  reject: (e) => { cleanup(); reject(e); },
979
1280
  billing: normalizedBilling,
980
- });
981
- // Tell tokenizer frame to expect N field values, then tokenize
982
- this.sendToTokenizer({
983
- type: 'OZ_TOKENIZE',
984
- requestId,
985
- apiKey: this.apiKey,
986
- pubKey: this.pubKey,
987
1281
  firstName,
988
1282
  lastName,
1283
+ readyElements,
989
1284
  fieldCount: readyElements.length,
990
1285
  });
991
- // Tell each ready element frame to send its raw value to the tokenizer
992
- readyElements.forEach(el => el.beginCollect(requestId));
993
- setTimeout(() => {
994
- if (this.tokenizeResolvers.has(requestId)) {
995
- this.tokenizeResolvers.delete(requestId);
996
- cleanup();
997
- reject(new OzError('Tokenization timed out after 30 seconds', undefined, 'timeout'));
998
- }
999
- }, 30000);
1286
+ try {
1287
+ // Tell tokenizer frame to expect N field values, then tokenize
1288
+ const cardChannels = readyElements.map(() => new MessageChannel());
1289
+ this.sendToTokenizer({
1290
+ type: 'OZ_TOKENIZE',
1291
+ requestId,
1292
+ waxKey: this.waxKey,
1293
+ tokenizationSessionId: this.tokenizationSessionId,
1294
+ pubKey: this.pubKey,
1295
+ firstName,
1296
+ lastName,
1297
+ fieldCount: readyElements.length,
1298
+ }, cardChannels.map(ch => ch.port1));
1299
+ // Tell each ready element frame to send its raw value to the tokenizer
1300
+ readyElements.forEach((el, i) => el.beginCollect(requestId, cardChannels[i].port2));
1301
+ const cardTimeoutId = setTimeout(() => {
1302
+ if (this.tokenizeResolvers.has(requestId)) {
1303
+ this.tokenizeResolvers.delete(requestId);
1304
+ this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId });
1305
+ cleanup();
1306
+ reject(new OzError('Tokenization timed out after 30 seconds', undefined, 'timeout'));
1307
+ }
1308
+ }, 30000);
1309
+ const cardPendingEntry = this.tokenizeResolvers.get(requestId);
1310
+ if (cardPendingEntry)
1311
+ cardPendingEntry.timeoutId = cardTimeoutId;
1312
+ }
1313
+ catch (err) {
1314
+ this.tokenizeResolvers.delete(requestId);
1315
+ cleanup();
1316
+ reject(err instanceof OzError ? err : new OzError('Tokenization failed to start'));
1317
+ }
1000
1318
  });
1001
1319
  }
1002
1320
  /**
@@ -1010,6 +1328,7 @@ class OzVault {
1010
1328
  return;
1011
1329
  this._destroyed = true;
1012
1330
  window.removeEventListener('message', this.boundHandleMessage);
1331
+ document.removeEventListener('visibilitychange', this.boundHandleVisibility);
1013
1332
  if (this._pendingMount) {
1014
1333
  document.removeEventListener('DOMContentLoaded', this._pendingMount);
1015
1334
  this._pendingMount = null;
@@ -1020,11 +1339,17 @@ class OzVault {
1020
1339
  }
1021
1340
  // Reject any pending tokenize promises so callers aren't left hanging
1022
1341
  this._tokenizing = null;
1023
- this.tokenizeResolvers.forEach(({ reject }) => {
1342
+ this.tokenizeResolvers.forEach(({ reject, timeoutId }, requestId) => {
1343
+ if (timeoutId != null)
1344
+ clearTimeout(timeoutId);
1345
+ this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId });
1024
1346
  reject(new OzError('Vault destroyed before tokenization completed.'));
1025
1347
  });
1026
1348
  this.tokenizeResolvers.clear();
1027
- this.bankTokenizeResolvers.forEach(({ reject }) => {
1349
+ this.bankTokenizeResolvers.forEach(({ reject, timeoutId }, requestId) => {
1350
+ if (timeoutId != null)
1351
+ clearTimeout(timeoutId);
1352
+ this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId });
1028
1353
  reject(new OzError('Vault destroyed before bank tokenization completed.'));
1029
1354
  });
1030
1355
  this.bankTokenizeResolvers.clear();
@@ -1033,17 +1358,49 @@ class OzVault {
1033
1358
  this.elementsByType.clear();
1034
1359
  this.bankElementsByType.clear();
1035
1360
  this.completionState.clear();
1361
+ this._tokenizeSuccessCount = 0;
1036
1362
  (_a = this.tokenizerFrame) === null || _a === void 0 ? void 0 : _a.remove();
1037
1363
  this.tokenizerFrame = null;
1038
1364
  this.tokenizerWindow = null;
1039
1365
  this.tokenizerReady = false;
1040
1366
  }
1041
1367
  // ─── Private ─────────────────────────────────────────────────────────────
1368
+ /**
1369
+ * Proactively re-mints the wax key when the page becomes visible again after
1370
+ * a long idle period. Wax keys have a fixed TTL (~30 minutes); a user who
1371
+ * leaves the tab in the background and returns could have an expired key.
1372
+ * Rather than waiting for a failed tokenization to trigger the reactive
1373
+ * refresh path, this pre-empts the failure when the vault is idle.
1374
+ *
1375
+ * Threshold: 20 minutes hidden. Chosen to be comfortably inside the ~30m TTL
1376
+ * while avoiding spurious refreshes for brief tab-switches.
1377
+ */
1378
+ handleVisibilityChange() {
1379
+ if (this._destroyed)
1380
+ return;
1381
+ const REFRESH_THRESHOLD_MS = 20 * 60 * 1000; // 20 minutes
1382
+ if (document.hidden) {
1383
+ this._hiddenAt = Date.now();
1384
+ }
1385
+ else {
1386
+ if (this._hiddenAt !== null &&
1387
+ Date.now() - this._hiddenAt >= REFRESH_THRESHOLD_MS &&
1388
+ this._storedFetchWaxKey &&
1389
+ !this._tokenizing &&
1390
+ !this._waxRefreshing) {
1391
+ this.refreshWaxKey().catch((err) => {
1392
+ // Proactive refresh failure is non-fatal — the reactive path on the
1393
+ // next createToken() call will handle it, including the auth retry.
1394
+ console.warn('[OzVault] Proactive wax key refresh failed:', err instanceof Error ? err.message : err);
1395
+ });
1396
+ }
1397
+ this._hiddenAt = null;
1398
+ }
1399
+ }
1042
1400
  mountTokenizerFrame() {
1043
1401
  const mount = () => {
1044
1402
  this._pendingMount = null;
1045
1403
  const iframe = document.createElement('iframe');
1046
- iframe.name = this.tokenizerName;
1047
1404
  iframe.style.cssText = 'position:absolute;top:-9999px;left:-9999px;width:1px;height:1px;';
1048
1405
  iframe.setAttribute('aria-hidden', 'true');
1049
1406
  iframe.tabIndex = -1;
@@ -1062,6 +1419,8 @@ class OzVault {
1062
1419
  }
1063
1420
  handleMessage(event) {
1064
1421
  var _a;
1422
+ if (this._destroyed)
1423
+ return;
1065
1424
  // Only accept messages from our frame origin (defense in depth; prevents
1066
1425
  // arbitrary pages from injecting OZ_TOKEN_RESULT etc. with a guessed vaultId).
1067
1426
  if (event.origin !== this.frameOrigin)
@@ -1079,14 +1438,17 @@ class OzVault {
1079
1438
  if (frameId) {
1080
1439
  const el = this.elements.get(frameId);
1081
1440
  if (el) {
1441
+ // Reset stale completion state when an element iframe re-loads (e.g. after
1442
+ // unmount() + mount() in a SPA). Without this, wasComplete stays true from
1443
+ // the previous session and justCompleted never fires, breaking auto-advance.
1444
+ if (msg.type === 'OZ_FRAME_READY') {
1445
+ this.completionState.set(frameId, false);
1446
+ }
1082
1447
  // Intercept OZ_CHANGE before forwarding — handle auto-advance and CVV sync
1083
1448
  if (msg.type === 'OZ_CHANGE') {
1084
1449
  this.handleElementChange(msg, el);
1085
1450
  }
1086
1451
  el.handleMessage(msg);
1087
- if (msg.type === 'OZ_FRAME_READY' && this.tokenizerReady) {
1088
- el.setTokenizerName(this.tokenizerName);
1089
- }
1090
1452
  }
1091
1453
  }
1092
1454
  }
@@ -1100,7 +1462,7 @@ class OzVault {
1100
1462
  const complete = msg.complete;
1101
1463
  const valid = msg.valid;
1102
1464
  const wasComplete = (_a = this.completionState.get(el.frameId)) !== null && _a !== void 0 ? _a : false;
1103
- this.completionState.set(el.frameId, complete);
1465
+ this.completionState.set(el.frameId, complete && valid);
1104
1466
  // Require valid too — avoids advancing at 13 digits for unknown-brand cards
1105
1467
  // where isComplete() fires before the user has finished typing.
1106
1468
  const justCompleted = complete && valid && !wasComplete;
@@ -1123,7 +1485,7 @@ class OzVault {
1123
1485
  }
1124
1486
  }
1125
1487
  handleTokenizerMessage(msg) {
1126
- var _a, _b;
1488
+ var _a, _b, _c;
1127
1489
  switch (msg.type) {
1128
1490
  case 'OZ_FRAME_READY':
1129
1491
  this.tokenizerReady = true;
@@ -1132,53 +1494,330 @@ class OzVault {
1132
1494
  this.loadErrorTimeoutId = null;
1133
1495
  }
1134
1496
  this.tokenizerWindow = (_b = (_a = this.tokenizerFrame) === null || _a === void 0 ? void 0 : _a.contentWindow) !== null && _b !== void 0 ? _b : null;
1135
- this.sendToTokenizer({ type: 'OZ_INIT', frameId: '__tokenizer__' });
1136
- this.elements.forEach(el => el.setTokenizerName(this.tokenizerName));
1497
+ // Deliver the wax key via OZ_INIT so the tokenizer stores it internally.
1498
+ // If waxKey is still empty (fetchWaxKey hasn't resolved yet), it will be
1499
+ // sent again from create() once the key is available.
1500
+ this.sendToTokenizer(Object.assign({ type: 'OZ_INIT', frameId: '__tokenizer__' }, (this.waxKey ? { waxKey: this.waxKey } : {})));
1501
+ (_c = this._onReady) === null || _c === void 0 ? void 0 : _c.call(this);
1137
1502
  break;
1138
1503
  case 'OZ_TOKEN_RESULT': {
1504
+ if (typeof msg.requestId !== 'string' || !msg.requestId) {
1505
+ console.error('[OzVault] OZ_TOKEN_RESULT missing requestId — discarding message.');
1506
+ break;
1507
+ }
1139
1508
  const pending = this.tokenizeResolvers.get(msg.requestId);
1140
1509
  if (pending) {
1141
1510
  this.tokenizeResolvers.delete(msg.requestId);
1142
- const card = msg.card;
1143
- pending.resolve(Object.assign(Object.assign({ token: msg.token, cvcSession: msg.cvcSession }, (card ? { card } : {})), (pending.billing ? { billing: pending.billing } : {})));
1511
+ if (pending.timeoutId != null)
1512
+ clearTimeout(pending.timeoutId);
1513
+ const token = msg.token;
1514
+ if (typeof token !== 'string' || !token) {
1515
+ pending.reject(new OzError('Vault returned a token result with a missing or empty token — possible vault API change.', undefined, 'server'));
1516
+ break;
1517
+ }
1518
+ const card = isCardMetadata(msg.card) ? msg.card : undefined;
1519
+ pending.resolve(Object.assign(Object.assign({ token, cvcSession: typeof msg.cvcSession === 'string' && msg.cvcSession ? msg.cvcSession : undefined }, (card ? { card } : {})), (pending.billing ? { billing: pending.billing } : {})));
1520
+ // Increment the per-key success counter and proactively refresh once
1521
+ // the budget is exhausted so the next createToken() call uses a fresh
1522
+ // key without waiting for a vault rejection.
1523
+ this._tokenizeSuccessCount++;
1524
+ if (this._tokenizeSuccessCount >= this._maxTokenizeCalls) {
1525
+ this.refreshWaxKey().catch((err) => {
1526
+ console.warn('[OzVault] Post-budget wax key refresh failed:', err instanceof Error ? err.message : err);
1527
+ });
1528
+ }
1144
1529
  }
1145
1530
  break;
1146
1531
  }
1147
1532
  case 'OZ_TOKEN_ERROR': {
1533
+ if (typeof msg.requestId !== 'string' || !msg.requestId) {
1534
+ console.error('[OzVault] OZ_TOKEN_ERROR missing requestId — discarding message.');
1535
+ break;
1536
+ }
1537
+ const raw = typeof msg.error === 'string' ? msg.error : '';
1538
+ const errorCode = isOzErrorCode(msg.errorCode) ? msg.errorCode : 'unknown';
1148
1539
  const pending = this.tokenizeResolvers.get(msg.requestId);
1149
1540
  if (pending) {
1150
1541
  this.tokenizeResolvers.delete(msg.requestId);
1151
- const raw = msg.error;
1152
- const errorCode = (msg.errorCode || 'unknown');
1542
+ if (pending.timeoutId != null)
1543
+ clearTimeout(pending.timeoutId);
1544
+ // Auto-refresh: if the wax key expired or was consumed and we haven't
1545
+ // already retried for this request, transparently re-mint and retry.
1546
+ if (this.isRefreshableAuthError(errorCode, raw) && !pending.retried && this._storedFetchWaxKey) {
1547
+ this.refreshWaxKey().then(() => {
1548
+ if (this._destroyed) {
1549
+ pending.reject(new OzError('Vault destroyed during wax key refresh.'));
1550
+ return;
1551
+ }
1552
+ const newRequestId = `req-${uuid()}`;
1553
+ // _tokenizing is still 'card' (cleanup() hasn't been called yet)
1554
+ this.tokenizeResolvers.set(newRequestId, Object.assign(Object.assign({}, pending), { retried: true }));
1555
+ try {
1556
+ const retryCardChannels = pending.readyElements.map(() => new MessageChannel());
1557
+ this.sendToTokenizer({
1558
+ type: 'OZ_TOKENIZE',
1559
+ requestId: newRequestId,
1560
+ waxKey: this.waxKey,
1561
+ tokenizationSessionId: this.tokenizationSessionId,
1562
+ pubKey: this.pubKey,
1563
+ firstName: pending.firstName,
1564
+ lastName: pending.lastName,
1565
+ fieldCount: pending.fieldCount,
1566
+ }, retryCardChannels.map(ch => ch.port1));
1567
+ pending.readyElements.forEach((el, i) => el.beginCollect(newRequestId, retryCardChannels[i].port2));
1568
+ const retryCardTimeoutId = setTimeout(() => {
1569
+ if (this.tokenizeResolvers.has(newRequestId)) {
1570
+ this.tokenizeResolvers.delete(newRequestId);
1571
+ this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId: newRequestId });
1572
+ pending.reject(new OzError('Tokenization timed out after wax key refresh.', undefined, 'timeout'));
1573
+ }
1574
+ }, 30000);
1575
+ const retryCardEntry = this.tokenizeResolvers.get(newRequestId);
1576
+ if (retryCardEntry)
1577
+ retryCardEntry.timeoutId = retryCardTimeoutId;
1578
+ }
1579
+ catch (setupErr) {
1580
+ this.tokenizeResolvers.delete(newRequestId);
1581
+ pending.reject(setupErr instanceof OzError ? setupErr : new OzError('Retry tokenization failed to start'));
1582
+ }
1583
+ }).catch((refreshErr) => {
1584
+ const msg = refreshErr instanceof Error ? refreshErr.message : 'Wax key refresh failed';
1585
+ pending.reject(new OzError(msg, undefined, 'auth'));
1586
+ });
1587
+ break;
1588
+ }
1153
1589
  pending.reject(new OzError(normalizeVaultError(raw), raw, errorCode));
1154
1590
  }
1155
1591
  // Also check bank resolvers — both card and bank errors use OZ_TOKEN_ERROR
1156
1592
  const bankPending = this.bankTokenizeResolvers.get(msg.requestId);
1157
1593
  if (bankPending) {
1158
1594
  this.bankTokenizeResolvers.delete(msg.requestId);
1159
- const raw = msg.error;
1160
- const errorCode = (msg.errorCode || 'unknown');
1595
+ if (bankPending.timeoutId != null)
1596
+ clearTimeout(bankPending.timeoutId);
1597
+ if (this.isRefreshableAuthError(errorCode, raw) && !bankPending.retried && this._storedFetchWaxKey) {
1598
+ this.refreshWaxKey().then(() => {
1599
+ if (this._destroyed) {
1600
+ bankPending.reject(new OzError('Vault destroyed during wax key refresh.'));
1601
+ return;
1602
+ }
1603
+ const newRequestId = `req-${uuid()}`;
1604
+ this.bankTokenizeResolvers.set(newRequestId, Object.assign(Object.assign({}, bankPending), { retried: true }));
1605
+ try {
1606
+ const retryBankChannels = bankPending.readyElements.map(() => new MessageChannel());
1607
+ this.sendToTokenizer({
1608
+ type: 'OZ_BANK_TOKENIZE',
1609
+ requestId: newRequestId,
1610
+ waxKey: this.waxKey,
1611
+ tokenizationSessionId: this.tokenizationSessionId,
1612
+ pubKey: this.pubKey,
1613
+ firstName: bankPending.firstName,
1614
+ lastName: bankPending.lastName,
1615
+ fieldCount: bankPending.fieldCount,
1616
+ }, retryBankChannels.map(ch => ch.port1));
1617
+ bankPending.readyElements.forEach((el, i) => el.beginCollect(newRequestId, retryBankChannels[i].port2));
1618
+ const retryBankTimeoutId = setTimeout(() => {
1619
+ if (this.bankTokenizeResolvers.has(newRequestId)) {
1620
+ this.bankTokenizeResolvers.delete(newRequestId);
1621
+ this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId: newRequestId });
1622
+ bankPending.reject(new OzError('Bank tokenization timed out after wax key refresh.', undefined, 'timeout'));
1623
+ }
1624
+ }, 30000);
1625
+ const retryBankEntry = this.bankTokenizeResolvers.get(newRequestId);
1626
+ if (retryBankEntry)
1627
+ retryBankEntry.timeoutId = retryBankTimeoutId;
1628
+ }
1629
+ catch (setupErr) {
1630
+ this.bankTokenizeResolvers.delete(newRequestId);
1631
+ bankPending.reject(setupErr instanceof OzError ? setupErr : new OzError('Retry bank tokenization failed to start'));
1632
+ }
1633
+ }).catch((refreshErr) => {
1634
+ const msg = refreshErr instanceof Error ? refreshErr.message : 'Wax key refresh failed';
1635
+ bankPending.reject(new OzError(msg, undefined, 'auth'));
1636
+ });
1637
+ break;
1638
+ }
1161
1639
  bankPending.reject(new OzError(normalizeBankVaultError(raw), raw, errorCode));
1162
1640
  }
1163
1641
  break;
1164
1642
  }
1165
1643
  case 'OZ_BANK_TOKEN_RESULT': {
1644
+ if (typeof msg.requestId !== 'string' || !msg.requestId) {
1645
+ console.error('[OzVault] OZ_BANK_TOKEN_RESULT missing requestId — discarding message.');
1646
+ break;
1647
+ }
1166
1648
  const pending = this.bankTokenizeResolvers.get(msg.requestId);
1167
1649
  if (pending) {
1168
1650
  this.bankTokenizeResolvers.delete(msg.requestId);
1169
- const bank = msg.bank;
1170
- pending.resolve(Object.assign({ token: msg.token }, (bank ? { bank } : {})));
1651
+ if (pending.timeoutId != null)
1652
+ clearTimeout(pending.timeoutId);
1653
+ const token = msg.token;
1654
+ if (typeof token !== 'string' || !token) {
1655
+ pending.reject(new OzError('Vault returned a bank token result with a missing or empty token — possible vault API change.', undefined, 'server'));
1656
+ break;
1657
+ }
1658
+ const bank = isBankAccountMetadata(msg.bank) ? msg.bank : undefined;
1659
+ pending.resolve(Object.assign({ token }, (bank ? { bank } : {})));
1660
+ // Same proactive refresh logic as card tokenization.
1661
+ this._tokenizeSuccessCount++;
1662
+ if (this._tokenizeSuccessCount >= this._maxTokenizeCalls) {
1663
+ this.refreshWaxKey().catch((err) => {
1664
+ console.warn('[OzVault] Post-budget wax key refresh failed:', err instanceof Error ? err.message : err);
1665
+ });
1666
+ }
1171
1667
  }
1172
1668
  break;
1173
1669
  }
1174
1670
  }
1175
1671
  }
1176
- sendToTokenizer(data) {
1672
+ /**
1673
+ * Returns true when an OZ_TOKEN_ERROR should trigger a wax key refresh.
1674
+ *
1675
+ * Primary path: vault returns 401/403 → errorCode 'auth'.
1676
+ * Defensive path: vault returns 400 → errorCode 'validation', but the raw
1677
+ * message contains wax-key-specific language (consumed, expired, invalid key,
1678
+ * etc.). This avoids a hard dependency on the vault returning a unified HTTP
1679
+ * status for consumed-key vs expired-key failures — both should refresh.
1680
+ *
1681
+ * Deliberately excludes 'network', 'timeout', and 'server' codes (transient
1682
+ * errors are already retried in fetchWithRetry) and 'unknown' (too broad).
1683
+ */
1684
+ isRefreshableAuthError(errorCode, raw) {
1685
+ if (errorCode === 'auth')
1686
+ return true;
1687
+ if (errorCode === 'validation') {
1688
+ const msg = raw.toLowerCase();
1689
+ // Only treat validation errors as wax-related if the message explicitly
1690
+ // names the wax/tokenization session mechanism. A bare "session" match
1691
+ // was too broad — any message mentioning "session" (e.g. a merchant
1692
+ // session field error) would trigger a spurious re-mint.
1693
+ return (msg.includes('wax') ||
1694
+ msg.includes('expired') ||
1695
+ msg.includes('consumed') ||
1696
+ msg.includes('invalid key') ||
1697
+ msg.includes('key not found') ||
1698
+ msg.includes('tokenization session'));
1699
+ }
1700
+ return false;
1701
+ }
1702
+ /**
1703
+ * Re-mints the wax key using the stored fetchWaxKey callback and updates
1704
+ * the tokenizer with the new key. Used for transparent auto-refresh when
1705
+ * the vault returns an auth error on tokenization.
1706
+ *
1707
+ * Only one refresh runs at a time — concurrent retries share the same promise.
1708
+ */
1709
+ refreshWaxKey() {
1710
+ var _a;
1711
+ if (this._waxRefreshing)
1712
+ return this._waxRefreshing;
1713
+ if (!this._storedFetchWaxKey) {
1714
+ return Promise.reject(new OzError('Wax key expired and no fetchWaxKey callback is available for auto-refresh. Call OzVault.create() again to obtain a new vault instance.', undefined, 'auth'));
1715
+ }
1716
+ const newSessionId = uuid();
1717
+ (_a = this._onWaxRefresh) === null || _a === void 0 ? void 0 : _a.call(this);
1718
+ this._waxRefreshing = this._storedFetchWaxKey(newSessionId)
1719
+ .then(newWaxKey => {
1720
+ if (typeof newWaxKey !== 'string' || !newWaxKey.trim()) {
1721
+ throw new OzError('fetchWaxKey returned an empty string during auto-refresh.', undefined, 'auth');
1722
+ }
1723
+ if (!this._destroyed) {
1724
+ this.waxKey = newWaxKey;
1725
+ this.tokenizationSessionId = newSessionId;
1726
+ this._tokenizeSuccessCount = 0;
1727
+ }
1728
+ if (!this._destroyed && this.tokenizerReady) {
1729
+ this.sendToTokenizer({ type: 'OZ_INIT', frameId: '__tokenizer__', waxKey: newWaxKey });
1730
+ }
1731
+ })
1732
+ .finally(() => {
1733
+ this._waxRefreshing = null;
1734
+ });
1735
+ return this._waxRefreshing;
1736
+ }
1737
+ sendToTokenizer(data, transfer) {
1177
1738
  var _a;
1178
1739
  const msg = Object.assign({ __oz: true, vaultId: this.vaultId }, data);
1179
- (_a = this.tokenizerWindow) === null || _a === void 0 ? void 0 : _a.postMessage(msg, this.frameOrigin);
1740
+ (_a = this.tokenizerWindow) === null || _a === void 0 ? void 0 : _a.postMessage(msg, this.frameOrigin, transfer !== null && transfer !== void 0 ? transfer : []);
1180
1741
  }
1181
1742
  }
1182
1743
 
1183
- export { OzElement, OzError, OzVault, normalizeBankVaultError, normalizeCardSaleError, normalizeVaultError };
1744
+ /**
1745
+ * Creates a ready-to-use `fetchWaxKey` callback for `OzVault.create()` and `<OzElements>`.
1746
+ *
1747
+ * Calls your backend mint endpoint with `{ sessionId }` and returns the wax key string.
1748
+ * Throws on non-OK responses or a missing `waxKey` field so the vault can surface the
1749
+ * error through its normal error path.
1750
+ *
1751
+ * Each call enforces a 10-second per-attempt timeout. On a pure network-level
1752
+ * failure (connection refused, DNS failure, etc.) the call is retried once after
1753
+ * 750ms before throwing. HTTP errors (4xx/5xx) are never retried — they indicate
1754
+ * an endpoint misconfiguration or an invalid key, not a transient failure.
1755
+ *
1756
+ * The mint endpoint is typically the one-line `createMintWaxHandler` / `createMintWaxMiddleware`
1757
+ * from `@ozura/elements/server`.
1758
+ *
1759
+ * @param mintUrl - Absolute or relative URL of your wax-key mint endpoint, e.g. `'/api/mint-wax'`.
1760
+ *
1761
+ * @example
1762
+ * // Vanilla JS
1763
+ * const vault = await OzVault.create({
1764
+ * pubKey: 'pk_live_...',
1765
+ * fetchWaxKey: createFetchWaxKey('/api/mint-wax'),
1766
+ * });
1767
+ *
1768
+ * @example
1769
+ * // React
1770
+ * <OzElements pubKey="pk_live_..." fetchWaxKey={createFetchWaxKey('/api/mint-wax')}>
1771
+ */
1772
+ function createFetchWaxKey(mintUrl) {
1773
+ const TIMEOUT_MS = 10000;
1774
+ // Each attempt gets its own AbortController so a timeout on attempt 1 does
1775
+ // not bleed into the retry. Uses AbortController + setTimeout instead of
1776
+ // AbortSignal.timeout() to support environments without that API.
1777
+ const attemptFetch = (sessionId) => {
1778
+ const controller = new AbortController();
1779
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
1780
+ return fetch(mintUrl, {
1781
+ method: 'POST',
1782
+ headers: { 'Content-Type': 'application/json' },
1783
+ body: JSON.stringify({ sessionId }),
1784
+ signal: controller.signal,
1785
+ }).finally(() => clearTimeout(timer));
1786
+ };
1787
+ return async (sessionId) => {
1788
+ let res;
1789
+ try {
1790
+ res = await attemptFetch(sessionId);
1791
+ }
1792
+ catch (firstErr) {
1793
+ // Abort/timeout should not be retried — the server received nothing or
1794
+ // we already waited the full timeout duration.
1795
+ if (firstErr instanceof Error && (firstErr.name === 'AbortError' || firstErr.name === 'TimeoutError')) {
1796
+ throw new OzError(`Wax key mint timed out after ${TIMEOUT_MS / 1000}s (${mintUrl})`, undefined, 'timeout');
1797
+ }
1798
+ // Pure network error (offline, DNS, connection refused) — retry once
1799
+ // after a short pause in case of a transient blip.
1800
+ await new Promise(resolve => setTimeout(resolve, 750));
1801
+ try {
1802
+ res = await attemptFetch(sessionId);
1803
+ }
1804
+ catch (retryErr) {
1805
+ const msg = retryErr instanceof Error ? retryErr.message : 'Network error';
1806
+ throw new OzError(`Could not reach wax key mint endpoint (${mintUrl}): ${msg}`, undefined, 'network');
1807
+ }
1808
+ }
1809
+ const data = await res.json().catch(() => ({}));
1810
+ if (!res.ok) {
1811
+ throw new OzError(typeof data.error === 'string' && data.error
1812
+ ? data.error
1813
+ : `Wax key mint failed (HTTP ${res.status})`, undefined, res.status >= 500 ? 'server' : res.status === 401 || res.status === 403 ? 'auth' : 'validation');
1814
+ }
1815
+ if (typeof data.waxKey !== 'string' || !data.waxKey.trim()) {
1816
+ throw new OzError('Mint endpoint response is missing waxKey. Check your /api/mint-wax implementation.', undefined, 'validation');
1817
+ }
1818
+ return data.waxKey;
1819
+ };
1820
+ }
1821
+
1822
+ export { OzElement, OzError, OzVault, createFetchWaxKey, normalizeBankVaultError, normalizeCardSaleError, normalizeVaultError };
1184
1823
  //# sourceMappingURL=oz-elements.esm.js.map