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