@visa/cli 4.1.0-rc.152 → 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;
|
|
@@ -84,6 +84,10 @@ export type CliReceiptFacts = {
|
|
|
84
84
|
/** True only when at least one payment field was successfully filled. */
|
|
85
85
|
credentialDisclosed: boolean;
|
|
86
86
|
};
|
|
87
|
+
type PayAttempt = {
|
|
88
|
+
fingerprint: string;
|
|
89
|
+
promise: Promise<CliReceiptFacts>;
|
|
90
|
+
};
|
|
87
91
|
export type CliStartMandateInput = {
|
|
88
92
|
/** Optional local card-capability selector (legacy name or exact request-key JKT). */
|
|
89
93
|
agentRef?: string;
|
|
@@ -222,6 +226,8 @@ export type CliEngineDeps = {
|
|
|
222
226
|
writeReceipt?: typeof realWriteReceipt;
|
|
223
227
|
store?: PreparedCheckoutSessionStore;
|
|
224
228
|
sessions?: Map<string, Session>;
|
|
229
|
+
/** Exact-review singleflight registry; tests inject a fresh map for isolation. */
|
|
230
|
+
payAttempts?: Map<string, PayAttempt>;
|
|
225
231
|
ttlMs?: number;
|
|
226
232
|
/** Owner-only card-mandate ledger — defaults to the ~/.visa-mcp singleton. */
|
|
227
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,12 +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) {
|
|
513
544
|
const noCredentialFacts = {
|
|
514
545
|
credentialIssued: false,
|
|
515
546
|
credentialDisclosed: false,
|
|
516
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
|
+
}
|
|
517
555
|
const session = sessions.get(input.reviewId);
|
|
518
556
|
// One signal for the whole pay lifecycle. `cardTokenId` is populated only
|
|
519
557
|
// by the resolved v4 card-grant authority; legacy credential-file calls
|
|
@@ -629,6 +667,23 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
629
667
|
...noCredentialFacts,
|
|
630
668
|
};
|
|
631
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
|
+
};
|
|
632
687
|
clearTimeout(session.cleanupTimer);
|
|
633
688
|
try {
|
|
634
689
|
// NO instrument read here. A tap-free mandate draw spends `covering
|
|
@@ -672,7 +727,7 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
672
727
|
const verdictSeam = cardDrawVerdict;
|
|
673
728
|
const capability = verdictSeam?.loadCapability(covering.agentJkt) ?? null;
|
|
674
729
|
if (!capability || !verdictSeam) {
|
|
675
|
-
return {
|
|
730
|
+
return finishAttempt({
|
|
676
731
|
outcome: 'failed',
|
|
677
732
|
confirmationRef: null,
|
|
678
733
|
receiptPath: null,
|
|
@@ -682,10 +737,10 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
682
737
|
source,
|
|
683
738
|
remainingMinor: null,
|
|
684
739
|
...noCredentialFacts,
|
|
685
|
-
};
|
|
740
|
+
});
|
|
686
741
|
}
|
|
687
742
|
if (covering.agentJkt && capability.agentJkt !== covering.agentJkt) {
|
|
688
|
-
return {
|
|
743
|
+
return finishAttempt({
|
|
689
744
|
outcome: 'failed',
|
|
690
745
|
confirmationRef: null,
|
|
691
746
|
receiptPath: null,
|
|
@@ -695,7 +750,7 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
695
750
|
source,
|
|
696
751
|
remainingMinor: null,
|
|
697
752
|
...noCredentialFacts,
|
|
698
|
-
};
|
|
753
|
+
});
|
|
699
754
|
}
|
|
700
755
|
// VgsLiveInstrument mints against an EXISTING intent (the mandate) with
|
|
701
756
|
// no fresh assurance — exactly the draw semantics. The fetchCredential
|
|
@@ -803,7 +858,7 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
803
858
|
// registration and non-budget mint tokens at verification; this
|
|
804
859
|
// client refusal exists to give the honest remedy up front instead
|
|
805
860
|
// of a mid-flow server error.
|
|
806
|
-
return {
|
|
861
|
+
return finishAttempt({
|
|
807
862
|
outcome: 'failed',
|
|
808
863
|
confirmationRef: null,
|
|
809
864
|
receiptPath: null,
|
|
@@ -821,7 +876,7 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
821
876
|
source: null,
|
|
822
877
|
remainingMinor: null,
|
|
823
878
|
...noCredentialFacts,
|
|
824
|
-
};
|
|
879
|
+
});
|
|
825
880
|
}
|
|
826
881
|
const mode = input.submit ? 'submit' : 'dry-run';
|
|
827
882
|
const result = await submitApprovedCheckout(input.reviewId, {
|
|
@@ -875,7 +930,7 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
875
930
|
}));
|
|
876
931
|
if (report.written)
|
|
877
932
|
receiptPath = report.path;
|
|
878
|
-
return {
|
|
933
|
+
return finishAttempt({
|
|
879
934
|
outcome: result.outcome,
|
|
880
935
|
confirmationRef: result.confirmationRef ?? null,
|
|
881
936
|
receiptPath,
|
|
@@ -887,10 +942,29 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
887
942
|
credentialIssued: result.credentialLifecycle !== 'not-requested',
|
|
888
943
|
credentialDisclosed: result.credentialLifecycle === 'partially-exposed' ||
|
|
889
944
|
result.credentialLifecycle === 'fully-filled',
|
|
890
|
-
};
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
catch (error) {
|
|
948
|
+
rejectAttempt(error);
|
|
949
|
+
throw error;
|
|
891
950
|
}
|
|
892
951
|
finally {
|
|
893
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
|
+
}
|
|
894
968
|
}
|
|
895
969
|
},
|
|
896
970
|
};
|