@visa/cli 4.1.0-rc.262 → 4.1.0-rc.264
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/dist/checkout-engine/adapters/shopify.d.ts +20 -2
- package/dist/checkout-engine/adapters/shopify.js +67 -11
- package/dist/checkout-engine/amount.d.ts +2 -0
- package/dist/checkout-engine/cli-engine.d.ts +3 -0
- package/dist/checkout-engine/cli-engine.js +3 -0
- package/dist/checkout-engine/executor.d.ts +31 -1
- package/dist/checkout-engine/executor.js +168 -43
- package/dist/checkout-engine/index.d.ts +3 -1
- package/dist/checkout-engine/index.js +2 -1
- package/dist/checkout-engine/mandate.d.ts +8 -0
- package/dist/checkout-engine/mandate.js +44 -9
- package/dist/cli.js +396 -396
- package/dist/mcp-server/index.js +313 -313
- package/dist/merchant-ucp-mcp/index.js +5 -5
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -18,7 +18,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
18
18
|
import { mkdir } from 'node:fs/promises';
|
|
19
19
|
import { join } from 'node:path';
|
|
20
20
|
import { detectFields } from './detect.js';
|
|
21
|
-
import { checkMandate, checkMandatePreFill } from './mandate.js';
|
|
21
|
+
import { checkMandate, checkMandatePreFill, } from './mandate.js';
|
|
22
22
|
import { EvidenceLog, maskOtp } from './evidence.js';
|
|
23
23
|
import { observeOutcome } from './outcome.js';
|
|
24
24
|
import { selectAdapter } from './adapters/index.js';
|
|
@@ -27,7 +27,7 @@ import { traceHandleFields } from './trace-handles.js';
|
|
|
27
27
|
import { readGenericPageAmount } from './amount.js';
|
|
28
28
|
import { webBotAuthHeadersOrNone } from './web-bot-auth.js';
|
|
29
29
|
import { assertShopifyGuestCheckout, ensureShopifyGuestCheckout, isShopifyCheckoutPage, readShopifyAmount, readStableShopifyAmount, shopifyEnglishCheckoutUrl, } from './adapters/shopify.js';
|
|
30
|
-
import { navigationRedirectEvidence, shopifyPrimaryDomainAlias } from './shopify-primary-domain.js';
|
|
30
|
+
import { navigationRedirectEvidence, shopifyPrimaryDomainAlias, } from './shopify-primary-domain.js';
|
|
31
31
|
export { minorFromDecimal, pageCurrency } from './amount.js';
|
|
32
32
|
const SUBMIT_TEXT = /pay|place order|complete|buy|submit|checkout/i;
|
|
33
33
|
const REVEAL_TEXT = /continue|next|proceed|review|go to payment/i;
|
|
@@ -389,7 +389,7 @@ function unknownPreparedCheckoutResult(reviewId) {
|
|
|
389
389
|
evidence.step('approval', { approved: false, reviewId, reason: detail });
|
|
390
390
|
return makeResult('failed', {}, evidence, [], detail);
|
|
391
391
|
}
|
|
392
|
-
function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirmationRef, failureCode) {
|
|
392
|
+
function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirmationRef, failureCode, refusalCode) {
|
|
393
393
|
const steps = evidence.getSteps();
|
|
394
394
|
const approved = steps.find((step) => step.type === 'approval' && step.data.approved === true);
|
|
395
395
|
const minted = steps.find((step) => step.type === 'credential-minted');
|
|
@@ -428,15 +428,25 @@ function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirma
|
|
|
428
428
|
...(completed ? { fillCompletedAt: completed.ts } : {}),
|
|
429
429
|
},
|
|
430
430
|
...(terminalFailureCode ? { failureCode: terminalFailureCode } : {}),
|
|
431
|
+
...(refusalCode ? { refusalCode } : {}),
|
|
431
432
|
...(detail ? { detail } : {}),
|
|
432
433
|
...(confirmationRef ? { confirmationRef } : {}),
|
|
433
434
|
};
|
|
434
435
|
}
|
|
436
|
+
// Shopify computes shipping and tax asynchronously after the delivery address
|
|
437
|
+
// lands; a settled, reconciled summary can take well over the default 6s on a
|
|
438
|
+
// cold checkout. Review is free and safe to wait on; approval and pre-submit
|
|
439
|
+
// keep the strict short window because they only confirm an already-settled page.
|
|
440
|
+
const SHOPIFY_REVIEW_SETTLE_MS = 20_000;
|
|
435
441
|
async function readTransactionFacts(page, opts, phase) {
|
|
436
442
|
const shopify = await isShopifyCheckoutPage(page);
|
|
437
443
|
const amountRead = shopify
|
|
438
444
|
? phase === 'review'
|
|
439
|
-
? await readStableShopifyAmount(page
|
|
445
|
+
? await readStableShopifyAmount(page, SHOPIFY_REVIEW_SETTLE_MS, {
|
|
446
|
+
// A trusted UCP handoff carries the merchant-settled total; a page
|
|
447
|
+
// that omits a tax row must match it before the review trusts it.
|
|
448
|
+
expectedMinor: opts.trustedMerchantIdentity ? (opts.amountMinor ?? null) : null,
|
|
449
|
+
})
|
|
440
450
|
: await readShopifyAmount(page, true)
|
|
441
451
|
: await readGenericPageAmount(page);
|
|
442
452
|
const pageAmount = shopify && amountRead.kind === 'none'
|
|
@@ -472,6 +482,7 @@ async function readTransactionFacts(page, opts, phase) {
|
|
|
472
482
|
amountMinor,
|
|
473
483
|
currency,
|
|
474
484
|
source,
|
|
485
|
+
code: 'amount_unreadable',
|
|
475
486
|
reason: pageAmount.kind === 'unreadable'
|
|
476
487
|
? (pageAmount.reason ?? 'page total is displayed but cannot be parsed unambiguously')
|
|
477
488
|
: 'transaction amount could not be determined',
|
|
@@ -486,6 +497,7 @@ async function readTransactionFacts(page, opts, phase) {
|
|
|
486
497
|
amountMinor,
|
|
487
498
|
currency,
|
|
488
499
|
source,
|
|
500
|
+
code: 'currency_unreadable',
|
|
489
501
|
reason: 'transaction currency could not be determined',
|
|
490
502
|
detail: 'transaction currency could not be determined (page total does not state one unambiguously, no currency asserted by the caller); refusing fail-closed',
|
|
491
503
|
};
|
|
@@ -526,23 +538,96 @@ function exactOrigin(value) {
|
|
|
526
538
|
return null;
|
|
527
539
|
}
|
|
528
540
|
}
|
|
529
|
-
export function
|
|
541
|
+
export function trustedMerchantOriginVerdict(options, pageUrl, expectedOrigin) {
|
|
530
542
|
const identity = options.trustedMerchantIdentity;
|
|
531
543
|
if (!identity)
|
|
532
544
|
return null;
|
|
533
|
-
if (Date.parse(identity.expiresAt) <= Date.now())
|
|
534
|
-
return 'trusted UCP checkout handoff expired';
|
|
545
|
+
if (Date.parse(identity.expiresAt) <= Date.now()) {
|
|
546
|
+
return { code: 'trusted_handoff_expired', reason: 'trusted UCP checkout handoff expired' };
|
|
547
|
+
}
|
|
535
548
|
const origin = exactOrigin(pageUrl);
|
|
536
|
-
if (!origin)
|
|
537
|
-
return
|
|
549
|
+
if (!origin) {
|
|
550
|
+
return {
|
|
551
|
+
code: 'trusted_origin_insecure',
|
|
552
|
+
reason: 'trusted UCP checkout reached a non-HTTPS or credentialed origin',
|
|
553
|
+
};
|
|
554
|
+
}
|
|
538
555
|
if (expectedOrigin) {
|
|
539
556
|
return origin === expectedOrigin
|
|
540
557
|
? null
|
|
541
|
-
:
|
|
558
|
+
: {
|
|
559
|
+
code: 'trusted_origin_changed',
|
|
560
|
+
reason: `merchant origin changed after review: ${expectedOrigin} -> ${origin}`,
|
|
561
|
+
};
|
|
542
562
|
}
|
|
543
563
|
return identity.allowedOrigins.includes(origin)
|
|
544
564
|
? null
|
|
545
|
-
:
|
|
565
|
+
: {
|
|
566
|
+
code: 'trusted_origin_undeclared',
|
|
567
|
+
reason: `trusted UCP checkout reached undeclared origin ${origin}`,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
export function trustedMerchantOriginRefusal(options, pageUrl, expectedOrigin) {
|
|
571
|
+
return trustedMerchantOriginVerdict(options, pageUrl, expectedOrigin)?.reason ?? null;
|
|
572
|
+
}
|
|
573
|
+
function wwwNormalizedHost(host) {
|
|
574
|
+
return host.toLowerCase().replace(/^www\./, '');
|
|
575
|
+
}
|
|
576
|
+
const MYSHOPIFY_SERVICE_HOST = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.myshopify\.com$/;
|
|
577
|
+
/**
|
|
578
|
+
* Bind the storefront a trusted UCP continuation actually lands on (#8669).
|
|
579
|
+
*
|
|
580
|
+
* The merchant published its UCP business profile at its own business origin
|
|
581
|
+
* and declared the permanent `*.myshopify.com` service that issued the
|
|
582
|
+
* continuation; the CLI verified both before minting the handoff. That is
|
|
583
|
+
* independently verified merchant provenance, so the review may bind the final
|
|
584
|
+
* page origin when, and only when: the navigation started on that declared
|
|
585
|
+
* myshopify origin, the final origin is plain HTTPS, and its host is the
|
|
586
|
+
* declared business host modulo a leading `www.` label. The redirect chain in
|
|
587
|
+
* between (Shopify's primary-domain hop, its shop.app bounce, #8496) carries no
|
|
588
|
+
* authority either way: a redirect cannot land on the merchant's own business
|
|
589
|
+
* domain unless the merchant controls it, and any other final host stays an
|
|
590
|
+
* undeclared origin. Shopify's `primary_domain_redirection` proof is recorded
|
|
591
|
+
* as evidence when present but is not required.
|
|
592
|
+
*/
|
|
593
|
+
export function trustedShopifyAliasOrigin(args) {
|
|
594
|
+
let initial;
|
|
595
|
+
let final;
|
|
596
|
+
try {
|
|
597
|
+
initial = new URL(args.initialUrl);
|
|
598
|
+
final = new URL(args.finalUrl);
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
if (initial.protocol !== 'https:' || final.protocol !== 'https:')
|
|
604
|
+
return null;
|
|
605
|
+
if (final.username || final.password || final.port)
|
|
606
|
+
return null;
|
|
607
|
+
const initialHost = initial.hostname.toLowerCase();
|
|
608
|
+
const finalHost = final.hostname.toLowerCase();
|
|
609
|
+
if (!MYSHOPIFY_SERVICE_HOST.test(initialHost) || initialHost === finalHost)
|
|
610
|
+
return null;
|
|
611
|
+
// A declared origin is held to the same standard as the final one: plain
|
|
612
|
+
// HTTPS, default port, no credentials. Anything else never contributes a
|
|
613
|
+
// host, so the identity stays an origin set rather than widening to a host.
|
|
614
|
+
const declared = args.allowedOrigins.flatMap((candidate) => {
|
|
615
|
+
try {
|
|
616
|
+
const url = new URL(candidate);
|
|
617
|
+
return url.protocol === 'https:' && !url.username && !url.password && !url.port
|
|
618
|
+
? [url.hostname.toLowerCase()]
|
|
619
|
+
: [];
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
return [];
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
if (!declared.includes(initialHost))
|
|
626
|
+
return null;
|
|
627
|
+
const declaredBusiness = declared.some((host) => host !== initialHost &&
|
|
628
|
+
!MYSHOPIFY_SERVICE_HOST.test(host) &&
|
|
629
|
+
wwwNormalizedHost(host) === wwwNormalizedHost(finalHost));
|
|
630
|
+
return declaredBusiness ? final.origin.toLowerCase() : null;
|
|
546
631
|
}
|
|
547
632
|
function mandateForPage(options, pageUrl) {
|
|
548
633
|
if (!options.trustedMerchantIdentity)
|
|
@@ -812,17 +897,52 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
812
897
|
url: options.trustedMerchantIdentity ? exactOrigin(page.url()) : page.url(),
|
|
813
898
|
});
|
|
814
899
|
let merchantHost = new URL(page.url()).hostname;
|
|
815
|
-
|
|
816
|
-
if (
|
|
900
|
+
let initialOriginVerdict = trustedMerchantOriginVerdict(options, page.url());
|
|
901
|
+
if (initialOriginVerdict?.code === 'trusted_origin_undeclared' &&
|
|
902
|
+
options.trustedMerchantIdentity) {
|
|
903
|
+
// #8669: the trusted path never got the Shopify primary-domain proof the
|
|
904
|
+
// generic path has carried since #8469, so every declared-business
|
|
905
|
+
// storefront that Shopify serves from its primary domain refused here.
|
|
906
|
+
const aliasOrigin = trustedShopifyAliasOrigin({
|
|
907
|
+
allowedOrigins: options.trustedMerchantIdentity.allowedOrigins,
|
|
908
|
+
initialUrl: options.url,
|
|
909
|
+
finalUrl: page.url(),
|
|
910
|
+
redirects: await navigationRedirectEvidence(navigationResponse),
|
|
911
|
+
});
|
|
912
|
+
if (aliasOrigin) {
|
|
913
|
+
evidence.step('note', {
|
|
914
|
+
kind: 'shopify-primary-domain-alias',
|
|
915
|
+
trusted: true,
|
|
916
|
+
continuationHost: new URL(options.url).hostname,
|
|
917
|
+
checkoutHost: new URL(aliasOrigin).hostname,
|
|
918
|
+
shopifyProof: shopifyPrimaryDomainAlias({
|
|
919
|
+
mandateHost: new URL(options.url).hostname,
|
|
920
|
+
initialUrl: options.url,
|
|
921
|
+
finalUrl: page.url(),
|
|
922
|
+
redirects: await navigationRedirectEvidence(navigationResponse),
|
|
923
|
+
}) !== null,
|
|
924
|
+
});
|
|
925
|
+
options.trustedMerchantIdentity = Object.freeze({
|
|
926
|
+
...options.trustedMerchantIdentity,
|
|
927
|
+
allowedOrigins: Object.freeze([
|
|
928
|
+
...options.trustedMerchantIdentity.allowedOrigins,
|
|
929
|
+
aliasOrigin,
|
|
930
|
+
]),
|
|
931
|
+
});
|
|
932
|
+
initialOriginVerdict = trustedMerchantOriginVerdict(options, page.url());
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
if (initialOriginVerdict) {
|
|
817
936
|
evidence.step('mandate-verdict', {
|
|
818
937
|
phase: 'trusted-origin',
|
|
819
938
|
ok: false,
|
|
820
|
-
reason:
|
|
939
|
+
reason: initialOriginVerdict.reason,
|
|
940
|
+
code: initialOriginVerdict.code,
|
|
821
941
|
});
|
|
822
942
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
823
943
|
return {
|
|
824
944
|
status: 'finished',
|
|
825
|
-
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter,
|
|
945
|
+
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, initialOriginVerdict.reason, undefined, undefined, initialOriginVerdict.code),
|
|
826
946
|
};
|
|
827
947
|
}
|
|
828
948
|
if (!options.trustedMerchantIdentity) {
|
|
@@ -854,7 +974,7 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
854
974
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
855
975
|
return {
|
|
856
976
|
status: 'finished',
|
|
857
|
-
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, preFill.reason),
|
|
977
|
+
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, preFill.reason, undefined, undefined, preFill.code),
|
|
858
978
|
};
|
|
859
979
|
}
|
|
860
980
|
// Payer-chosen amount (Stripe payment links): an empty customUnitAmount
|
|
@@ -973,19 +1093,21 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
973
1093
|
}
|
|
974
1094
|
await settle(page);
|
|
975
1095
|
const prefillHost = new URL(page.url()).hostname;
|
|
976
|
-
const
|
|
977
|
-
if (
|
|
978
|
-
const reason =
|
|
1096
|
+
const trustedPrefillVerdict = trustedMerchantOriginVerdict(options, page.url());
|
|
1097
|
+
if (trustedPrefillVerdict || prefillHost !== merchantHost) {
|
|
1098
|
+
const reason = trustedPrefillVerdict?.reason ??
|
|
979
1099
|
`merchant changed during contact prefill: ${merchantHost} -> ${prefillHost}`;
|
|
1100
|
+
const code = trustedPrefillVerdict?.code ?? 'merchant_host_mismatch';
|
|
980
1101
|
evidence.step('mandate-verdict', {
|
|
981
1102
|
phase: 'contact-prefill',
|
|
982
1103
|
ok: false,
|
|
983
1104
|
reason,
|
|
1105
|
+
code,
|
|
984
1106
|
});
|
|
985
1107
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
986
1108
|
return {
|
|
987
1109
|
status: 'finished',
|
|
988
|
-
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, reason),
|
|
1110
|
+
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, reason, undefined, undefined, code),
|
|
989
1111
|
};
|
|
990
1112
|
}
|
|
991
1113
|
detected = await detectFields(page);
|
|
@@ -1030,17 +1152,18 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
1030
1152
|
// whichever declared origin is actually on-screen only after those
|
|
1031
1153
|
// credential-free steps, then require that exact origin for approval,
|
|
1032
1154
|
// credential fill, and submit.
|
|
1033
|
-
const
|
|
1034
|
-
if (
|
|
1155
|
+
const reviewOriginVerdict = trustedMerchantOriginVerdict(options, page.url());
|
|
1156
|
+
if (reviewOriginVerdict) {
|
|
1035
1157
|
evidence.step('mandate-verdict', {
|
|
1036
1158
|
phase: 'review-origin',
|
|
1037
1159
|
ok: false,
|
|
1038
|
-
reason:
|
|
1160
|
+
reason: reviewOriginVerdict.reason,
|
|
1161
|
+
code: reviewOriginVerdict.code,
|
|
1039
1162
|
});
|
|
1040
1163
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1041
1164
|
return {
|
|
1042
1165
|
status: 'finished',
|
|
1043
|
-
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter,
|
|
1166
|
+
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, reviewOriginVerdict.reason, undefined, undefined, reviewOriginVerdict.code),
|
|
1044
1167
|
};
|
|
1045
1168
|
}
|
|
1046
1169
|
merchantHost = new URL(page.url()).hostname;
|
|
@@ -1052,7 +1175,7 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
1052
1175
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1053
1176
|
return {
|
|
1054
1177
|
status: 'finished',
|
|
1055
|
-
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, facts.detail),
|
|
1178
|
+
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, facts.detail, undefined, undefined, facts.code),
|
|
1056
1179
|
};
|
|
1057
1180
|
}
|
|
1058
1181
|
const verdict = checkMandate(mandateForPage(options, page.url()), {
|
|
@@ -1065,7 +1188,7 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
1065
1188
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1066
1189
|
return {
|
|
1067
1190
|
status: 'finished',
|
|
1068
|
-
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, verdict.reason),
|
|
1191
|
+
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, verdict.reason, undefined, undefined, verdict.code),
|
|
1069
1192
|
};
|
|
1070
1193
|
}
|
|
1071
1194
|
// Multi-step pages may expose the final submit control in a hidden section
|
|
@@ -1156,15 +1279,16 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1156
1279
|
// the credential boundary. A changed checkout requires a fresh review.
|
|
1157
1280
|
await waitForStableDom(page);
|
|
1158
1281
|
const merchantHost = new URL(page.url()).hostname;
|
|
1159
|
-
const
|
|
1160
|
-
if (
|
|
1282
|
+
const approvalOriginVerdict = trustedMerchantOriginVerdict(options, page.url(), checkout.review.merchantOrigin);
|
|
1283
|
+
if (approvalOriginVerdict) {
|
|
1161
1284
|
evidence.step('mandate-verdict', {
|
|
1162
1285
|
phase: 'approval-origin',
|
|
1163
1286
|
ok: false,
|
|
1164
|
-
reason:
|
|
1287
|
+
reason: approvalOriginVerdict.reason,
|
|
1288
|
+
code: approvalOriginVerdict.code,
|
|
1165
1289
|
});
|
|
1166
1290
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1167
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter,
|
|
1291
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvalOriginVerdict.reason, undefined, undefined, approvalOriginVerdict.code);
|
|
1168
1292
|
}
|
|
1169
1293
|
const preFill = checkMandatePreFill(mandateForPage(options, page.url()), {
|
|
1170
1294
|
merchantHost,
|
|
@@ -1174,7 +1298,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1174
1298
|
if (!preFill.ok) {
|
|
1175
1299
|
evidence.step('approval', { approved: false, reviewId: checkout.review.id });
|
|
1176
1300
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1177
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, preFill.reason);
|
|
1301
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, preFill.reason, undefined, undefined, preFill.code);
|
|
1178
1302
|
}
|
|
1179
1303
|
const approvedFacts = await readTransactionFacts(page, options, 'approval');
|
|
1180
1304
|
recordTransactionFacts(evidence, 'approval', approvedFacts);
|
|
@@ -1186,7 +1310,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1186
1310
|
});
|
|
1187
1311
|
evidence.step('approval', { approved: false, reviewId: checkout.review.id });
|
|
1188
1312
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1189
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvedFacts.detail);
|
|
1313
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvedFacts.detail, undefined, undefined, approvedFacts.code);
|
|
1190
1314
|
}
|
|
1191
1315
|
const approvalVerdict = checkMandate(mandateForPage(options, page.url()), {
|
|
1192
1316
|
merchantHost,
|
|
@@ -1197,7 +1321,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1197
1321
|
if (!approvalVerdict.ok) {
|
|
1198
1322
|
evidence.step('approval', { approved: false, reviewId: checkout.review.id });
|
|
1199
1323
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1200
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvalVerdict.reason);
|
|
1324
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvalVerdict.reason, undefined, undefined, approvalVerdict.code);
|
|
1201
1325
|
}
|
|
1202
1326
|
const changedAtApproval = reviewChangeReason(checkout.review, merchantHost, approvedFacts, exactOrigin(page.url()) ?? undefined);
|
|
1203
1327
|
if (changedAtApproval) {
|
|
@@ -1207,7 +1331,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1207
1331
|
reason: changedAtApproval,
|
|
1208
1332
|
});
|
|
1209
1333
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1210
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, changedAtApproval);
|
|
1334
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, changedAtApproval, undefined, undefined, 'review_facts_changed');
|
|
1211
1335
|
}
|
|
1212
1336
|
// Revalidate the same future target before credential minting. This may be
|
|
1213
1337
|
// hidden on a multi-step checkout; the final lookup after reveal requires
|
|
@@ -1221,7 +1345,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1221
1345
|
reason: submitChangedAtApproval,
|
|
1222
1346
|
});
|
|
1223
1347
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1224
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitChangedAtApproval);
|
|
1348
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitChangedAtApproval, undefined, undefined, 'review_facts_changed');
|
|
1225
1349
|
}
|
|
1226
1350
|
if (opts.mode === 'submit' && !approvalSubmit) {
|
|
1227
1351
|
const reason = 'no submit target was available for human review';
|
|
@@ -1392,18 +1516,19 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1392
1516
|
reason: submitFacts.reason,
|
|
1393
1517
|
});
|
|
1394
1518
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1395
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitFacts.detail);
|
|
1519
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitFacts.detail, undefined, undefined, submitFacts.code);
|
|
1396
1520
|
}
|
|
1397
1521
|
const submitMerchantHost = new URL(page.url()).hostname;
|
|
1398
|
-
const
|
|
1399
|
-
if (
|
|
1522
|
+
const submitOriginVerdict = trustedMerchantOriginVerdict(options, page.url(), checkout.review.merchantOrigin);
|
|
1523
|
+
if (submitOriginVerdict) {
|
|
1400
1524
|
evidence.step('mandate-verdict', {
|
|
1401
1525
|
phase: 'pre-submit-origin',
|
|
1402
1526
|
ok: false,
|
|
1403
|
-
reason:
|
|
1527
|
+
reason: submitOriginVerdict.reason,
|
|
1528
|
+
code: submitOriginVerdict.code,
|
|
1404
1529
|
});
|
|
1405
1530
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1406
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter,
|
|
1531
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitOriginVerdict.reason, undefined, undefined, submitOriginVerdict.code);
|
|
1407
1532
|
}
|
|
1408
1533
|
const verdict = checkMandate(mandateForPage(options, page.url()), {
|
|
1409
1534
|
merchantHost: submitMerchantHost,
|
|
@@ -1413,7 +1538,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1413
1538
|
evidence.step('mandate-verdict', { phase: 'pre-submit', ...verdict });
|
|
1414
1539
|
if (!verdict.ok) {
|
|
1415
1540
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1416
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, verdict.reason);
|
|
1541
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, verdict.reason, undefined, undefined, verdict.code);
|
|
1417
1542
|
}
|
|
1418
1543
|
const changedBeforeSubmit = reviewChangeReason(checkout.review, submitMerchantHost, submitFacts, exactOrigin(page.url()) ?? undefined);
|
|
1419
1544
|
if (changedBeforeSubmit) {
|
|
@@ -1423,7 +1548,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1423
1548
|
reason: changedBeforeSubmit,
|
|
1424
1549
|
});
|
|
1425
1550
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1426
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, changedBeforeSubmit);
|
|
1551
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, changedBeforeSubmit, undefined, undefined, 'review_facts_changed');
|
|
1427
1552
|
}
|
|
1428
1553
|
const submit = await findSubmit(page);
|
|
1429
1554
|
const submitChangedBeforeClick = submitTargetChangeReason(checkout.review, submit);
|
|
@@ -1434,7 +1559,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1434
1559
|
reason: submitChangedBeforeClick,
|
|
1435
1560
|
});
|
|
1436
1561
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1437
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitChangedBeforeClick);
|
|
1562
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitChangedBeforeClick, undefined, undefined, 'review_facts_changed');
|
|
1438
1563
|
}
|
|
1439
1564
|
if (!adapterFillOk) {
|
|
1440
1565
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { createCliCheckoutEngine, type CliReviewInput, type CliReviewFacts, type CliPayInput, type CliReceiptFacts, type CliStartMandateInput, type CliMandateFacts, type CliEngineDeps, type ReceiptWriteObservation, CheckoutReviewRefusedError, CardMandateActivationError, classifyServerIntentFailure, type CardMandateActivationFacts, type CliResumeMandateInput, } from './cli-engine.js';
|
|
2
|
-
export { prepareCheckout, submitApprovedCheckout, runCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
|
|
2
|
+
export { prepareCheckout, submitApprovedCheckout, runCheckout, InMemoryPreparedCheckoutStore, trustedShopifyAliasOrigin, } from './executor.js';
|
|
3
|
+
export { MANDATE_REFUSAL_CODES } from './mandate.js';
|
|
4
|
+
export type { MandateRefusalCode, MandateVerdict } from './mandate.js';
|
|
3
5
|
export type { CheckoutResult, CheckoutReview, CheckoutOutcome, CheckoutFailureCode, CheckoutRoute, } from './executor.js';
|
|
4
6
|
export { readConfirmedMerchants, type ConfirmedMerchant, type ConfirmedCharge, } from './confirmed-merchants.js';
|
|
5
7
|
export { KNOWN_MERCHANT_IDENTITIES, type MerchantIdentity } from './known-merchants.js';
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// consumes createCliCheckoutEngine() through a structural seam; the core engine
|
|
3
3
|
// primitives are re-exported for direct/embedded use.
|
|
4
4
|
export { createCliCheckoutEngine, CheckoutReviewRefusedError, CardMandateActivationError, classifyServerIntentFailure, } from './cli-engine.js';
|
|
5
|
-
export { prepareCheckout, submitApprovedCheckout, runCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
|
|
5
|
+
export { prepareCheckout, submitApprovedCheckout, runCheckout, InMemoryPreparedCheckoutStore, trustedShopifyAliasOrigin, } from './executor.js';
|
|
6
|
+
export { MANDATE_REFUSAL_CODES } from './mandate.js';
|
|
6
7
|
export { readConfirmedMerchants, } from './confirmed-merchants.js';
|
|
7
8
|
export { KNOWN_MERCHANT_IDENTITIES } from './known-merchants.js';
|
|
8
9
|
export { RECEIPT_DIR } from './receipt-dir.js';
|
|
@@ -15,11 +15,19 @@ export type MandatePreFillContext = {
|
|
|
15
15
|
currency?: string | null;
|
|
16
16
|
now?: Date;
|
|
17
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* Bounded, machine-readable reason a review or pay was refused by the mandate
|
|
20
|
+
* or trusted-identity gate. Callers branch on this instead of parsing the
|
|
21
|
+
* human-readable reason sentence.
|
|
22
|
+
*/
|
|
23
|
+
export type MandateRefusalCode = 'mandate_missing' | 'mandate_malformed' | 'mandate_expired' | 'currency_mismatch' | 'merchant_host_mismatch' | 'currency_unreadable' | 'amount_invalid' | 'amount_over_cap' | 'amount_unreadable' | 'trusted_handoff_expired' | 'trusted_origin_insecure' | 'trusted_origin_changed' | 'trusted_origin_undeclared' | 'review_facts_changed';
|
|
24
|
+
export declare const MANDATE_REFUSAL_CODES: ReadonlySet<MandateRefusalCode>;
|
|
18
25
|
export type MandateVerdict = {
|
|
19
26
|
ok: true;
|
|
20
27
|
} | {
|
|
21
28
|
ok: false;
|
|
22
29
|
reason: string;
|
|
30
|
+
code: MandateRefusalCode;
|
|
23
31
|
};
|
|
24
32
|
export declare function checkMandatePreFill(mandate: Mandate | null | undefined, ctx: MandatePreFillContext): MandateVerdict;
|
|
25
33
|
export declare function checkMandate(mandate: Mandate | null | undefined, ctx: MandateContext): MandateVerdict;
|
|
@@ -17,36 +17,61 @@
|
|
|
17
17
|
// All amounts are integer minor units (e.g. cents for USD). There is no
|
|
18
18
|
// floating-point money here on purpose; the executor reads a minor-unit total
|
|
19
19
|
// and compares integers.
|
|
20
|
+
export const MANDATE_REFUSAL_CODES = new Set([
|
|
21
|
+
'mandate_missing',
|
|
22
|
+
'mandate_malformed',
|
|
23
|
+
'mandate_expired',
|
|
24
|
+
'currency_mismatch',
|
|
25
|
+
'merchant_host_mismatch',
|
|
26
|
+
'currency_unreadable',
|
|
27
|
+
'amount_invalid',
|
|
28
|
+
'amount_over_cap',
|
|
29
|
+
'amount_unreadable',
|
|
30
|
+
'trusted_handoff_expired',
|
|
31
|
+
'trusted_origin_insecure',
|
|
32
|
+
'trusted_origin_changed',
|
|
33
|
+
'trusted_origin_undeclared',
|
|
34
|
+
'review_facts_changed',
|
|
35
|
+
]);
|
|
20
36
|
function isInteger(n) {
|
|
21
37
|
return typeof n === 'number' && Number.isInteger(n);
|
|
22
38
|
}
|
|
23
39
|
// Pre-fill gate: everything knowable before a credential is minted.
|
|
24
40
|
export function checkMandatePreFill(mandate, ctx) {
|
|
25
41
|
if (!mandate) {
|
|
26
|
-
return { ok: false, reason: 'no mandate provided' };
|
|
42
|
+
return { ok: false, reason: 'no mandate provided', code: 'mandate_missing' };
|
|
27
43
|
}
|
|
28
44
|
// Structural validation: a malformed mandate must never authorize a spend.
|
|
29
45
|
if (!isInteger(mandate.maxAmountMinor) || mandate.maxAmountMinor <= 0) {
|
|
30
|
-
return {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
reason: 'mandate maxAmountMinor must be a positive integer (minor units)',
|
|
49
|
+
code: 'mandate_malformed',
|
|
50
|
+
};
|
|
31
51
|
}
|
|
32
52
|
if (!mandate.currency) {
|
|
33
|
-
return { ok: false, reason: 'mandate is missing a currency' };
|
|
53
|
+
return { ok: false, reason: 'mandate is missing a currency', code: 'mandate_malformed' };
|
|
34
54
|
}
|
|
35
55
|
if (!mandate.expiresAt) {
|
|
36
|
-
return { ok: false, reason: 'mandate is missing an expiry' };
|
|
56
|
+
return { ok: false, reason: 'mandate is missing an expiry', code: 'mandate_malformed' };
|
|
37
57
|
}
|
|
38
58
|
const expiry = new Date(mandate.expiresAt);
|
|
39
59
|
if (Number.isNaN(expiry.getTime())) {
|
|
40
|
-
return {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
reason: `mandate expiry is not a valid date: ${mandate.expiresAt}`,
|
|
63
|
+
code: 'mandate_malformed',
|
|
64
|
+
};
|
|
41
65
|
}
|
|
42
66
|
const now = ctx.now ?? new Date();
|
|
43
67
|
if (expiry.getTime() <= now.getTime()) {
|
|
44
|
-
return { ok: false, reason: `mandate expired at ${mandate.expiresAt}
|
|
68
|
+
return { ok: false, reason: `mandate expired at ${mandate.expiresAt}`, code: 'mandate_expired' };
|
|
45
69
|
}
|
|
46
70
|
if (ctx.currency && mandate.currency.toUpperCase() !== ctx.currency.toUpperCase()) {
|
|
47
71
|
return {
|
|
48
72
|
ok: false,
|
|
49
73
|
reason: `currency mismatch: mandate ${mandate.currency} vs transaction ${ctx.currency}`,
|
|
74
|
+
code: 'currency_mismatch',
|
|
50
75
|
};
|
|
51
76
|
}
|
|
52
77
|
if (mandate.merchantHost &&
|
|
@@ -54,6 +79,7 @@ export function checkMandatePreFill(mandate, ctx) {
|
|
|
54
79
|
return {
|
|
55
80
|
ok: false,
|
|
56
81
|
reason: `merchant host mismatch: mandate ${mandate.merchantHost} vs checkout ${ctx.merchantHost}`,
|
|
82
|
+
code: 'merchant_host_mismatch',
|
|
57
83
|
};
|
|
58
84
|
}
|
|
59
85
|
return { ok: true };
|
|
@@ -65,20 +91,29 @@ export function checkMandate(mandate, ctx) {
|
|
|
65
91
|
if (!pre.ok)
|
|
66
92
|
return pre;
|
|
67
93
|
if (!mandate)
|
|
68
|
-
return { ok: false, reason: 'no mandate provided' };
|
|
94
|
+
return { ok: false, reason: 'no mandate provided', code: 'mandate_missing' };
|
|
69
95
|
// The pre-fill phase skips the currency check when none is asserted yet;
|
|
70
96
|
// here the resolved currency is mandatory.
|
|
71
97
|
if (!ctx.currency) {
|
|
72
|
-
return {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
reason: 'transaction currency could not be determined',
|
|
101
|
+
code: 'currency_unreadable',
|
|
102
|
+
};
|
|
73
103
|
}
|
|
74
104
|
// Transaction amount must be a clean integer minor-unit value.
|
|
75
105
|
if (!isInteger(ctx.amountMinor) || ctx.amountMinor < 0) {
|
|
76
|
-
return {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
reason: 'transaction amount is not a non-negative integer (minor units)',
|
|
109
|
+
code: 'amount_invalid',
|
|
110
|
+
};
|
|
77
111
|
}
|
|
78
112
|
if (ctx.amountMinor > mandate.maxAmountMinor) {
|
|
79
113
|
return {
|
|
80
114
|
ok: false,
|
|
81
115
|
reason: `amount ${ctx.amountMinor} exceeds mandate cap ${mandate.maxAmountMinor} (minor units)`,
|
|
116
|
+
code: 'amount_over_cap',
|
|
82
117
|
};
|
|
83
118
|
}
|
|
84
119
|
return { ok: true };
|