@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.
@@ -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 trustedMerchantOriginRefusal(options, pageUrl, expectedOrigin) {
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 'trusted UCP checkout reached a non-HTTPS or credentialed origin';
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
- : `merchant origin changed after review: ${expectedOrigin} -> ${origin}`;
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
- : `trusted UCP checkout reached undeclared origin ${origin}`;
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
- const initialOriginRefusal = trustedMerchantOriginRefusal(options, page.url());
816
- if (initialOriginRefusal) {
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: initialOriginRefusal,
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, initialOriginRefusal),
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 trustedPrefillRefusal = trustedMerchantOriginRefusal(options, page.url());
977
- if (trustedPrefillRefusal || prefillHost !== merchantHost) {
978
- const reason = trustedPrefillRefusal ??
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 reviewOriginRefusal = trustedMerchantOriginRefusal(options, page.url());
1034
- if (reviewOriginRefusal) {
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: reviewOriginRefusal,
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, reviewOriginRefusal),
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 approvalOriginRefusal = trustedMerchantOriginRefusal(options, page.url(), checkout.review.merchantOrigin);
1160
- if (approvalOriginRefusal) {
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: approvalOriginRefusal,
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, approvalOriginRefusal);
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 submitOriginRefusal = trustedMerchantOriginRefusal(options, page.url(), checkout.review.merchantOrigin);
1399
- if (submitOriginRefusal) {
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: submitOriginRefusal,
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, submitOriginRefusal);
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 { ok: false, reason: 'mandate maxAmountMinor must be a positive integer (minor units)' };
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 { ok: false, reason: `mandate expiry is not a valid date: ${mandate.expiresAt}` };
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 { ok: false, reason: 'transaction currency could not be determined' };
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 { ok: false, reason: 'transaction amount is not a non-negative integer (minor units)' };
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 };