@agent-cards/checkout 0.12.0 → 0.13.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.
@@ -1,7 +1,9 @@
1
- import { normalizeCheckoutSignals, assessCheckoutSupport, } from './preflight.js';
1
+ import { normalizeCheckoutSignals, assessCheckoutSupport, cardEntryAttributable, } from './preflight.js';
2
2
  const EVENTS = ['framenavigated', 'frameattached', 'framedetached'];
3
3
  const DEADLINE = Symbol('checkout inspection deadline');
4
4
  const DEFAULTS = { timeoutMs: 1_500, maxFrames: 64, maxSignals: 2_048, maxSnapshotBytes: 262_144 };
5
+ /** Frames at one nesting depth are read together. A remote CDP connection pays per round trip, not per frame. */
6
+ const CONCURRENCY = 8;
5
7
  const encoder = new TextEncoder();
6
8
  function bounded(value, fallback, maximum, minimum = 1) {
7
9
  if (value === undefined)
@@ -27,6 +29,28 @@ async function beforeDeadline(operation, deadline) {
27
29
  clearTimeout(timer);
28
30
  }
29
31
  }
32
+ function httpOrigin(url) {
33
+ if (!url || !/^https?:/i.test(url))
34
+ return null;
35
+ try {
36
+ return new URL(url).origin;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ /** Run `worker` over `items` with bounded concurrency; results keep the input order. */
43
+ async function inBatches(items, size, worker) {
44
+ const results = new Array(items.length);
45
+ let next = 0;
46
+ await Promise.all(Array.from({ length: Math.min(size, items.length) }, async () => {
47
+ while (next < items.length) {
48
+ const index = next++;
49
+ results[index] = await worker(items[index]);
50
+ }
51
+ }));
52
+ return results;
53
+ }
30
54
  /** Read only frame, external asset and form locations; return reviewed rule IDs, never raw URLs. */
31
55
  export async function collectCheckoutSignals(page, options = {}) {
32
56
  const limits = {
@@ -41,6 +65,7 @@ export async function collectCheckoutSignals(page, options = {}) {
41
65
  const registered = [];
42
66
  let transientBytes = 0;
43
67
  let truncated = false;
68
+ let stopped = false;
44
69
  const changed = () => { reasons.add('navigation_changed'); };
45
70
  const limit = (reason) => { reasons.add(reason); truncated = true; };
46
71
  const append = (signal) => {
@@ -58,6 +83,24 @@ export async function collectCheckoutSignals(page, options = {}) {
58
83
  signals.push(signal);
59
84
  return true;
60
85
  };
86
+ const stop = () => {
87
+ if (stopped)
88
+ return true;
89
+ if (performance.now() >= deadline) {
90
+ reasons.add('collection_timeout');
91
+ stopped = true;
92
+ }
93
+ else if (signals.length >= limits.maxSignals) {
94
+ limit('signal_limit');
95
+ stopped = true;
96
+ }
97
+ else if (truncated && reasons.has('snapshot_limit'))
98
+ stopped = true;
99
+ return stopped;
100
+ };
101
+ // Hostnames of visible nested card-entry frames outside the top document's origin. They stay
102
+ // local to this call; only the attribution verdict reaches the observation.
103
+ const cardEntryHosts = new Set();
61
104
  try {
62
105
  for (const event of EVENTS) {
63
106
  page.on(event, changed);
@@ -73,29 +116,25 @@ export async function collectCheckoutSignals(page, options = {}) {
73
116
  const ids = new Map(frames.map((frame, index) => [frame, index]));
74
117
  if (frames.length > limits.maxFrames)
75
118
  limit('frame_limit');
119
+ const topOrigin = frames.length ? httpOrigin(initialUrls.get(frames[0])) : null;
76
120
  const visibility = new Map();
77
- const ordered = frames.slice(0, limits.maxFrames).sort((a, b) => {
78
- const depth = (frame) => {
79
- const visited = new Set();
80
- while (frame.parentFrame() && !visited.has(frame)) {
81
- visited.add(frame);
82
- frame = frame.parentFrame();
83
- }
84
- return visited.size;
85
- };
86
- return depth(a) - depth(b);
87
- });
88
- for (const frame of ordered) {
89
- if (performance.now() >= deadline) {
90
- reasons.add('collection_timeout');
91
- break;
92
- }
93
- if (signals.length >= limits.maxSignals) {
94
- limit('signal_limit');
95
- break;
121
+ const depth = (frame) => {
122
+ const visited = new Set();
123
+ while (frame.parentFrame() && !visited.has(frame)) {
124
+ visited.add(frame);
125
+ frame = frame.parentFrame();
96
126
  }
97
- if (truncated && reasons.has('snapshot_limit'))
98
- break;
127
+ return visited.size;
128
+ };
129
+ // Parents settle before their children so visibility can propagate; siblings share round trips.
130
+ const levels = new Map();
131
+ for (const frame of frames.slice(0, limits.maxFrames)) {
132
+ const level = depth(frame);
133
+ levels.set(level, [...(levels.get(level) ?? []), frame]);
134
+ }
135
+ const readFrame = async (frame) => {
136
+ if (stop())
137
+ return null;
99
138
  const parent = frame.parentFrame();
100
139
  const frame_id = ids.get(frame);
101
140
  const parent_frame_id = parent ? ids.get(parent) ?? null : null;
@@ -103,8 +142,9 @@ export async function collectCheckoutSignals(page, options = {}) {
103
142
  try {
104
143
  if (frame.isDetached()) {
105
144
  reasons.add('frame_unavailable');
106
- continue;
145
+ return null;
107
146
  }
147
+ let released;
108
148
  if (parent) {
109
149
  if (!ids.has(parent))
110
150
  reasons.add('frame_unavailable');
@@ -139,8 +179,9 @@ export async function collectCheckoutSignals(page, options = {}) {
139
179
  visible = visible === false || ownVisibility === false ? false : visible === null || ownVisibility === null ? null : true;
140
180
  }
141
181
  finally {
142
- // Disposal only releases our automation handle. A stalled disposal cannot extend the inspection deadline.
143
- await beforeDeadline(() => element.dispose(), deadline);
182
+ // Disposal only releases our automation handle and overlaps the document read below.
183
+ // A stalled disposal still cannot extend the inspection deadline.
184
+ released = beforeDeadline(() => element.dispose(), deadline).then(() => undefined, error => error);
144
185
  }
145
186
  }
146
187
  visibility.set(frame, visible);
@@ -149,6 +190,19 @@ export async function collectCheckoutSignals(page, options = {}) {
149
190
  let bytes = 0;
150
191
  let signalLimit = false;
151
192
  let byteLimit = false;
193
+ const shown = (element) => {
194
+ if (!element.isConnected)
195
+ return false;
196
+ for (let current = element; current;) {
197
+ const style = getComputedStyle(current);
198
+ if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse' || style.opacity === '0')
199
+ return false;
200
+ const root = current.getRootNode();
201
+ current = current.parentElement ?? (root instanceof ShadowRoot ? root.host : null);
202
+ }
203
+ const rect = element.getBoundingClientRect();
204
+ return rect.width > 0 && rect.height > 0;
205
+ };
152
206
  const elements = document.querySelectorAll('script[src], iframe[src], form[action]');
153
207
  for (let index = 0; index < elements.length; index++) {
154
208
  if (index >= remaining) {
@@ -176,31 +230,63 @@ export async function collectCheckoutSignals(page, options = {}) {
176
230
  byteLimit = true;
177
231
  break;
178
232
  }
179
- let visible = null;
180
- if (kind !== 'script') {
181
- visible = element.isConnected;
182
- for (let current = element; current && visible;) {
183
- const style = getComputedStyle(current);
184
- if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse' || style.opacity === '0')
185
- visible = false;
186
- const root = current.getRootNode();
187
- current = current.parentElement ?? (root instanceof ShadowRoot ? root.host : null);
188
- }
189
- const rect = element.getBoundingClientRect();
190
- visible = visible && rect.width > 0 && rect.height > 0;
191
- }
192
- collected.push({ kind, url, visible });
233
+ collected.push({ kind, url, visible: kind === 'script' ? null : shown(element) });
234
+ }
235
+ // Card-entry detection reads field metadata only: the autocomplete token or the
236
+ // control's name and id. Values, labels and page text are never read.
237
+ let cardEntry = false;
238
+ const inputs = document.querySelectorAll('input');
239
+ for (let index = 0; index < inputs.length && index < 256 && !cardEntry; index++) {
240
+ const input = inputs[index];
241
+ const type = (input.getAttribute('type') || 'text').toLowerCase();
242
+ if (['hidden', 'submit', 'button', 'checkbox', 'radio', 'image', 'reset', 'file', 'range', 'color'].includes(type))
243
+ continue;
244
+ const autocomplete = (input.getAttribute('autocomplete') || '').trim().toLowerCase();
245
+ const label = `${input.getAttribute('name') || ''} ${input.getAttribute('id') || ''}`.toLowerCase().replace(/[^a-z0-9]+/g, '');
246
+ const cardField = /^cc-(?:number|csc|exp|exp-month|exp-year)$/.test(autocomplete)
247
+ || !label.includes('gift') && /cardnumber|cardnum|cardno|ccnumber|ccnum|ccno|cvv|cvc|csc|securitycode|cardcode/.test(label);
248
+ if (cardField && shown(input))
249
+ cardEntry = true;
193
250
  }
194
251
  const documentUrl = document.URL;
195
252
  if (documentUrl.length * 3 > byteBudget)
196
253
  byteLimit = true;
197
- return { documentUrl: byteLimit && documentUrl.length * 3 > byteBudget ? '' : documentUrl, collected, signalLimit, byteLimit };
254
+ return { documentUrl: byteLimit && documentUrl.length * 3 > byteBudget ? '' : documentUrl, collected, signalLimit, byteLimit, cardEntry };
198
255
  }, { remaining: Math.max(0, limits.maxSignals - signals.length - 1), byteBudget: Math.max(0, limits.maxSnapshotBytes - transientBytes) }), deadline);
256
+ const releaseError = released ? await released : undefined;
257
+ if (releaseError !== undefined)
258
+ throw releaseError;
199
259
  if (frame.isDetached())
200
260
  reasons.add('frame_unavailable');
201
- if (data.documentUrl !== initialUrls.get(frame) || frame.url() !== initialUrls.get(frame))
261
+ // Committed URLs are compared with committed URLs. The document URL is compared only for a
262
+ // frame that committed an http(s) document: a script-created frame written through
263
+ // document.open() inherits the merchant URL without navigating anywhere.
264
+ const initial = initialUrls.get(frame) ?? '';
265
+ if (frame.url() !== initial || (/^https?:/i.test(initial) && data.documentUrl !== '' && data.documentUrl !== initial))
202
266
  reasons.add('navigation_changed');
203
- append({ kind: parent ? 'frame' : 'document', url: data.documentUrl, frame_id, parent_frame_id, visible });
267
+ return { frame_id, parent_frame_id, nested: parent !== null, visible, data };
268
+ }
269
+ catch (error) {
270
+ if (error === DEADLINE) {
271
+ reasons.add('collection_timeout');
272
+ stopped = true;
273
+ return null;
274
+ }
275
+ // Browser errors can include complete merchant URLs. Return a bounded reason instead.
276
+ reasons.add('frame_unavailable');
277
+ return null;
278
+ }
279
+ };
280
+ for (const level of [...levels.keys()].sort((a, b) => a - b)) {
281
+ if (stop())
282
+ break;
283
+ const outcomes = await inBatches(levels.get(level), CONCURRENCY, readFrame);
284
+ // Append in frame order so identical pages produce identical observations.
285
+ for (const outcome of outcomes) {
286
+ if (!outcome)
287
+ continue;
288
+ const { data, frame_id, parent_frame_id, nested, visible } = outcome;
289
+ append({ kind: nested ? 'frame' : 'document', url: data.documentUrl, frame_id, parent_frame_id, visible });
204
290
  for (const signal of data.collected) {
205
291
  if (!append({ ...signal, frame_id, parent_frame_id, visible: visible === false ? false : signal.kind === 'script' ? visible : visible === null ? null : signal.visible }))
206
292
  break;
@@ -209,14 +295,11 @@ export async function collectCheckoutSignals(page, options = {}) {
209
295
  limit('signal_limit');
210
296
  if (data.byteLimit)
211
297
  limit('snapshot_limit');
212
- }
213
- catch (error) {
214
- if (error === DEADLINE) {
215
- reasons.add('collection_timeout');
216
- break;
298
+ if (nested && visible === true && data.cardEntry === true) {
299
+ const origin = httpOrigin(data.documentUrl);
300
+ if (origin && origin !== topOrigin)
301
+ cardEntryHosts.add(new URL(origin).hostname);
217
302
  }
218
- // Browser errors can include complete merchant URLs. Return a bounded reason instead.
219
- reasons.add('frame_unavailable');
220
303
  }
221
304
  }
222
305
  if (performance.now() >= deadline)
@@ -239,7 +322,17 @@ export async function collectCheckoutSignals(page, options = {}) {
239
322
  }
240
323
  }
241
324
  }
242
- let snapshot = normalizeCheckoutSignals({ signals, observation: { complete: reasons.size === 0, truncated, reasons: [...reasons] } });
325
+ const observation = () => ({ complete: reasons.size === 0, truncated, reasons: [...reasons] });
326
+ let snapshot = normalizeCheckoutSignals({ signals, observation: observation() });
327
+ // Visible card controls on a site no evidenced processor owns mean the card form belongs to
328
+ // something the catalog has not reviewed. The observation is then incomplete, so a co-loaded
329
+ // SDK script stays a candidate instead of becoming the selected processor. A snapshot past the
330
+ // byte budget is already incomplete and is bounded below instead.
331
+ if (encoder.encode(JSON.stringify(snapshot)).length <= limits.maxSnapshotBytes
332
+ && [...cardEntryHosts].some(host => !cardEntryAttributable(snapshot, host))) {
333
+ reasons.add('card_entry_unrecognized');
334
+ snapshot = normalizeCheckoutSignals({ signals, observation: observation() });
335
+ }
243
336
  if (encoder.encode(JSON.stringify(snapshot)).length > limits.maxSnapshotBytes) {
244
337
  limit('snapshot_limit');
245
338
  const boundedSnapshot = normalizeCheckoutSignals({ signals: [], observation: { complete: false, truncated, reasons: [...reasons] } });
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "url": {
58
58
  "type": "string",
59
- "maxLength": 4096
59
+ "maxLength": 65536
60
60
  }
61
61
  }
62
62
  }
@@ -90,6 +90,7 @@
90
90
  "navigation_changed",
91
91
  "invalid_observation",
92
92
  "invalid_url",
93
+ "card_entry_unrecognized",
93
94
  "collection_incomplete"
94
95
  ]
95
96
  }
@@ -162,7 +163,7 @@
162
163
  "pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]{0,119}$"
163
164
  },
164
165
  "catalog_version": {
165
- "const": "2026-09-14.1"
166
+ "const": "2026-09-15.1"
166
167
  },
167
168
  "integration": {
168
169
  "type": "object",
@@ -358,7 +359,7 @@
358
359
  "const": 1
359
360
  },
360
361
  "catalog_version": {
361
- "const": "2026-09-14.1"
362
+ "const": "2026-09-15.1"
362
363
  },
363
364
  "signals": {
364
365
  "type": "array",
@@ -450,10 +451,29 @@
450
451
  "bambora-na-fields-1-0-5-39",
451
452
  "global-payments-js-4-1-3",
452
453
  "global-payments-js-v1-alias",
454
+ "tranzila-terminal-hosted-page",
455
+ "square-card-element-frame",
456
+ "authorize-net-sim-hosted-form",
457
+ "worldpay-hosted-payment-page",
458
+ "rapyd-hosted-checkout-page",
459
+ "dlocal-smart-fields-parent",
460
+ "dlocal-smart-card-fields",
461
+ "mercado-pago-secure-fields-frame",
462
+ "mercado-pago-checkout-pro-card-form",
453
463
  "square-web-sdk-sandbox",
454
464
  "paysafe-js-test",
455
465
  "worldpay-checkout-sdk-try",
456
- "authorize-net-accept-test"
466
+ "authorize-net-accept-test",
467
+ "tranzila-terminal-hosted-page-directng",
468
+ "square-card-element-frame-sandbox",
469
+ "authorize-net-sim-hosted-form-secure2",
470
+ "worldpay-hosted-payment-page-test",
471
+ "rapyd-hosted-checkout-page-sandbox",
472
+ "adyen-secured-fields-live-us",
473
+ "adyen-secured-fields-live-au",
474
+ "adyen-secured-fields-live-apse",
475
+ "adyen-secured-fields-live-in",
476
+ "adyen-secured-fields-live-nea"
457
477
  ]
458
478
  },
459
479
  "kind": {
@@ -520,6 +540,7 @@
520
540
  "navigation_changed",
521
541
  "invalid_observation",
522
542
  "invalid_url",
543
+ "card_entry_unrecognized",
523
544
  "collection_incomplete"
524
545
  ]
525
546
  }
@@ -550,7 +571,7 @@
550
571
  "pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]{0,119}$"
551
572
  },
552
573
  "catalog_version": {
553
- "const": "2026-09-14.1"
574
+ "const": "2026-09-15.1"
554
575
  },
555
576
  "integration": {
556
577
  "type": "object",
@@ -753,7 +774,7 @@
753
774
  "const": 1
754
775
  },
755
776
  "catalog_version": {
756
- "const": "2026-09-14.1"
777
+ "const": "2026-09-15.1"
757
778
  },
758
779
  "integration": {
759
780
  "type": "object",
@@ -947,10 +968,29 @@
947
968
  "bambora-na-fields-1-0-5-39",
948
969
  "global-payments-js-4-1-3",
949
970
  "global-payments-js-v1-alias",
971
+ "tranzila-terminal-hosted-page",
972
+ "square-card-element-frame",
973
+ "authorize-net-sim-hosted-form",
974
+ "worldpay-hosted-payment-page",
975
+ "rapyd-hosted-checkout-page",
976
+ "dlocal-smart-fields-parent",
977
+ "dlocal-smart-card-fields",
978
+ "mercado-pago-secure-fields-frame",
979
+ "mercado-pago-checkout-pro-card-form",
950
980
  "square-web-sdk-sandbox",
951
981
  "paysafe-js-test",
952
982
  "worldpay-checkout-sdk-try",
953
- "authorize-net-accept-test"
983
+ "authorize-net-accept-test",
984
+ "tranzila-terminal-hosted-page-directng",
985
+ "square-card-element-frame-sandbox",
986
+ "authorize-net-sim-hosted-form-secure2",
987
+ "worldpay-hosted-payment-page-test",
988
+ "rapyd-hosted-checkout-page-sandbox",
989
+ "adyen-secured-fields-live-us",
990
+ "adyen-secured-fields-live-au",
991
+ "adyen-secured-fields-live-apse",
992
+ "adyen-secured-fields-live-in",
993
+ "adyen-secured-fields-live-nea"
954
994
  ]
955
995
  },
956
996
  "kind": {
@@ -1046,6 +1086,7 @@
1046
1086
  "navigation_changed",
1047
1087
  "invalid_observation",
1048
1088
  "invalid_url",
1089
+ "card_entry_unrecognized",
1049
1090
  "collection_incomplete"
1050
1091
  ]
1051
1092
  }
@@ -1,11 +1,12 @@
1
1
  /** Advisory page discovery. These rules never admit a payment destination. */
2
2
  export declare const CHECKOUT_PREFLIGHT_VERSION = "1";
3
- export declare const CHECKOUT_PREFLIGHT_CATALOG_VERSION = "2026-09-14.1";
3
+ export declare const CHECKOUT_PREFLIGHT_CATALOG_VERSION = "2026-09-15.1";
4
4
  export declare const CHECKOUT_PREFLIGHT_LIMITS: Readonly<{
5
5
  frames: 64;
6
6
  signals: 2048;
7
7
  snapshot_bytes: 262144;
8
8
  url_length: 4096;
9
+ raw_url_length: 65536;
9
10
  }>;
10
11
  export type CheckoutSignalKind = 'document' | 'frame' | 'script' | 'iframe' | 'form';
11
12
  export type CheckoutScenario = 'one_time' | 'save_card' | 'subscription_initial' | 'subscription_renewal';
@@ -126,6 +127,7 @@ export declare function getCheckoutPreflightCatalog(): {
126
127
  signals: 2048;
127
128
  snapshot_bytes: 262144;
128
129
  url_length: 4096;
130
+ raw_url_length: 65536;
129
131
  }>;
130
132
  known_psps: {
131
133
  psp: string;
@@ -1347,6 +1349,14 @@ export declare function getCheckoutPreflightCatalog(): {
1347
1349
  };
1348
1350
  observation_reason_codes: string[];
1349
1351
  };
1352
+ /**
1353
+ * Whether a visible card-entry frame on `hostname` can belong to a processor already evidenced in the
1354
+ * snapshot. A collector reports `card_entry_unrecognized` when this is false, so co-loaded SDK scripts
1355
+ * cannot be presented as the processor behind card controls that no reviewed fingerprint explains, and
1356
+ * a card form with no processor evidence at all is reported rather than passing as an empty page.
1357
+ * The hostname stays local to the caller; nothing here enters evidence.
1358
+ */
1359
+ export declare function cardEntryAttributable(input: CheckoutSignalSnapshot | unknown, hostname: unknown): boolean;
1350
1360
  /** Classify a sanitized snapshot. No browser, HTTP, or payment operations occur here. */
1351
1361
  export declare function assessCheckoutSupport(input: CheckoutSignalSnapshot | unknown, options?: CheckoutPreflightOptions): CheckoutSupportResult;
1352
1362
  /** Machine-readable transport shapes; semantic compatibility is checked by the classifier. */