@visa/cli 4.1.0-rc.229 → 4.1.0-rc.230
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.
- package/README.md +17 -9
- package/dist/checkout-engine/adapters/shopify.d.ts +27 -2
- package/dist/checkout-engine/adapters/shopify.js +194 -20
- package/dist/checkout-engine/cli-engine.d.ts +35 -2
- package/dist/checkout-engine/cli-engine.js +146 -12
- package/dist/checkout-engine/executor.d.ts +17 -0
- package/dist/checkout-engine/executor.js +212 -40
- package/dist/checkout-engine/hosted-approval.js +4 -1
- package/dist/checkout-engine/index.d.ts +3 -2
- package/dist/checkout-engine/index.js +1 -0
- package/dist/checkout-engine/receipt.d.ts +14 -0
- package/dist/checkout-engine/receipt.js +13 -3
- package/dist/checkout-engine/unresolved-charges.js +9 -0
- package/dist/checkout-engine/web-bot-auth.d.ts +7 -1
- package/dist/checkout-engine/web-bot-auth.js +60 -1
- package/dist/cli.js +540 -474
- package/dist/mcp-server/index.js +451 -396
- package/dist/skills/pair-visa-agent/RUNTIMES.md +9 -1
- package/dist/skills/pair-visa-agent/scripts/__tests__/setup.test.mjs +407 -0
- package/dist/skills/pair-visa-agent/scripts/setup.mjs +310 -30
- package/install.ps1 +5 -4
- package/install.sh +1 -1
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +8 -10
- package/server.json +2 -2
|
@@ -26,7 +26,7 @@ import { summarizeFillFailure } from './adapters/generic.js';
|
|
|
26
26
|
import { traceHandleFields } from './trace-handles.js';
|
|
27
27
|
import { readGenericPageAmount } from './amount.js';
|
|
28
28
|
import { webBotAuthHeadersOrNone } from './web-bot-auth.js';
|
|
29
|
-
import {
|
|
29
|
+
import { assertShopifyGuestCheckout, ensureShopifyGuestCheckout, isShopifyCheckoutPage, readShopifyAmount, readStableShopifyAmount, shopifyEnglishCheckoutUrl, } from './adapters/shopify.js';
|
|
30
30
|
export { minorFromDecimal, pageCurrency } from './amount.js';
|
|
31
31
|
const SUBMIT_TEXT = /pay|place order|complete|buy|submit|checkout/i;
|
|
32
32
|
const REVEAL_TEXT = /continue|next|proceed|review|go to payment/i;
|
|
@@ -499,7 +499,10 @@ function recordTransactionFacts(evidence, phase, facts) {
|
|
|
499
499
|
currency: facts.currency,
|
|
500
500
|
});
|
|
501
501
|
}
|
|
502
|
-
function reviewChangeReason(review, merchantHost, facts) {
|
|
502
|
+
function reviewChangeReason(review, merchantHost, facts, merchantOrigin) {
|
|
503
|
+
if (review.merchantOrigin && merchantOrigin !== review.merchantOrigin) {
|
|
504
|
+
return `merchant origin changed after review: ${review.merchantOrigin} -> ${merchantOrigin ?? 'invalid'}`;
|
|
505
|
+
}
|
|
503
506
|
if (merchantHost !== review.merchantHost) {
|
|
504
507
|
return `merchant changed after review: ${review.merchantHost} -> ${merchantHost}`;
|
|
505
508
|
}
|
|
@@ -511,6 +514,40 @@ function reviewChangeReason(review, merchantHost, facts) {
|
|
|
511
514
|
}
|
|
512
515
|
return null;
|
|
513
516
|
}
|
|
517
|
+
function exactOrigin(value) {
|
|
518
|
+
try {
|
|
519
|
+
const url = new URL(value);
|
|
520
|
+
if (url.protocol !== 'https:' || url.username || url.password)
|
|
521
|
+
return null;
|
|
522
|
+
return url.origin.toLowerCase();
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
export function trustedMerchantOriginRefusal(options, pageUrl, expectedOrigin) {
|
|
529
|
+
const identity = options.trustedMerchantIdentity;
|
|
530
|
+
if (!identity)
|
|
531
|
+
return null;
|
|
532
|
+
if (Date.parse(identity.expiresAt) <= Date.now())
|
|
533
|
+
return 'trusted UCP checkout handoff expired';
|
|
534
|
+
const origin = exactOrigin(pageUrl);
|
|
535
|
+
if (!origin)
|
|
536
|
+
return 'trusted UCP checkout reached a non-HTTPS or credentialed origin';
|
|
537
|
+
if (expectedOrigin) {
|
|
538
|
+
return origin === expectedOrigin
|
|
539
|
+
? null
|
|
540
|
+
: `merchant origin changed after review: ${expectedOrigin} -> ${origin}`;
|
|
541
|
+
}
|
|
542
|
+
return identity.allowedOrigins.includes(origin)
|
|
543
|
+
? null
|
|
544
|
+
: `trusted UCP checkout reached undeclared origin ${origin}`;
|
|
545
|
+
}
|
|
546
|
+
function mandateForPage(options, pageUrl) {
|
|
547
|
+
if (!options.trustedMerchantIdentity)
|
|
548
|
+
return options.mandate;
|
|
549
|
+
return { ...options.mandate, merchantHost: new URL(pageUrl).hostname };
|
|
550
|
+
}
|
|
514
551
|
function submitTargetChangeReason(review, current) {
|
|
515
552
|
const reviewed = review.submitTargetFingerprint;
|
|
516
553
|
if (!reviewed && !current)
|
|
@@ -727,6 +764,14 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
727
764
|
const options = {
|
|
728
765
|
...opts,
|
|
729
766
|
mandate: { ...opts.mandate },
|
|
767
|
+
...(opts.trustedMerchantIdentity
|
|
768
|
+
? {
|
|
769
|
+
trustedMerchantIdentity: Object.freeze({
|
|
770
|
+
...opts.trustedMerchantIdentity,
|
|
771
|
+
allowedOrigins: Object.freeze([...opts.trustedMerchantIdentity.allowedOrigins]),
|
|
772
|
+
}),
|
|
773
|
+
}
|
|
774
|
+
: {}),
|
|
730
775
|
};
|
|
731
776
|
const evidence = new EvidenceLog();
|
|
732
777
|
// Pin an English locale: amount reconciliation reads the order summary by
|
|
@@ -757,12 +802,30 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
757
802
|
let fields = {};
|
|
758
803
|
let keepOpen = false;
|
|
759
804
|
try {
|
|
760
|
-
evidence.step('navigation', {
|
|
805
|
+
evidence.step('navigation', {
|
|
806
|
+
url: options.trustedMerchantIdentity ? exactOrigin(options.url) : options.url,
|
|
807
|
+
});
|
|
761
808
|
await page.goto(options.url, { waitUntil: 'domcontentloaded' });
|
|
762
809
|
await waitForStableDom(page);
|
|
763
|
-
evidence.step('dom-stable', {
|
|
764
|
-
|
|
765
|
-
|
|
810
|
+
evidence.step('dom-stable', {
|
|
811
|
+
url: options.trustedMerchantIdentity ? exactOrigin(page.url()) : page.url(),
|
|
812
|
+
});
|
|
813
|
+
let merchantHost = new URL(page.url()).hostname;
|
|
814
|
+
const initialOriginRefusal = trustedMerchantOriginRefusal(options, page.url());
|
|
815
|
+
if (initialOriginRefusal) {
|
|
816
|
+
evidence.step('mandate-verdict', {
|
|
817
|
+
phase: 'trusted-origin',
|
|
818
|
+
ok: false,
|
|
819
|
+
reason: initialOriginRefusal,
|
|
820
|
+
});
|
|
821
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
822
|
+
return {
|
|
823
|
+
status: 'finished',
|
|
824
|
+
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, initialOriginRefusal),
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
let reviewedOrigin;
|
|
828
|
+
const preFill = checkMandatePreFill(mandateForPage(options, page.url()), {
|
|
766
829
|
merchantHost,
|
|
767
830
|
currency: options.currency ?? null,
|
|
768
831
|
});
|
|
@@ -809,37 +872,76 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
809
872
|
await page.goto(englishUrl, { waitUntil: 'domcontentloaded' }).catch(() => { });
|
|
810
873
|
await waitForStableDom(page);
|
|
811
874
|
if (await isShopifyCheckoutPage(page)) {
|
|
812
|
-
evidence.step('navigation', {
|
|
875
|
+
evidence.step('navigation', {
|
|
876
|
+
url: options.trustedMerchantIdentity ? exactOrigin(page.url()) : page.url(),
|
|
877
|
+
reason: 'shopify-locale-normalized',
|
|
878
|
+
});
|
|
813
879
|
detected = await detectFields(page);
|
|
814
880
|
}
|
|
815
881
|
}
|
|
816
882
|
}
|
|
817
883
|
let adapter = selectAdapter(detected, { shopify: shopifyPage });
|
|
818
884
|
if (options.contact && adapter.prepareContact) {
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
885
|
+
const recordPrefill = async (contact) => {
|
|
886
|
+
const result = await adapter.prepareContact(page, contact);
|
|
887
|
+
for (const field of result.filled) {
|
|
888
|
+
evidence.step('contact-prefill', {
|
|
889
|
+
role: field.role,
|
|
890
|
+
confidence: field.confidence,
|
|
891
|
+
source: field.source,
|
|
892
|
+
frame: field.frame,
|
|
893
|
+
value: field.value,
|
|
894
|
+
ok: field.ok,
|
|
895
|
+
error: field.error,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
return result;
|
|
899
|
+
};
|
|
900
|
+
let preparedContact = await recordPrefill(options.contact);
|
|
901
|
+
if (shopifyPage && options.checkoutRoute === 'guest-card') {
|
|
902
|
+
const guest = await ensureShopifyGuestCheckout(page);
|
|
903
|
+
evidence.step('note', {
|
|
836
904
|
phase: 'contact-prefill',
|
|
905
|
+
checkoutRoute: options.checkoutRoute,
|
|
906
|
+
shopifyGuestStatus: guest.status,
|
|
907
|
+
signal: guest.signal,
|
|
837
908
|
});
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
909
|
+
if (guest.status === 'action-required') {
|
|
910
|
+
evidence.step('outcome', {
|
|
911
|
+
outcome: 'action-required',
|
|
912
|
+
signal: guest.signal,
|
|
913
|
+
phase: 'contact-prefill',
|
|
914
|
+
});
|
|
915
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
916
|
+
return {
|
|
917
|
+
status: 'finished',
|
|
918
|
+
result: makeResult('action-required', fields, evidence, requiresAdapter, guest.detail, undefined, 'human-action-required'),
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
if (guest.status === 'transitioned') {
|
|
922
|
+
// Do not write the recognized email a second time: Shopify can open
|
|
923
|
+
// the same modal on every email input event. Only retry contact fill
|
|
924
|
+
// when the takeover interrupted it, and omit email on that retry.
|
|
925
|
+
if (!preparedContact.ok) {
|
|
926
|
+
await settle(page);
|
|
927
|
+
detected = await detectFields(page);
|
|
928
|
+
adapter = selectAdapter(detected, { shopify: true });
|
|
929
|
+
preparedContact = await recordPrefill({ ...options.contact, email: undefined });
|
|
930
|
+
}
|
|
931
|
+
const verifiedGuest = await assertShopifyGuestCheckout(page);
|
|
932
|
+
if (verifiedGuest.status === 'action-required') {
|
|
933
|
+
evidence.step('outcome', {
|
|
934
|
+
outcome: 'action-required',
|
|
935
|
+
signal: verifiedGuest.signal,
|
|
936
|
+
phase: 'contact-prefill-guest-verification',
|
|
937
|
+
});
|
|
938
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
939
|
+
return {
|
|
940
|
+
status: 'finished',
|
|
941
|
+
result: makeResult('action-required', fields, evidence, requiresAdapter, verifiedGuest.detail, undefined, 'human-action-required'),
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
}
|
|
843
945
|
}
|
|
844
946
|
if (!preparedContact.ok) {
|
|
845
947
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
@@ -851,8 +953,10 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
851
953
|
}
|
|
852
954
|
await settle(page);
|
|
853
955
|
const prefillHost = new URL(page.url()).hostname;
|
|
854
|
-
|
|
855
|
-
|
|
956
|
+
const trustedPrefillRefusal = trustedMerchantOriginRefusal(options, page.url());
|
|
957
|
+
if (trustedPrefillRefusal || prefillHost !== merchantHost) {
|
|
958
|
+
const reason = trustedPrefillRefusal ??
|
|
959
|
+
`merchant changed during contact prefill: ${merchantHost} -> ${prefillHost}`;
|
|
856
960
|
evidence.step('mandate-verdict', {
|
|
857
961
|
phase: 'contact-prefill',
|
|
858
962
|
ok: false,
|
|
@@ -901,6 +1005,26 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
901
1005
|
result: makeResult('failed', fields, evidence, requiresAdapter, reason, undefined, 'card-number-field-unavailable'),
|
|
902
1006
|
};
|
|
903
1007
|
}
|
|
1008
|
+
// Contact/shipping and Shopify locale normalization may legitimately move
|
|
1009
|
+
// between the exact service and business origins authorized by UCP. Freeze
|
|
1010
|
+
// whichever declared origin is actually on-screen only after those
|
|
1011
|
+
// credential-free steps, then require that exact origin for approval,
|
|
1012
|
+
// credential fill, and submit.
|
|
1013
|
+
const reviewOriginRefusal = trustedMerchantOriginRefusal(options, page.url());
|
|
1014
|
+
if (reviewOriginRefusal) {
|
|
1015
|
+
evidence.step('mandate-verdict', {
|
|
1016
|
+
phase: 'review-origin',
|
|
1017
|
+
ok: false,
|
|
1018
|
+
reason: reviewOriginRefusal,
|
|
1019
|
+
});
|
|
1020
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1021
|
+
return {
|
|
1022
|
+
status: 'finished',
|
|
1023
|
+
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, reviewOriginRefusal),
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
merchantHost = new URL(page.url()).hostname;
|
|
1027
|
+
reviewedOrigin = options.trustedMerchantIdentity ? exactOrigin(page.url()) : undefined;
|
|
904
1028
|
const facts = await readTransactionFacts(page, options, 'review');
|
|
905
1029
|
recordTransactionFacts(evidence, 'review', facts);
|
|
906
1030
|
if (!facts.ok) {
|
|
@@ -911,7 +1035,7 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
911
1035
|
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, facts.detail),
|
|
912
1036
|
};
|
|
913
1037
|
}
|
|
914
|
-
const verdict = checkMandate(options.
|
|
1038
|
+
const verdict = checkMandate(mandateForPage(options, page.url()), {
|
|
915
1039
|
merchantHost,
|
|
916
1040
|
amountMinor: facts.amountMinor,
|
|
917
1041
|
currency: facts.currency,
|
|
@@ -930,8 +1054,12 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
930
1054
|
const submit = await findSubmit(page, { allowDeferred: true });
|
|
931
1055
|
const review = Object.freeze({
|
|
932
1056
|
id: randomUUID(),
|
|
933
|
-
|
|
1057
|
+
// A UCP continuation may contain a bearer-like path/query. The live page
|
|
1058
|
+
// stays in the process-bound store; serializable review facts expose only
|
|
1059
|
+
// the exact reviewed origin.
|
|
1060
|
+
url: reviewedOrigin ?? page.url(),
|
|
934
1061
|
merchantHost,
|
|
1062
|
+
...(reviewedOrigin ? { merchantOrigin: reviewedOrigin } : {}),
|
|
935
1063
|
amountMinor: facts.amountMinor,
|
|
936
1064
|
currency: facts.currency,
|
|
937
1065
|
mandateMaxAmountMinor: options.mandate.maxAmountMinor,
|
|
@@ -939,6 +1067,7 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
939
1067
|
submitTarget: submit?.desc ?? null,
|
|
940
1068
|
submitTargetFingerprint: submit ? Object.freeze({ ...submit.fingerprint }) : null,
|
|
941
1069
|
detectedRoles: Object.freeze(Object.keys(fields)),
|
|
1070
|
+
checkoutRoute: options.checkoutRoute,
|
|
942
1071
|
});
|
|
943
1072
|
evidence.step('review', {
|
|
944
1073
|
reviewId: review.id,
|
|
@@ -948,12 +1077,14 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
948
1077
|
submitTarget: review.submitTarget,
|
|
949
1078
|
submitTargetFingerprint: review.submitTargetFingerprint,
|
|
950
1079
|
detectedRoles: review.detectedRoles,
|
|
1080
|
+
checkoutRoute: review.checkoutRoute,
|
|
951
1081
|
});
|
|
952
1082
|
const checkout = Object.freeze({
|
|
953
1083
|
review,
|
|
954
1084
|
fields: Object.freeze({ ...fields }),
|
|
955
1085
|
evidence,
|
|
956
1086
|
requiresAdapter: Object.freeze([...requiresAdapter]),
|
|
1087
|
+
checkoutRoute: options.checkoutRoute,
|
|
957
1088
|
});
|
|
958
1089
|
store.put(review.id, {
|
|
959
1090
|
checkout,
|
|
@@ -1005,7 +1136,17 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1005
1136
|
// the credential boundary. A changed checkout requires a fresh review.
|
|
1006
1137
|
await waitForStableDom(page);
|
|
1007
1138
|
const merchantHost = new URL(page.url()).hostname;
|
|
1008
|
-
const
|
|
1139
|
+
const approvalOriginRefusal = trustedMerchantOriginRefusal(options, page.url(), checkout.review.merchantOrigin);
|
|
1140
|
+
if (approvalOriginRefusal) {
|
|
1141
|
+
evidence.step('mandate-verdict', {
|
|
1142
|
+
phase: 'approval-origin',
|
|
1143
|
+
ok: false,
|
|
1144
|
+
reason: approvalOriginRefusal,
|
|
1145
|
+
});
|
|
1146
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1147
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvalOriginRefusal);
|
|
1148
|
+
}
|
|
1149
|
+
const preFill = checkMandatePreFill(mandateForPage(options, page.url()), {
|
|
1009
1150
|
merchantHost,
|
|
1010
1151
|
currency: options.currency ?? null,
|
|
1011
1152
|
});
|
|
@@ -1027,7 +1168,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1027
1168
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1028
1169
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvedFacts.detail);
|
|
1029
1170
|
}
|
|
1030
|
-
const approvalVerdict = checkMandate(options.
|
|
1171
|
+
const approvalVerdict = checkMandate(mandateForPage(options, page.url()), {
|
|
1031
1172
|
merchantHost,
|
|
1032
1173
|
amountMinor: approvedFacts.amountMinor,
|
|
1033
1174
|
currency: approvedFacts.currency,
|
|
@@ -1038,7 +1179,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1038
1179
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1039
1180
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvalVerdict.reason);
|
|
1040
1181
|
}
|
|
1041
|
-
const changedAtApproval = reviewChangeReason(checkout.review, merchantHost, approvedFacts);
|
|
1182
|
+
const changedAtApproval = reviewChangeReason(checkout.review, merchantHost, approvedFacts, exactOrigin(page.url()) ?? undefined);
|
|
1042
1183
|
if (changedAtApproval) {
|
|
1043
1184
|
evidence.step('approval', {
|
|
1044
1185
|
approved: false,
|
|
@@ -1083,6 +1224,24 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1083
1224
|
? `validated the reviewed checkout; would click ${approvalSubmit.desc}`
|
|
1084
1225
|
: 'validated the reviewed checkout; no submit control detected');
|
|
1085
1226
|
}
|
|
1227
|
+
if (options.checkoutRoute === 'guest-card' && (await isShopifyCheckoutPage(page))) {
|
|
1228
|
+
const guest = await assertShopifyGuestCheckout(page);
|
|
1229
|
+
evidence.step('note', {
|
|
1230
|
+
phase: 'pre-credential',
|
|
1231
|
+
checkoutRoute: options.checkoutRoute,
|
|
1232
|
+
shopifyGuestStatus: guest.status,
|
|
1233
|
+
signal: guest.signal,
|
|
1234
|
+
});
|
|
1235
|
+
if (guest.status === 'action-required') {
|
|
1236
|
+
evidence.step('outcome', {
|
|
1237
|
+
outcome: 'action-required',
|
|
1238
|
+
signal: guest.signal,
|
|
1239
|
+
phase: 'pre-credential',
|
|
1240
|
+
});
|
|
1241
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1242
|
+
return makeResult('action-required', state.fields, evidence, requiresAdapter, guest.detail, undefined, 'human-action-required');
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1086
1245
|
const credential = await opts.instrument.getCredential({
|
|
1087
1246
|
merchantHost,
|
|
1088
1247
|
amountMinor: approvedFacts.amountMinor,
|
|
@@ -1104,8 +1263,11 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1104
1263
|
// A reveal/continue action can navigate between attempts. Never expose
|
|
1105
1264
|
// the credential to a host other than the one the human reviewed.
|
|
1106
1265
|
const fillHost = new URL(page.url()).hostname;
|
|
1107
|
-
|
|
1108
|
-
|
|
1266
|
+
const fillOriginRefusal = trustedMerchantOriginRefusal(options, page.url(), checkout.review.merchantOrigin);
|
|
1267
|
+
if (fillOriginRefusal ||
|
|
1268
|
+
(!checkout.review.merchantOrigin && fillHost !== checkout.review.merchantHost)) {
|
|
1269
|
+
const reason = fillOriginRefusal ??
|
|
1270
|
+
`merchant changed after review: ${checkout.review.merchantHost} -> ${fillHost}`;
|
|
1109
1271
|
evidence.step('mandate-verdict', {
|
|
1110
1272
|
phase: 'approved-submit',
|
|
1111
1273
|
ok: false,
|
|
@@ -1213,7 +1375,17 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1213
1375
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitFacts.detail);
|
|
1214
1376
|
}
|
|
1215
1377
|
const submitMerchantHost = new URL(page.url()).hostname;
|
|
1216
|
-
const
|
|
1378
|
+
const submitOriginRefusal = trustedMerchantOriginRefusal(options, page.url(), checkout.review.merchantOrigin);
|
|
1379
|
+
if (submitOriginRefusal) {
|
|
1380
|
+
evidence.step('mandate-verdict', {
|
|
1381
|
+
phase: 'pre-submit-origin',
|
|
1382
|
+
ok: false,
|
|
1383
|
+
reason: submitOriginRefusal,
|
|
1384
|
+
});
|
|
1385
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1386
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitOriginRefusal);
|
|
1387
|
+
}
|
|
1388
|
+
const verdict = checkMandate(mandateForPage(options, page.url()), {
|
|
1217
1389
|
merchantHost: submitMerchantHost,
|
|
1218
1390
|
amountMinor: submitFacts.amountMinor,
|
|
1219
1391
|
currency: submitFacts.currency,
|
|
@@ -1223,7 +1395,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1223
1395
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1224
1396
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, verdict.reason);
|
|
1225
1397
|
}
|
|
1226
|
-
const changedBeforeSubmit = reviewChangeReason(checkout.review, submitMerchantHost, submitFacts);
|
|
1398
|
+
const changedBeforeSubmit = reviewChangeReason(checkout.review, submitMerchantHost, submitFacts, exactOrigin(page.url()) ?? undefined);
|
|
1227
1399
|
if (changedBeforeSubmit) {
|
|
1228
1400
|
evidence.step('mandate-verdict', {
|
|
1229
1401
|
phase: 'pre-submit',
|
|
@@ -61,8 +61,11 @@ function defaultOpenUrl(url) {
|
|
|
61
61
|
// `CHECKOUT_SKIP_BROWSER_OPEN=1` or `VISA_SUPPRESS_BROWSER=true|1` disables it
|
|
62
62
|
// (headless/agent hosts where launching a browser on the WRONG machine is pointless or noisy).
|
|
63
63
|
if (process.env.CHECKOUT_SKIP_BROWSER_OPEN === '1' ||
|
|
64
|
+
process.env.CHECKOUT_SKIP_BROWSER_OPEN === 'true' ||
|
|
64
65
|
process.env.VISA_SUPPRESS_BROWSER === '1' ||
|
|
65
|
-
process.env.VISA_SUPPRESS_BROWSER === 'true'
|
|
66
|
+
process.env.VISA_SUPPRESS_BROWSER === 'true' ||
|
|
67
|
+
process.env.VISA_CLI_NO_BROWSER === '1' ||
|
|
68
|
+
process.env.VISA_CLI_NO_BROWSER === 'true')
|
|
66
69
|
return;
|
|
67
70
|
// Platform-appropriate opener; unknown platforms just skip (the URL is logged).
|
|
68
71
|
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start ""' : 'xdg-open';
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
export { createCliCheckoutEngine, type CliReviewInput, type CliReviewFacts, type CliPayInput, type CliReceiptFacts, type CliStartMandateInput, type CliMandateFacts, type CliEngineDeps, CheckoutReviewRefusedError, } from './cli-engine.js';
|
|
1
|
+
export { createCliCheckoutEngine, type CliReviewInput, type CliReviewFacts, type CliPayInput, type CliReceiptFacts, type CliStartMandateInput, type CliMandateFacts, type CliEngineDeps, type ReceiptWriteObservation, CheckoutReviewRefusedError, } from './cli-engine.js';
|
|
2
2
|
export { prepareCheckout, submitApprovedCheckout, runCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
|
|
3
|
-
export type { CheckoutResult, CheckoutReview, CheckoutOutcome, CheckoutFailureCode, } from './executor.js';
|
|
3
|
+
export type { CheckoutResult, CheckoutReview, CheckoutOutcome, CheckoutFailureCode, CheckoutRoute, } from './executor.js';
|
|
4
4
|
export { readConfirmedMerchants, type ConfirmedMerchant, type ConfirmedCharge, } from './confirmed-merchants.js';
|
|
5
5
|
export { KNOWN_MERCHANT_IDENTITIES, type MerchantIdentity } from './known-merchants.js';
|
|
6
6
|
export { RECEIPT_DIR } from './receipt-dir.js';
|
|
7
7
|
export { HostedApprovalDeclinedError, sanitizeApprovalIntent, APPROVAL_INTENT_MAX_CHARS, } from './hosted-approval.js';
|
|
8
8
|
export { createCardMandate, drawFromMandate, MandateDrawDeclinedError, DEFAULT_MANDATE_MAX_DRAWS, type CreateCardMandateInput, type CreateCardMandateDeps, type CardMandateFacts, type DrawFromMandateInput, type DrawFromMandateDeps, type DrawResult, type CardMandateMerchant, } from './mandate/card-mandate.js';
|
|
9
9
|
export { MandateLedger, remainingMinor, defaultLedgerPath, CARD_MANDATE_LEDGER_VERSION, type CardMandateRecord, type CardMandateLedgerFile, type CardMandateDraw, type CardMandateReservation, } from './mandate/mandate-ledger.js';
|
|
10
|
+
export { WEB_BOT_AUTH_MAX_TTL_SECONDS, WEB_BOT_AUTH_DEFAULT_TTL_SECONDS, WEB_BOT_AUTH_SIGNATURE_LABEL, WEB_BOT_AUTH_TAG, WEB_BOT_AUTH_COVERED_COMPONENTS, buildWebBotAuthHeaders, buildWebBotAuthHeadersAsync, webBotAuthHeadersOrNone, webBotAuthHeadersOrNoneAsync, resolveWebBotAuthConfig, type WebBotAuthPrivateJwk, type WebBotAuthSigningKey, type WebBotAuthConfig, type WebBotAuthHeaders, type BuildHeadersParams, } from './web-bot-auth.js';
|
|
@@ -9,3 +9,4 @@ export { RECEIPT_DIR } from './receipt-dir.js';
|
|
|
9
9
|
export { HostedApprovalDeclinedError, sanitizeApprovalIntent, APPROVAL_INTENT_MAX_CHARS, } from './hosted-approval.js';
|
|
10
10
|
export { createCardMandate, drawFromMandate, MandateDrawDeclinedError, DEFAULT_MANDATE_MAX_DRAWS, } from './mandate/card-mandate.js';
|
|
11
11
|
export { MandateLedger, remainingMinor, defaultLedgerPath, CARD_MANDATE_LEDGER_VERSION, } from './mandate/mandate-ledger.js';
|
|
12
|
+
export { WEB_BOT_AUTH_MAX_TTL_SECONDS, WEB_BOT_AUTH_DEFAULT_TTL_SECONDS, WEB_BOT_AUTH_SIGNATURE_LABEL, WEB_BOT_AUTH_TAG, WEB_BOT_AUTH_COVERED_COMPONENTS, buildWebBotAuthHeaders, buildWebBotAuthHeadersAsync, webBotAuthHeadersOrNone, webBotAuthHeadersOrNoneAsync, resolveWebBotAuthConfig, } from './web-bot-auth.js';
|
|
@@ -36,6 +36,10 @@ export type CheckoutReceiptV1 = {
|
|
|
36
36
|
export type CheckoutReceiptV2 = {
|
|
37
37
|
schema: 'checkout-agent-receipt/v2';
|
|
38
38
|
recordedAt: string;
|
|
39
|
+
/** Correlates terminal pre-review observations that have no reviewId yet. */
|
|
40
|
+
observationId?: string;
|
|
41
|
+
/** Explicitly distinguishes pre-submit review observations from payment attempts. */
|
|
42
|
+
phase?: 'review';
|
|
39
43
|
merchant: {
|
|
40
44
|
name: string;
|
|
41
45
|
host: string;
|
|
@@ -57,6 +61,15 @@ export type CheckoutReceiptV2 = {
|
|
|
57
61
|
network: {
|
|
58
62
|
confirmation: 'APPROVED' | 'DECLINED' | null;
|
|
59
63
|
};
|
|
64
|
+
/**
|
|
65
|
+
* Capability evidence for unsuccessful checkout surfaces. Optional so
|
|
66
|
+
* previously published v2 receipt producers remain source-compatible.
|
|
67
|
+
*/
|
|
68
|
+
capability?: {
|
|
69
|
+
failureCode: string | null;
|
|
70
|
+
detectedRoles: string[];
|
|
71
|
+
requiresAdapter: string[];
|
|
72
|
+
};
|
|
60
73
|
/** Local reviewed-attempt identifier. Keeps filenames stable across schema versions. */
|
|
61
74
|
receiptId: string | null;
|
|
62
75
|
/** Merchant confirmation reference only; never substituted with an internal review id. */
|
|
@@ -80,6 +93,7 @@ export type ReceiptWriteReport = {
|
|
|
80
93
|
export declare function buildReceipt(input: {
|
|
81
94
|
mode: CheckoutMode;
|
|
82
95
|
reviewId: string | null;
|
|
96
|
+
observationId?: string;
|
|
83
97
|
merchant: {
|
|
84
98
|
name: string;
|
|
85
99
|
host: string;
|
|
@@ -6,7 +6,7 @@ import { submitClickedWithoutConfirmation } from './live-fill-approval.js';
|
|
|
6
6
|
* What the operator still owes after this run. Empty for a clean dry-run fill
|
|
7
7
|
* and for a definitive submit outcome whose VIC confirmation posted.
|
|
8
8
|
*/
|
|
9
|
-
function reconciliationFor(result, vicConfirmation) {
|
|
9
|
+
function reconciliationFor(result, vicConfirmation, mode) {
|
|
10
10
|
const reasons = [];
|
|
11
11
|
if (submitClickedWithoutConfirmation(result)) {
|
|
12
12
|
reasons.push('the pay control was clicked but no definitive outcome was observed — ' +
|
|
@@ -22,7 +22,7 @@ function reconciliationFor(result, vicConfirmation) {
|
|
|
22
22
|
reasons.push('confirmed without a merchant confirmation reference — ' +
|
|
23
23
|
'record the order number from the merchant dashboard');
|
|
24
24
|
}
|
|
25
|
-
if (result.outcome === 'action-required') {
|
|
25
|
+
if (mode === 'submit' && result.outcome === 'action-required') {
|
|
26
26
|
reasons.push('an issuer verification challenge was shown and not completed — ' +
|
|
27
27
|
'finish the purchase manually if still wanted, or confirm with the merchant that no order exists');
|
|
28
28
|
}
|
|
@@ -30,7 +30,7 @@ function reconciliationFor(result, vicConfirmation) {
|
|
|
30
30
|
}
|
|
31
31
|
export function buildReceipt(input) {
|
|
32
32
|
const { result } = input;
|
|
33
|
-
const reconciliation = reconciliationFor(result, input.vicConfirmation);
|
|
33
|
+
const reconciliation = reconciliationFor(result, input.vicConfirmation, input.mode);
|
|
34
34
|
const fallbackAction = input.mode === 'dry-run'
|
|
35
35
|
? 'Dry run only — nothing was submitted; safe to run again.'
|
|
36
36
|
: result.outcome === 'confirmed'
|
|
@@ -55,15 +55,25 @@ export function buildReceipt(input) {
|
|
|
55
55
|
const cardLast4 = input.cardLast4 && /^\d{4}$/.test(input.cardLast4) ? input.cardLast4 : null;
|
|
56
56
|
const checkoutUrl = input.merchant.url && KNOWN_MERCHANT_IDENTITIES[input.merchant.url] ? input.merchant.url : null;
|
|
57
57
|
const networkConfirmation = input.vicConfirmation?.posted === true ? input.vicConfirmation.transactionStatus : null;
|
|
58
|
+
const capability = result.failureCode || result.requiresAdapter.length > 0
|
|
59
|
+
? {
|
|
60
|
+
failureCode: result.failureCode ?? null,
|
|
61
|
+
detectedRoles: Object.keys(result.fields).sort(),
|
|
62
|
+
requiresAdapter: [...result.requiresAdapter].sort(),
|
|
63
|
+
}
|
|
64
|
+
: null;
|
|
58
65
|
return {
|
|
59
66
|
schema: 'checkout-agent-receipt/v2',
|
|
60
67
|
recordedAt: (input.recordedAt ?? new Date()).toISOString(),
|
|
68
|
+
...(input.observationId ? { observationId: input.observationId } : {}),
|
|
69
|
+
...(input.observationId && input.reviewId === null ? { phase: 'review' } : {}),
|
|
61
70
|
merchant: { name: input.merchant.name, host: input.merchant.host, checkoutUrl },
|
|
62
71
|
transaction: input.transaction,
|
|
63
72
|
outcome: result.outcome,
|
|
64
73
|
agent: input.agentName ? { name: input.agentName } : null,
|
|
65
74
|
rail: { type: 'card', cardLast4 },
|
|
66
75
|
network: { confirmation: networkConfirmation },
|
|
76
|
+
...(capability ? { capability } : {}),
|
|
67
77
|
receiptId: input.reviewId,
|
|
68
78
|
reference: result.confirmationRef ?? null,
|
|
69
79
|
recovery: {
|
|
@@ -61,6 +61,15 @@ function parseUnresolved(json, receiptFile) {
|
|
|
61
61
|
receipt.schema !== 'checkout-agent-receipt/v2') {
|
|
62
62
|
return null;
|
|
63
63
|
}
|
|
64
|
+
// Terminal review observations are useful capability evidence, but no pay
|
|
65
|
+
// control, credential, or submission exists yet. They must never poison the
|
|
66
|
+
// double-charge ledger even when the observed page showed a Shop Pay or 3DS
|
|
67
|
+
// action-required surface.
|
|
68
|
+
if (receipt.schema === 'checkout-agent-receipt/v2' &&
|
|
69
|
+
(receipt.phase === 'review' ||
|
|
70
|
+
(typeof receipt.observationId === 'string' && receipt.receiptId === null))) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
64
73
|
if (typeof receipt.outcome !== 'string' || !MAY_HAVE_CHARGED_OUTCOMES.has(receipt.outcome)) {
|
|
65
74
|
return null;
|
|
66
75
|
}
|
|
@@ -20,7 +20,8 @@ export interface WebBotAuthPrivateJwk {
|
|
|
20
20
|
export interface WebBotAuthSigningKey {
|
|
21
21
|
/** RFC 7638 thumbprint. Must match a `kid` published in the directory. */
|
|
22
22
|
keyId: string;
|
|
23
|
-
privateJwk
|
|
23
|
+
privateJwk?: WebBotAuthPrivateJwk;
|
|
24
|
+
signFn?: (signingInput: Uint8Array) => Promise<Uint8Array>;
|
|
24
25
|
}
|
|
25
26
|
export interface WebBotAuthConfig {
|
|
26
27
|
/** Absolute https URL of the operator-hosted signature directory. */
|
|
@@ -81,6 +82,11 @@ export declare function buildWebBotAuthHeaders(params: BuildHeadersParams): WebB
|
|
|
81
82
|
* module existed — rather than throwing into a live checkout.
|
|
82
83
|
*/
|
|
83
84
|
export declare function webBotAuthHeadersOrNone(config: WebBotAuthConfig | null, targetUrl: string, nowSeconds: number): WebBotAuthHeaders | null;
|
|
85
|
+
/**
|
|
86
|
+
* Async version of buildWebBotAuthHeaders, supporting Turnkey or vault signFn signers.
|
|
87
|
+
*/
|
|
88
|
+
export declare function buildWebBotAuthHeadersAsync(params: BuildHeadersParams): Promise<WebBotAuthHeaders>;
|
|
89
|
+
export declare function webBotAuthHeadersOrNoneAsync(config: WebBotAuthConfig | null, targetUrl: string, nowSeconds: number): Promise<WebBotAuthHeaders | null>;
|
|
84
90
|
/**
|
|
85
91
|
* Resolve signing config from the environment. Returns `null` — meaning "send
|
|
86
92
|
* unsigned" — unless a directory URL and a usable key are BOTH present.
|
|
@@ -88,6 +88,15 @@ export function buildWebBotAuthHeaders(params) {
|
|
|
88
88
|
};
|
|
89
89
|
const base = buildSignatureBase(baseParams);
|
|
90
90
|
const signatureParams = buildSignatureParams(baseParams);
|
|
91
|
+
// privateJwk is optional on the key so a vault-held signer can supply signFn
|
|
92
|
+
// instead. Only the async builder can await such a signer, so refuse here with
|
|
93
|
+
// the same explicit message rather than handing undefined to createPrivateKey
|
|
94
|
+
// and surfacing an opaque node:crypto error.
|
|
95
|
+
if (!config.key.privateJwk) {
|
|
96
|
+
throw new Error(config.key.signFn
|
|
97
|
+
? 'WebBotAuthSigningKey with signFn requires buildWebBotAuthHeadersAsync'
|
|
98
|
+
: 'WebBotAuthSigningKey requires either privateJwk or signFn');
|
|
99
|
+
}
|
|
91
100
|
const privateKey = createPrivateKey({
|
|
92
101
|
key: config.key.privateJwk,
|
|
93
102
|
format: 'jwk',
|
|
@@ -121,6 +130,56 @@ export function webBotAuthHeadersOrNone(config, targetUrl, nowSeconds) {
|
|
|
121
130
|
return null;
|
|
122
131
|
}
|
|
123
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Async version of buildWebBotAuthHeaders, supporting Turnkey or vault signFn signers.
|
|
135
|
+
*/
|
|
136
|
+
export async function buildWebBotAuthHeadersAsync(params) {
|
|
137
|
+
const { authority, config, nowSeconds } = params;
|
|
138
|
+
const ttl = Math.min(Math.max(1, Math.floor(config.ttlSeconds ?? WEB_BOT_AUTH_DEFAULT_TTL_SECONDS)), WEB_BOT_AUTH_MAX_TTL_SECONDS);
|
|
139
|
+
const created = Math.floor(nowSeconds);
|
|
140
|
+
const expires = created + ttl;
|
|
141
|
+
const baseParams = {
|
|
142
|
+
authority,
|
|
143
|
+
directoryUrl: config.directoryUrl,
|
|
144
|
+
keyId: config.key.keyId,
|
|
145
|
+
created,
|
|
146
|
+
expires,
|
|
147
|
+
};
|
|
148
|
+
const base = buildSignatureBase(baseParams);
|
|
149
|
+
const signatureParams = buildSignatureParams(baseParams);
|
|
150
|
+
let signature;
|
|
151
|
+
if (config.key.signFn) {
|
|
152
|
+
signature = await config.key.signFn(Buffer.from(base, 'utf8'));
|
|
153
|
+
}
|
|
154
|
+
else if (config.key.privateJwk) {
|
|
155
|
+
const privateKey = createPrivateKey({
|
|
156
|
+
key: config.key.privateJwk,
|
|
157
|
+
format: 'jwk',
|
|
158
|
+
});
|
|
159
|
+
signature = cryptoSign(null, Buffer.from(base, 'utf8'), privateKey);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
throw new Error('WebBotAuthSigningKey requires either privateJwk or signFn');
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
'Signature-Input': `${WEB_BOT_AUTH_SIGNATURE_LABEL}=${signatureParams}`,
|
|
166
|
+
Signature: `${WEB_BOT_AUTH_SIGNATURE_LABEL}=:${Buffer.from(signature).toString('base64')}:`,
|
|
167
|
+
'Signature-Agent': sfString(config.directoryUrl),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
export async function webBotAuthHeadersOrNoneAsync(config, targetUrl, nowSeconds) {
|
|
171
|
+
if (!config)
|
|
172
|
+
return null;
|
|
173
|
+
try {
|
|
174
|
+
const authority = new URL(targetUrl).host;
|
|
175
|
+
if (!authority)
|
|
176
|
+
return null;
|
|
177
|
+
return await buildWebBotAuthHeadersAsync({ authority, config, nowSeconds });
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
124
183
|
/**
|
|
125
184
|
* Resolve signing config from the environment. Returns `null` — meaning "send
|
|
126
185
|
* unsigned" — unless a directory URL and a usable key are BOTH present.
|
|
@@ -151,7 +210,7 @@ export function resolveWebBotAuthConfig(env, loadKey) {
|
|
|
151
210
|
catch {
|
|
152
211
|
return null;
|
|
153
212
|
}
|
|
154
|
-
if (!key?.keyId || key.privateJwk?.crv !== 'Ed25519' || !key.privateJwk.d)
|
|
213
|
+
if (!key?.keyId || (!key.signFn && (key.privateJwk?.crv !== 'Ed25519' || !key.privateJwk.d)))
|
|
155
214
|
return null;
|
|
156
215
|
const rawTtl = Number(env.VISA_WEB_BOT_AUTH_TTL_SECONDS);
|
|
157
216
|
const ttlSeconds = Number.isFinite(rawTtl) && rawTtl > 0 ? rawTtl : undefined;
|