@ozura/elements 1.2.0-next.26 → 1.2.0-next.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -326,7 +326,7 @@ function sanitizeOptions(options) {
326
326
  * it never holds raw card data — all sensitive values live in the iframe.
327
327
  */
328
328
  class OzElement {
329
- constructor(elementType, options, vaultId, frameBaseUrl, fonts = [], appearanceStyle) {
329
+ constructor(elementType, options, vaultId, frameBaseUrl, fonts = [], appearanceStyle, onDestroy) {
330
330
  this.iframe = null;
331
331
  this._frameWindow = null;
332
332
  this._ready = false;
@@ -342,6 +342,7 @@ class OzElement {
342
342
  this.fonts = fonts;
343
343
  this.appearanceStyle = appearanceStyle;
344
344
  this.frameId = `oz-${elementType}-${uuid()}`;
345
+ this._onDestroy = onDestroy;
345
346
  }
346
347
  /** The element type this proxy represents. */
347
348
  get type() {
@@ -497,9 +498,14 @@ class OzElement {
497
498
  * and prevents future use. Distinct from `unmount()` which allows re-mounting.
498
499
  */
499
500
  destroy() {
501
+ var _a;
500
502
  this.unmount();
501
503
  this.handlers.clear();
502
504
  this._destroyed = true;
505
+ // Notify OzVault so it can prune the stale frameId entry from its elements
506
+ // and completionState maps. Without this, manually calling el.destroy() leaks
507
+ // map entries that grow unboundedly in SPA scenarios with repeated mount/unmount.
508
+ (_a = this._onDestroy) === null || _a === void 0 ? void 0 : _a.call(this);
503
509
  }
504
510
  // ─── Called by OzVault ───────────────────────────────────────────────────
505
511
  /**
@@ -902,7 +908,22 @@ function createSessionFetcher(url) {
902
908
  throw new OzError(`Could not reach session endpoint (${url}): ${msg}`, undefined, 'network');
903
909
  }
904
910
  }
905
- const data = await res.json().catch(() => ({}));
911
+ // Parse JSON separately from the ok-check so that a non-JSON error body
912
+ // (HTML error page, WAF block, CDN 503) produces the right error code.
913
+ // Previously res.json() was attempted before res.ok was checked; a parse
914
+ // failure on a 5xx HTML body would fall through as {} and produce a
915
+ // misleading 'validation' code when the real cause is a server/network issue.
916
+ let data = {};
917
+ try {
918
+ data = await res.json();
919
+ }
920
+ catch (_a) {
921
+ if (!res.ok) {
922
+ throw new OzError(`Session endpoint returned HTTP ${res.status} with a non-JSON body`, undefined, res.status >= 500 ? 'server' : res.status === 401 || res.status === 403 ? 'auth' : 'validation');
923
+ }
924
+ // HTTP 200 but body isn't JSON — this is a misconfigured session endpoint.
925
+ throw new OzError('Session endpoint returned HTTP 200 but the response body is not valid JSON. Check your /api/oz-session implementation.', undefined, 'validation');
926
+ }
906
927
  if (!res.ok) {
907
928
  throw new OzError(typeof data.error === 'string' && data.error
908
929
  ? data.error
@@ -920,10 +941,19 @@ function createSessionFetcher(url) {
920
941
  }
921
942
 
922
943
  function isCardMetadata(v) {
923
- return !!v && typeof v === 'object' && typeof v.last4 === 'string';
944
+ if (!v || typeof v !== 'object')
945
+ return false;
946
+ const r = v;
947
+ return (typeof r.last4 === 'string' &&
948
+ typeof r.brand === 'string' &&
949
+ typeof r.expMonth === 'string' &&
950
+ typeof r.expYear === 'string');
924
951
  }
925
952
  function isBankAccountMetadata(v) {
926
- return !!v && typeof v === 'object' && typeof v.last4 === 'string';
953
+ if (!v || typeof v !== 'object')
954
+ return false;
955
+ const r = v;
956
+ return typeof r.last4 === 'string' && typeof r.routingNumberLast4 === 'string';
927
957
  }
928
958
  const DEFAULT_FRAME_BASE_URL = "https://lively-hill-097170c0f.4.azurestaticapps.net";
929
959
  /**
@@ -933,16 +963,10 @@ const DEFAULT_FRAME_BASE_URL = "https://lively-hill-097170c0f.4.azurestaticapps.
933
963
  * Use the static `OzVault.create()` factory — do not call `new OzVault()` directly.
934
964
  *
935
965
  * @example
966
+ * // Recommended — pass sessionUrl and let the SDK call your backend automatically
936
967
  * const vault = await OzVault.create({
937
- * pubKey: 'pk_live_...',
938
- * fetchWaxKey: async (sessionId) => {
939
- * // Call your backend — which calls ozura.mintWaxKey() from @ozura/elements/server
940
- * const { waxKey } = await fetch('/api/mint-wax', {
941
- * method: 'POST',
942
- * body: JSON.stringify({ sessionId }),
943
- * }).then(r => r.json());
944
- * return waxKey;
945
- * },
968
+ * pubKey: 'pk_prod_...', // or 'pk_test_...' for test mode
969
+ * sessionUrl: '/api/oz-session', // backend endpoint that calls ozura.createSession()
946
970
  * });
947
971
  * const cardNum = vault.createElement('cardNumber');
948
972
  * cardNum.mount('#card-number');
@@ -1178,7 +1202,12 @@ class OzVault {
1178
1202
  this.completionState.delete(existing.frameId);
1179
1203
  existing.destroy();
1180
1204
  }
1181
- const el = new OzElement(type, options, this.vaultId, this.frameBaseUrl, this.fonts, this.resolvedAppearance);
1205
+ const el = new OzElement(type, options, this.vaultId, this.frameBaseUrl, this.fonts, this.resolvedAppearance, () => {
1206
+ // Prune vault-level maps when the element is manually destroyed so they
1207
+ // don't grow unboundedly in SPA scenarios with repeated mount/unmount cycles.
1208
+ this.elements.delete(el.frameId);
1209
+ this.completionState.delete(el.frameId);
1210
+ });
1182
1211
  this.elements.set(el.frameId, el);
1183
1212
  typeMap.set(type, el);
1184
1213
  return el;
@@ -1835,61 +1864,65 @@ class OzVault {
1835
1864
  }
1836
1865
  pending.reject(new OzError(normalizeVaultError(raw), raw, errorCode));
1837
1866
  }
1838
- // Also check bank resolvers — both card and bank errors use OZ_TOKEN_ERROR
1839
- const bankPending = this.bankTokenizeResolvers.get(msg.requestId);
1840
- if (bankPending) {
1841
- this.bankTokenizeResolvers.delete(msg.requestId);
1842
- if (bankPending.timeoutId != null)
1843
- clearTimeout(bankPending.timeoutId);
1844
- if (this.isRefreshableAuthError(errorCode, raw) && !bankPending.retried && this._storedFetchWaxKey) {
1845
- const resetCountAtRetry = this._resetCount;
1846
- this.refreshWaxKey().then(() => {
1847
- if (this._destroyed) {
1848
- bankPending.reject(new OzError('Vault destroyed during wax key refresh.'));
1849
- return;
1850
- }
1851
- if (this._resetCount !== resetCountAtRetry) {
1852
- bankPending.reject(new OzError('Vault was reset while tokenization was in progress.'));
1853
- return;
1854
- }
1855
- const newRequestId = `req-${uuid()}`;
1856
- this.bankTokenizeResolvers.set(newRequestId, Object.assign(Object.assign({}, bankPending), { retried: true }));
1857
- try {
1858
- const retryBankChannels = bankPending.readyElements.map(() => new MessageChannel());
1859
- this.sendToTokenizer({
1860
- type: 'OZ_BANK_TOKENIZE',
1861
- requestId: newRequestId,
1862
- waxKey: this.waxKey,
1863
- tokenizationSessionId: this.tokenizationSessionId,
1864
- pubKey: this.pubKey,
1865
- firstName: bankPending.firstName,
1866
- lastName: bankPending.lastName,
1867
- fieldCount: bankPending.fieldCount,
1868
- }, retryBankChannels.map(ch => ch.port1));
1869
- bankPending.readyElements.forEach((el, i) => el.beginCollect(newRequestId, retryBankChannels[i].port2));
1870
- const retryBankTimeoutId = setTimeout(() => {
1871
- if (this.bankTokenizeResolvers.has(newRequestId)) {
1872
- this.bankTokenizeResolvers.delete(newRequestId);
1873
- this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId: newRequestId });
1874
- bankPending.reject(new OzError('Bank tokenization timed out after wax key refresh.', undefined, 'timeout'));
1875
- }
1876
- }, 30000);
1877
- const retryBankEntry = this.bankTokenizeResolvers.get(newRequestId);
1878
- if (retryBankEntry)
1879
- retryBankEntry.timeoutId = retryBankTimeoutId;
1880
- }
1881
- catch (setupErr) {
1882
- this.bankTokenizeResolvers.delete(newRequestId);
1883
- bankPending.reject(setupErr instanceof OzError ? setupErr : new OzError('Retry bank tokenization failed to start'));
1884
- }
1885
- }).catch((refreshErr) => {
1886
- const msg = refreshErr instanceof Error ? refreshErr.message : 'Wax key refresh failed';
1887
- bankPending.reject(new OzError(msg, undefined, 'auth'));
1888
- });
1889
- break;
1867
+ else {
1868
+ // Also check bank resolvers — both card and bank errors use OZ_TOKEN_ERROR.
1869
+ // Use else-if rather than sequential checks so a UUID collision (however
1870
+ // improbable) can never trigger double-rejection of two unrelated resolvers.
1871
+ const bankPending = this.bankTokenizeResolvers.get(msg.requestId);
1872
+ if (bankPending) {
1873
+ this.bankTokenizeResolvers.delete(msg.requestId);
1874
+ if (bankPending.timeoutId != null)
1875
+ clearTimeout(bankPending.timeoutId);
1876
+ if (this.isRefreshableAuthError(errorCode, raw) && !bankPending.retried && this._storedFetchWaxKey) {
1877
+ const resetCountAtRetry = this._resetCount;
1878
+ this.refreshWaxKey().then(() => {
1879
+ if (this._destroyed) {
1880
+ bankPending.reject(new OzError('Vault destroyed during wax key refresh.'));
1881
+ return;
1882
+ }
1883
+ if (this._resetCount !== resetCountAtRetry) {
1884
+ bankPending.reject(new OzError('Vault was reset while tokenization was in progress.'));
1885
+ return;
1886
+ }
1887
+ const newRequestId = `req-${uuid()}`;
1888
+ this.bankTokenizeResolvers.set(newRequestId, Object.assign(Object.assign({}, bankPending), { retried: true }));
1889
+ try {
1890
+ const retryBankChannels = bankPending.readyElements.map(() => new MessageChannel());
1891
+ this.sendToTokenizer({
1892
+ type: 'OZ_BANK_TOKENIZE',
1893
+ requestId: newRequestId,
1894
+ waxKey: this.waxKey,
1895
+ tokenizationSessionId: this.tokenizationSessionId,
1896
+ pubKey: this.pubKey,
1897
+ firstName: bankPending.firstName,
1898
+ lastName: bankPending.lastName,
1899
+ fieldCount: bankPending.fieldCount,
1900
+ }, retryBankChannels.map(ch => ch.port1));
1901
+ bankPending.readyElements.forEach((el, i) => el.beginCollect(newRequestId, retryBankChannels[i].port2));
1902
+ const retryBankTimeoutId = setTimeout(() => {
1903
+ if (this.bankTokenizeResolvers.has(newRequestId)) {
1904
+ this.bankTokenizeResolvers.delete(newRequestId);
1905
+ this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId: newRequestId });
1906
+ bankPending.reject(new OzError('Bank tokenization timed out after wax key refresh.', undefined, 'timeout'));
1907
+ }
1908
+ }, 30000);
1909
+ const retryBankEntry = this.bankTokenizeResolvers.get(newRequestId);
1910
+ if (retryBankEntry)
1911
+ retryBankEntry.timeoutId = retryBankTimeoutId;
1912
+ }
1913
+ catch (setupErr) {
1914
+ this.bankTokenizeResolvers.delete(newRequestId);
1915
+ bankPending.reject(setupErr instanceof OzError ? setupErr : new OzError('Retry bank tokenization failed to start'));
1916
+ }
1917
+ }).catch((refreshErr) => {
1918
+ const msg = refreshErr instanceof Error ? refreshErr.message : 'Wax key refresh failed';
1919
+ bankPending.reject(new OzError(msg, undefined, 'auth'));
1920
+ });
1921
+ break;
1922
+ }
1923
+ bankPending.reject(new OzError(normalizeBankVaultError(raw), raw, errorCode));
1890
1924
  }
1891
- bankPending.reject(new OzError(normalizeBankVaultError(raw), raw, errorCode));
1892
- }
1925
+ } // end else (bank path)
1893
1926
  break;
1894
1927
  }
1895
1928
  case 'OZ_BANK_TOKEN_RESULT': {