@visa/cli 4.1.0-rc.151 → 4.1.0-rc.153

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { type Browser } from 'playwright-core';
2
- import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, type PreparedCheckoutSessionStore } from './executor.js';
2
+ import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, type CheckoutOutcome, type PreparedCheckoutSessionStore } from './executor.js';
3
3
  import { claimMandatePickup as realClaimMandatePickup, runHostedApproval as realRunHostedApproval } from './hosted-approval.js';
4
4
  import { type VgsCheckoutTarget } from './vgs-live-instrument.js';
5
5
  import { serverFetchCryptogram, serverPostConfirmation } from './vgs-gateway/server-mint-client.js';
@@ -58,7 +58,7 @@ export type CliPayInput = CliReviewInput & {
58
58
  onApprovalUrl?: (url: string) => void;
59
59
  };
60
60
  export type CliReceiptFacts = {
61
- outcome: string;
61
+ outcome: CheckoutOutcome;
62
62
  confirmationRef: string | null;
63
63
  receiptPath: string | null;
64
64
  detail: string | null;
@@ -79,6 +79,14 @@ export type CliReceiptFacts = {
79
79
  * card confirmation target exists.
80
80
  */
81
81
  processorIntentId?: string | null;
82
+ /** True only when the checkout engine recorded a credential-minted step. */
83
+ credentialIssued: boolean;
84
+ /** True only when at least one payment field was successfully filled. */
85
+ credentialDisclosed: boolean;
86
+ };
87
+ type PayAttempt = {
88
+ fingerprint: string;
89
+ promise: Promise<CliReceiptFacts>;
82
90
  };
83
91
  export type CliStartMandateInput = {
84
92
  /** Optional local card-capability selector (legacy name or exact request-key JKT). */
@@ -218,6 +226,8 @@ export type CliEngineDeps = {
218
226
  writeReceipt?: typeof realWriteReceipt;
219
227
  store?: PreparedCheckoutSessionStore;
220
228
  sessions?: Map<string, Session>;
229
+ /** Exact-review singleflight registry; tests inject a fresh map for isolation. */
230
+ payAttempts?: Map<string, PayAttempt>;
221
231
  ttlMs?: number;
222
232
  /** Owner-only card-mandate ledger — defaults to the ~/.visa-mcp singleton. */
223
233
  ledger?: MandateLedger;
@@ -119,16 +119,46 @@ async function resolveCardInstrument(input) {
119
119
  '`visa agent grant-card <agent-id> --ceiling <usd> --per-transaction <usd> --wait` to ' +
120
120
  'attach one, or use a pre-provisioned VIC runtime.', readError instanceof Error ? { cause: readError } : undefined);
121
121
  }
122
+ function payAttemptFingerprint(input) {
123
+ return JSON.stringify([
124
+ input.url,
125
+ input.amount,
126
+ input.currency,
127
+ input.credentialPath,
128
+ input.cardTokenId ?? null,
129
+ input.agentJkt ?? null,
130
+ input.contact,
131
+ input.approvalBaseUrl,
132
+ input.merchantName ?? null,
133
+ input.merchantCountryCode ?? null,
134
+ input.submit,
135
+ ]);
136
+ }
137
+ function failedPay(detail) {
138
+ return {
139
+ outcome: 'failed',
140
+ confirmationRef: null,
141
+ receiptPath: null,
142
+ detail,
143
+ vicConfirmation: null,
144
+ source: null,
145
+ remainingMinor: null,
146
+ credentialIssued: false,
147
+ credentialDisclosed: false,
148
+ };
149
+ }
122
150
  const RECEIPT_DIR = join(homedir(), '.visa-mcp', 'checkout-receipts');
123
151
  // Must match the prepared-checkout store TTL so a session and its store entry
124
152
  // expire together — an abandoned review can't leak the browser + state.
125
153
  const PREPARED_TTL_MS = 5 * 60 * 1000;
126
154
  const defaultStore = new InMemoryPreparedCheckoutStore({ ttlMs: PREPARED_TTL_MS });
127
155
  const defaultSessions = new Map();
156
+ const defaultPayAttempts = new Map();
128
157
  const defaultLedger = new MandateLedger();
129
158
  export function createCliCheckoutEngine(deps = {}) {
130
159
  const store = deps.store ?? defaultStore;
131
160
  const sessions = deps.sessions ?? defaultSessions;
161
+ const payAttempts = deps.payAttempts ?? defaultPayAttempts;
132
162
  const ttlMs = deps.ttlMs ?? PREPARED_TTL_MS;
133
163
  const launchBrowser = deps.launchBrowser ?? (() => launchCheckoutBrowser());
134
164
  const prepareCheckout = deps.prepareCheckout ?? realPrepareCheckout;
@@ -508,8 +538,20 @@ export function createCliCheckoutEngine(deps = {}) {
508
538
  */
509
539
  async releaseReview(reviewId) {
510
540
  await closeSession(reviewId);
541
+ payAttempts.delete(reviewId);
511
542
  },
512
543
  async pay(input) {
544
+ const noCredentialFacts = {
545
+ credentialIssued: false,
546
+ credentialDisclosed: false,
547
+ };
548
+ const fingerprint = payAttemptFingerprint(input);
549
+ const priorAttempt = payAttempts.get(input.reviewId);
550
+ if (priorAttempt) {
551
+ return priorAttempt.fingerprint === fingerprint
552
+ ? priorAttempt.promise
553
+ : failedPay(`review ${input.reviewId} is already executing with different payment facts — wait for that exact attempt and inspect its receipt`);
554
+ }
513
555
  const session = sessions.get(input.reviewId);
514
556
  // One signal for the whole pay lifecycle. `cardTokenId` is populated only
515
557
  // by the resolved v4 card-grant authority; legacy credential-file calls
@@ -525,6 +567,7 @@ export function createCliCheckoutEngine(deps = {}) {
525
567
  vicConfirmation: null,
526
568
  source: null,
527
569
  remainingMinor: null,
570
+ ...noCredentialFacts,
528
571
  };
529
572
  }
530
573
  // The reviewId selects the prepared browser session, but the pay call also
@@ -548,6 +591,7 @@ export function createCliCheckoutEngine(deps = {}) {
548
591
  vicConfirmation: null,
549
592
  source: null,
550
593
  remainingMinor: null,
594
+ ...noCredentialFacts,
551
595
  };
552
596
  }
553
597
  if (payUrl !== reviewedUrl) {
@@ -560,6 +604,7 @@ export function createCliCheckoutEngine(deps = {}) {
560
604
  vicConfirmation: null,
561
605
  source: null,
562
606
  remainingMinor: null,
607
+ ...noCredentialFacts,
563
608
  };
564
609
  }
565
610
  if (input.currency.toUpperCase() !== session.currency.toUpperCase()) {
@@ -572,6 +617,7 @@ export function createCliCheckoutEngine(deps = {}) {
572
617
  vicConfirmation: null,
573
618
  source: null,
574
619
  remainingMinor: null,
620
+ ...noCredentialFacts,
575
621
  };
576
622
  }
577
623
  if (input.agentJkt !== session.agentJkt) {
@@ -584,6 +630,7 @@ export function createCliCheckoutEngine(deps = {}) {
584
630
  vicConfirmation: null,
585
631
  source: null,
586
632
  remainingMinor: null,
633
+ ...noCredentialFacts,
587
634
  };
588
635
  }
589
636
  // Amount-bind the confirmation: the pay-call amount must match the
@@ -601,6 +648,7 @@ export function createCliCheckoutEngine(deps = {}) {
601
648
  vicConfirmation: null,
602
649
  source: null,
603
650
  remainingMinor: null,
651
+ ...noCredentialFacts,
604
652
  };
605
653
  }
606
654
  // Reject an expired prepared checkout BEFORE running the hosted passkey
@@ -616,8 +664,26 @@ export function createCliCheckoutEngine(deps = {}) {
616
664
  vicConfirmation: null,
617
665
  source: null,
618
666
  remainingMinor: null,
667
+ ...noCredentialFacts,
619
668
  };
620
669
  }
670
+ let resolveAttempt;
671
+ let rejectAttempt;
672
+ const sharedResult = new Promise((resolve, reject) => {
673
+ resolveAttempt = resolve;
674
+ rejectAttempt = reject;
675
+ });
676
+ // The first caller still receives the direct execution result below; this
677
+ // retained promise exists for overlapping and bounded late duplicates.
678
+ // Mark its rejection handled even when there is no duplicate consumer.
679
+ void sharedResult.catch(() => undefined);
680
+ payAttempts.set(input.reviewId, { fingerprint, promise: sharedResult });
681
+ let completedAttempt;
682
+ const finishAttempt = (result) => {
683
+ completedAttempt = result;
684
+ resolveAttempt(result);
685
+ return result;
686
+ };
621
687
  clearTimeout(session.cleanupTimer);
622
688
  try {
623
689
  // NO instrument read here. A tap-free mandate draw spends `covering
@@ -661,7 +727,7 @@ export function createCliCheckoutEngine(deps = {}) {
661
727
  const verdictSeam = cardDrawVerdict;
662
728
  const capability = verdictSeam?.loadCapability(covering.agentJkt) ?? null;
663
729
  if (!capability || !verdictSeam) {
664
- return {
730
+ return finishAttempt({
665
731
  outcome: 'failed',
666
732
  confirmationRef: null,
667
733
  receiptPath: null,
@@ -670,10 +736,11 @@ export function createCliCheckoutEngine(deps = {}) {
670
736
  vicConfirmation: null,
671
737
  source,
672
738
  remainingMinor: null,
673
- };
739
+ ...noCredentialFacts,
740
+ });
674
741
  }
675
742
  if (covering.agentJkt && capability.agentJkt !== covering.agentJkt) {
676
- return {
743
+ return finishAttempt({
677
744
  outcome: 'failed',
678
745
  confirmationRef: null,
679
746
  receiptPath: null,
@@ -682,7 +749,8 @@ export function createCliCheckoutEngine(deps = {}) {
682
749
  vicConfirmation: null,
683
750
  source,
684
751
  remainingMinor: null,
685
- };
752
+ ...noCredentialFacts,
753
+ });
686
754
  }
687
755
  // VgsLiveInstrument mints against an EXISTING intent (the mandate) with
688
756
  // no fresh assurance — exactly the draw semantics. The fetchCredential
@@ -790,7 +858,7 @@ export function createCliCheckoutEngine(deps = {}) {
790
858
  // registration and non-budget mint tokens at verification; this
791
859
  // client refusal exists to give the honest remedy up front instead
792
860
  // of a mid-flow server error.
793
- return {
861
+ return finishAttempt({
794
862
  outcome: 'failed',
795
863
  confirmationRef: null,
796
864
  receiptPath: null,
@@ -807,7 +875,8 @@ export function createCliCheckoutEngine(deps = {}) {
807
875
  vicConfirmation: null,
808
876
  source: null,
809
877
  remainingMinor: null,
810
- };
878
+ ...noCredentialFacts,
879
+ });
811
880
  }
812
881
  const mode = input.submit ? 'submit' : 'dry-run';
813
882
  const result = await submitApprovedCheckout(input.reviewId, {
@@ -861,7 +930,7 @@ export function createCliCheckoutEngine(deps = {}) {
861
930
  }));
862
931
  if (report.written)
863
932
  receiptPath = report.path;
864
- return {
933
+ return finishAttempt({
865
934
  outcome: result.outcome,
866
935
  confirmationRef: result.confirmationRef ?? null,
867
936
  receiptPath,
@@ -870,10 +939,32 @@ export function createCliCheckoutEngine(deps = {}) {
870
939
  source,
871
940
  remainingMinor: drawnRemaining,
872
941
  processorIntentId,
873
- };
942
+ credentialIssued: result.credentialLifecycle !== 'not-requested',
943
+ credentialDisclosed: result.credentialLifecycle === 'partially-exposed' ||
944
+ result.credentialLifecycle === 'fully-filled',
945
+ });
946
+ }
947
+ catch (error) {
948
+ rejectAttempt(error);
949
+ throw error;
874
950
  }
875
951
  finally {
876
952
  await closeSession(input.reviewId);
953
+ if (!completedAttempt ||
954
+ completedAttempt.outcome === 'failed' ||
955
+ completedAttempt.outcome === 'declined') {
956
+ if (payAttempts.get(input.reviewId)?.promise === sharedResult) {
957
+ payAttempts.delete(input.reviewId);
958
+ }
959
+ }
960
+ else {
961
+ const expiry = setTimeout(() => {
962
+ if (payAttempts.get(input.reviewId)?.promise === sharedResult) {
963
+ payAttempts.delete(input.reviewId);
964
+ }
965
+ }, ttlMs);
966
+ expiry.unref?.();
967
+ }
877
968
  }
878
969
  },
879
970
  };