@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:
|
|
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
|
};
|