@visa/cli 4.1.0-rc.3 → 4.1.0-rc.30
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/README.md +132 -242
- package/dist/checkout-engine/adapters/generic.d.ts +19 -0
- package/dist/checkout-engine/adapters/generic.js +201 -0
- package/dist/checkout-engine/adapters/index.d.ts +7 -0
- package/dist/checkout-engine/adapters/index.js +17 -0
- package/dist/checkout-engine/adapters/stripe-like.d.ts +10 -0
- package/dist/checkout-engine/adapters/stripe-like.js +21 -0
- package/dist/checkout-engine/browser-launch.d.ts +46 -0
- package/dist/checkout-engine/browser-launch.js +81 -0
- package/dist/checkout-engine/ceremony.d.ts +64 -0
- package/dist/checkout-engine/ceremony.js +261 -0
- package/dist/checkout-engine/cli-engine.d.ts +208 -0
- package/dist/checkout-engine/cli-engine.js +584 -0
- package/dist/checkout-engine/detect.d.ts +61 -0
- package/dist/checkout-engine/detect.js +392 -0
- package/dist/checkout-engine/evidence.d.ts +25 -0
- package/dist/checkout-engine/evidence.js +104 -0
- package/dist/checkout-engine/executor.d.ts +174 -0
- package/dist/checkout-engine/executor.js +1306 -0
- package/dist/checkout-engine/hosted-approval.d.ts +135 -0
- package/dist/checkout-engine/hosted-approval.js +311 -0
- package/dist/checkout-engine/index.d.ts +6 -0
- package/dist/checkout-engine/index.js +8 -0
- package/dist/checkout-engine/inline-target.d.ts +13 -0
- package/dist/checkout-engine/inline-target.js +37 -0
- package/dist/checkout-engine/instrument.d.ts +55 -0
- package/dist/checkout-engine/instrument.js +87 -0
- package/dist/checkout-engine/live-fill-approval.d.ts +43 -0
- package/dist/checkout-engine/live-fill-approval.js +90 -0
- package/dist/checkout-engine/mandate/card-mandate.d.ts +117 -0
- package/dist/checkout-engine/mandate/card-mandate.js +221 -0
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +135 -0
- package/dist/checkout-engine/mandate/mandate-ledger.js +318 -0
- package/dist/checkout-engine/mandate.d.ts +25 -0
- package/dist/checkout-engine/mandate.js +100 -0
- package/dist/checkout-engine/outcome.d.ts +30 -0
- package/dist/checkout-engine/outcome.js +225 -0
- package/dist/checkout-engine/owner-only-file.d.ts +19 -0
- package/dist/checkout-engine/owner-only-file.js +41 -0
- package/dist/checkout-engine/package.json +3 -0
- package/dist/checkout-engine/pay-args.d.ts +14 -0
- package/dist/checkout-engine/pay-args.js +44 -0
- package/dist/checkout-engine/pay.d.ts +1 -0
- package/dist/checkout-engine/pay.js +13 -0
- package/dist/checkout-engine/receipt.d.ts +81 -0
- package/dist/checkout-engine/receipt.js +109 -0
- package/dist/checkout-engine/repo-env.d.ts +11 -0
- package/dist/checkout-engine/repo-env.js +23 -0
- package/dist/checkout-engine/run-live-fill.d.ts +1 -0
- package/dist/checkout-engine/run-live-fill.js +493 -0
- package/dist/checkout-engine/types.d.ts +39 -0
- package/dist/checkout-engine/types.js +2 -0
- package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +74 -0
- package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +248 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +82 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +178 -0
- package/dist/checkout-engine/vgs-live-instrument.d.ts +168 -0
- package/dist/checkout-engine/vgs-live-instrument.js +289 -0
- package/dist/checkout-engine/vic-confirmation.d.ts +34 -0
- package/dist/checkout-engine/vic-confirmation.js +39 -0
- package/dist/cli.js +327 -375
- package/dist/mcp-server/index.js +253 -163
- package/dist/skills/pair-visa-agent/RUNTIMES.md +79 -0
- package/dist/skills/pair-visa-agent/SKILL.md +402 -0
- package/dist/skills/pair-visa-agent/scripts/setup.mjs +48 -0
- package/install.ps1 +3 -41
- package/install.sh +3 -35
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +9 -5
- package/server.json +3 -3
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
// createCliCheckoutEngine — the review()/pay() adapter consumed by @visa/cli's
|
|
2
|
+
// pay_merchant tool. It COMPOSES prepareCheckout, runHostedApproval, the
|
|
3
|
+
// server-side mint (serverCreateIntent/serverFetchCryptogram — the credential is
|
|
4
|
+
// minted by the verify-web deployment, not this machine), submitApprovedCheckout,
|
|
5
|
+
// reportVicOutcome, and receipts into a two-call API.
|
|
6
|
+
//
|
|
7
|
+
// A live browser + prepared-checkout session is held in-process between review
|
|
8
|
+
// and pay, keyed by reviewId, so the submitted checkout is the exact one the
|
|
9
|
+
// human approved. Passkey approval uses the hosted /approve page only.
|
|
10
|
+
//
|
|
11
|
+
// Every browser/network primitive is injectable (CliEngineDeps) so the session/
|
|
12
|
+
// timer/store lifecycle is unit-testable without launching Chromium.
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { readFile } from 'node:fs/promises';
|
|
16
|
+
import { launchCheckoutBrowser } from './browser-launch.js';
|
|
17
|
+
import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
|
|
18
|
+
import { runHostedApproval as realRunHostedApproval } from './hosted-approval.js';
|
|
19
|
+
import { VgsAssuranceInstrument, VgsLiveInstrument, decimalToMinor, } from './vgs-live-instrument.js';
|
|
20
|
+
import { serverCreateIntent, serverFetchCryptogram, serverPostConfirmation, } from './vgs-gateway/server-mint-client.js';
|
|
21
|
+
import { createCardMandate, drawFromMandate, MandateDrawDeclinedError, } from './mandate/card-mandate.js';
|
|
22
|
+
import { MandateLedger } from './mandate/mandate-ledger.js';
|
|
23
|
+
import { buildReceipt, writeReceipt as realWriteReceipt } from './receipt.js';
|
|
24
|
+
import { reportVicOutcome as realReportVicOutcome, } from './vic-confirmation.js';
|
|
25
|
+
/**
|
|
26
|
+
* A card-mandate draw failed transiently (retryable) rather than definitively.
|
|
27
|
+
* Gateway 5xx, "server cryptogram not completed / try again", and network
|
|
28
|
+
* reset/timeout errors are transient: the mandate stays healthy and must NOT be
|
|
29
|
+
* disabled. Walks the error's cause chain so a wrapped MandateDrawDeclinedError
|
|
30
|
+
* is classified by its underlying gateway error. Exported for tests.
|
|
31
|
+
*/
|
|
32
|
+
export function isTransientDrawFailure(err) {
|
|
33
|
+
const msgs = [];
|
|
34
|
+
let e = err;
|
|
35
|
+
for (let i = 0; i < 5 && e; i++) {
|
|
36
|
+
if (e instanceof Error && typeof e.message === 'string')
|
|
37
|
+
msgs.push(e.message);
|
|
38
|
+
e = e.cause;
|
|
39
|
+
}
|
|
40
|
+
// Transient = a gateway 5xx / 429 in the mint client's "(last: <status>: …)"
|
|
41
|
+
// framing, or an unambiguous network reset/timeout error name. The status is
|
|
42
|
+
// ANCHORED to `(last:` so a bare 3-digit token elsewhere (an amount, a ref id,
|
|
43
|
+
// an attempt count) can never be mistaken for a status code, and so the
|
|
44
|
+
// advisory DRAW_REMEDY wrapper text ("gateway 5xx / try again") cannot
|
|
45
|
+
// self-classify a hard decline as transient. A hard decline (4xx / a
|
|
46
|
+
// card-decline reason) matches nothing here → the mandate is correctly disabled.
|
|
47
|
+
return /\(last:\s*(?:5\d\d|429)\b|\bETIMEDOUT\b|\bECONNRESET\b|\bECONNREFUSED\b|\bEAI_AGAIN\b|socket hang up|tim(?:e|ed)[ -]?out/i.test(msgs.join(' '));
|
|
48
|
+
}
|
|
49
|
+
const RECEIPT_DIR = join(homedir(), '.visa-mcp', 'checkout-receipts');
|
|
50
|
+
// Must match the prepared-checkout store TTL so a session and its store entry
|
|
51
|
+
// expire together — an abandoned review can't leak the browser + state.
|
|
52
|
+
const PREPARED_TTL_MS = 5 * 60 * 1000;
|
|
53
|
+
const defaultStore = new InMemoryPreparedCheckoutStore({ ttlMs: PREPARED_TTL_MS });
|
|
54
|
+
const defaultSessions = new Map();
|
|
55
|
+
const defaultLedger = new MandateLedger();
|
|
56
|
+
export function createCliCheckoutEngine(deps = {}) {
|
|
57
|
+
const store = deps.store ?? defaultStore;
|
|
58
|
+
const sessions = deps.sessions ?? defaultSessions;
|
|
59
|
+
const ttlMs = deps.ttlMs ?? PREPARED_TTL_MS;
|
|
60
|
+
const launchBrowser = deps.launchBrowser ?? (() => launchCheckoutBrowser());
|
|
61
|
+
const prepareCheckout = deps.prepareCheckout ?? realPrepareCheckout;
|
|
62
|
+
const submitApprovedCheckout = deps.submitApprovedCheckout ?? realSubmitApprovedCheckout;
|
|
63
|
+
const runHostedApproval = deps.runHostedApproval ?? realRunHostedApproval;
|
|
64
|
+
const reportVicOutcome = deps.reportVicOutcome ?? realReportVicOutcome;
|
|
65
|
+
const writeReceipt = deps.writeReceipt ?? realWriteReceipt;
|
|
66
|
+
const ledger = deps.ledger ?? defaultLedger;
|
|
67
|
+
const now = deps.now ?? (() => new Date());
|
|
68
|
+
const fetchMandateCryptogram = deps.serverFetchCryptogram ?? serverFetchCryptogram;
|
|
69
|
+
const cardDrawVerdict = deps.cardDrawVerdict ?? null;
|
|
70
|
+
const cardMandateRegister = deps.cardMandateRegister ?? null;
|
|
71
|
+
async function closeSession(reviewId) {
|
|
72
|
+
const session = sessions.get(reviewId);
|
|
73
|
+
if (!session)
|
|
74
|
+
return;
|
|
75
|
+
clearTimeout(session.cleanupTimer);
|
|
76
|
+
sessions.delete(reviewId);
|
|
77
|
+
await session.browser.close().catch(() => { });
|
|
78
|
+
}
|
|
79
|
+
function buildTarget(input) {
|
|
80
|
+
return {
|
|
81
|
+
merchantName: input.merchantName ?? new URL(input.url).hostname,
|
|
82
|
+
merchantUrl: input.url,
|
|
83
|
+
merchantCountryCode: input.merchantCountryCode ?? 'US',
|
|
84
|
+
transactionAmount: input.amount,
|
|
85
|
+
transactionCurrencyCode: input.currency,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
// BUDGET step: one passkey approves a CEILING; a VGS intent is minted with
|
|
90
|
+
// that ceiling as its decline threshold and the owner-only ledger records
|
|
91
|
+
// the cumulative budget. No browser checkout is prepared — this is purely
|
|
92
|
+
// the passkey ceremony + intent, so later pay() draws need no fresh tap.
|
|
93
|
+
async startCardMandate(input) {
|
|
94
|
+
const ceilingMinor = decimalToMinor(input.ceiling);
|
|
95
|
+
if (ceilingMinor === null || ceilingMinor <= 0) {
|
|
96
|
+
throw new Error(`invalid mandate ceiling ${JSON.stringify(input.ceiling)}`);
|
|
97
|
+
}
|
|
98
|
+
if (input.perTransaction !== undefined) {
|
|
99
|
+
const perTxMinor = decimalToMinor(input.perTransaction);
|
|
100
|
+
if (perTxMinor === null || perTxMinor <= 0 || perTxMinor > ceilingMinor) {
|
|
101
|
+
throw new Error(`invalid mandate perTransaction ${JSON.stringify(input.perTransaction)} — ` +
|
|
102
|
+
'must be a positive amount at or under the ceiling');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// BUDGET mandate: not tied to a merchant. The approval screen shows the
|
|
106
|
+
// any-merchant label so the owner sees the broader scope they're granting;
|
|
107
|
+
// the URL is a stable sentinel (advisory on the intent, ignored on draw).
|
|
108
|
+
const anyMerchant = input.anyMerchant === true;
|
|
109
|
+
if (!anyMerchant && !input.url) {
|
|
110
|
+
throw new Error('a merchant url is required unless anyMerchant (budget mandate) is set');
|
|
111
|
+
}
|
|
112
|
+
const merchant = anyMerchant
|
|
113
|
+
? {
|
|
114
|
+
name: 'spend budget mandate',
|
|
115
|
+
url: 'https://any-merchant.visa/budget',
|
|
116
|
+
countryCode: input.merchantCountryCode ?? 'US',
|
|
117
|
+
}
|
|
118
|
+
: {
|
|
119
|
+
name: input.merchantName ?? new URL(input.url).hostname,
|
|
120
|
+
url: input.url,
|
|
121
|
+
countryCode: input.merchantCountryCode ?? 'US',
|
|
122
|
+
};
|
|
123
|
+
const credential = JSON.parse(await readFile(input.credentialPath, 'utf8'));
|
|
124
|
+
// The passkey ceremony is scoped to the CEILING + merchant (not one
|
|
125
|
+
// charge) — that scope is the unproven part of the spike.
|
|
126
|
+
const ceilingTarget = {
|
|
127
|
+
merchantName: merchant.name,
|
|
128
|
+
merchantUrl: merchant.url,
|
|
129
|
+
merchantCountryCode: merchant.countryCode,
|
|
130
|
+
transactionAmount: input.ceiling,
|
|
131
|
+
transactionCurrencyCode: input.currency,
|
|
132
|
+
};
|
|
133
|
+
// BUDGET mode: the ceiling target's amount IS the approved ceiling, so the
|
|
134
|
+
// server mints a budget mint token bound to that ceiling — later draws pull
|
|
135
|
+
// sub-ceiling amounts against it tap-free (the single-purchase fresh-tap
|
|
136
|
+
// path below stays non-budget).
|
|
137
|
+
const assurance = await runHostedApproval({
|
|
138
|
+
baseUrl: input.approvalBaseUrl,
|
|
139
|
+
tokenId: credential.tokenId,
|
|
140
|
+
target: ceilingTarget,
|
|
141
|
+
consumerEmail: input.contact.email,
|
|
142
|
+
budget: true,
|
|
143
|
+
onApprovalUrl: deps.onApprovalUrl,
|
|
144
|
+
...(input.maxDraws !== undefined ? { maxDraws: input.maxDraws } : {}),
|
|
145
|
+
...(input.perTransaction !== undefined ? { perTransaction: input.perTransaction } : {}),
|
|
146
|
+
...(input.intent !== undefined ? { intent: input.intent } : {}),
|
|
147
|
+
});
|
|
148
|
+
const mintToken = assurance.mintToken;
|
|
149
|
+
if (!mintToken) {
|
|
150
|
+
throw new Error('the approval server issued no mint token — server-side minting requires the ' +
|
|
151
|
+
'verify-web deployment to run real/turnkey auth (not the dev stub). Retry once it does.');
|
|
152
|
+
}
|
|
153
|
+
const expiresAt = input.expiresAt ?? new Date(now().getTime() + 24 * 60 * 60 * 1000).toISOString();
|
|
154
|
+
const facts = await createCardMandate({
|
|
155
|
+
tokenId: credential.tokenId,
|
|
156
|
+
assuranceData: assurance.assuranceData,
|
|
157
|
+
ceilingMinor,
|
|
158
|
+
merchant,
|
|
159
|
+
currencyCode: input.currency,
|
|
160
|
+
expiresAt,
|
|
161
|
+
...(input.maxDraws !== undefined ? { maxDraws: input.maxDraws } : {}),
|
|
162
|
+
...(anyMerchant ? { crossMerchant: true } : {}),
|
|
163
|
+
}, {
|
|
164
|
+
createIntent: (i) => serverCreateIntent(input.approvalBaseUrl, mintToken, i),
|
|
165
|
+
ledger,
|
|
166
|
+
approvalBaseUrl: input.approvalBaseUrl,
|
|
167
|
+
mintToken,
|
|
168
|
+
now,
|
|
169
|
+
});
|
|
170
|
+
// #5942 CONVERGE: seed the auth-side cumulative store keyed by the VGS INTENT
|
|
171
|
+
// id (`facts.mandateId`) so a later delegated draw resolves the mandate. This
|
|
172
|
+
// is BEST-EFFORT: a failure is logged, never fatal — the bearer mint-token
|
|
173
|
+
// path still works, only the delegated draw needs the register row. Skipped
|
|
174
|
+
// entirely when no mode='card' delegated binding is present.
|
|
175
|
+
const registerCap = cardMandateRegister?.loadCapability() ?? null;
|
|
176
|
+
let registerFailed = false;
|
|
177
|
+
if (registerCap && cardMandateRegister) {
|
|
178
|
+
const reg = await cardMandateRegister
|
|
179
|
+
.register({
|
|
180
|
+
authBaseUrl: registerCap.authBaseUrl,
|
|
181
|
+
agentKey: registerCap.agentKey,
|
|
182
|
+
mandateId: facts.mandateId,
|
|
183
|
+
mintToken,
|
|
184
|
+
ceiling: input.ceiling,
|
|
185
|
+
currency: input.currency,
|
|
186
|
+
})
|
|
187
|
+
.catch((err) => ({
|
|
188
|
+
ok: false,
|
|
189
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
190
|
+
}));
|
|
191
|
+
if (!reg.ok) {
|
|
192
|
+
registerFailed = true;
|
|
193
|
+
// Register failed: the server `card_mandate_spend` row was never
|
|
194
|
+
// created, so a delegated draw against this mandate would 404
|
|
195
|
+
// `no_mandate`. Mark it register-failed so findCovering() SKIPS it and
|
|
196
|
+
// the owner's next checkout falls through to a fresh per-purchase tap,
|
|
197
|
+
// rather than silently selecting a mandate that can't be drawn. Marking
|
|
198
|
+
// is best-effort too — a failure here must not abort mandate-start.
|
|
199
|
+
const marked = await ledger
|
|
200
|
+
.markRegisterFailed(facts.mandateId, now())
|
|
201
|
+
.then(() => true)
|
|
202
|
+
.catch(() => false);
|
|
203
|
+
process.stderr.write(marked
|
|
204
|
+
? `warning: card-mandate register failed (${reg.reason ?? 'unknown'}) — this mandate ` +
|
|
205
|
+
`will NOT be used for tap-free draws; your next checkout will use a fresh ` +
|
|
206
|
+
`per-purchase passkey tap. Re-run 'mandate start' to try again.\n`
|
|
207
|
+
: // Escalate: the mark write ALSO failed, so the mandate is persisted but
|
|
208
|
+
// NOT disabled — findCovering could still select an undrawable mandate.
|
|
209
|
+
// Tell the owner loudly not to rely on it and how to recover.
|
|
210
|
+
`warning: card-mandate register failed (${reg.reason ?? 'unknown'}) AND the mandate ` +
|
|
211
|
+
`could NOT be disabled locally — do not rely on it. Run 'mandate list' and re-run ` +
|
|
212
|
+
`'mandate start'. mandateId=${facts.mandateId}\n`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { ...facts, merchantHost: new URL(merchant.url).hostname, registerFailed };
|
|
216
|
+
},
|
|
217
|
+
async review(input) {
|
|
218
|
+
const amountMinor = decimalToMinor(input.amount);
|
|
219
|
+
if (amountMinor === null)
|
|
220
|
+
throw new Error(`invalid amount ${JSON.stringify(input.amount)}`);
|
|
221
|
+
const host = new URL(input.url).hostname;
|
|
222
|
+
const browser = await launchBrowser();
|
|
223
|
+
try {
|
|
224
|
+
const prep = await prepareCheckout({
|
|
225
|
+
url: input.url,
|
|
226
|
+
mandate: {
|
|
227
|
+
maxAmountMinor: amountMinor,
|
|
228
|
+
currency: input.currency,
|
|
229
|
+
merchantHost: host,
|
|
230
|
+
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
|
231
|
+
},
|
|
232
|
+
amountMinor,
|
|
233
|
+
currency: input.currency,
|
|
234
|
+
browser,
|
|
235
|
+
}, store);
|
|
236
|
+
if (prep.status !== 'ready') {
|
|
237
|
+
await browser.close();
|
|
238
|
+
throw new Error(`review refused: ${prep.result.outcome} — ${prep.result.detail ?? ''}`);
|
|
239
|
+
}
|
|
240
|
+
const r = prep.checkout.review;
|
|
241
|
+
// Expire the companion session on the SAME schedule as the store entry
|
|
242
|
+
// (unref'd so a pending timer never keeps the process alive).
|
|
243
|
+
const cleanupTimer = setTimeout(() => void closeSession(r.id), ttlMs);
|
|
244
|
+
cleanupTimer.unref?.();
|
|
245
|
+
sessions.set(r.id, {
|
|
246
|
+
browser,
|
|
247
|
+
target: buildTarget(input),
|
|
248
|
+
amountMinor,
|
|
249
|
+
currency: input.currency,
|
|
250
|
+
contact: input.contact,
|
|
251
|
+
cleanupTimer,
|
|
252
|
+
});
|
|
253
|
+
return {
|
|
254
|
+
reviewId: r.id,
|
|
255
|
+
merchantHost: r.merchantHost,
|
|
256
|
+
amountMinor: r.amountMinor,
|
|
257
|
+
currency: r.currency,
|
|
258
|
+
submitTargetFingerprint: JSON.stringify(r.submitTargetFingerprint ?? null),
|
|
259
|
+
detectedRoles: [...r.detectedRoles],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
await browser.close().catch(() => { });
|
|
264
|
+
throw err;
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
async pay(input) {
|
|
268
|
+
const session = sessions.get(input.reviewId);
|
|
269
|
+
if (!session) {
|
|
270
|
+
return {
|
|
271
|
+
outcome: 'failed',
|
|
272
|
+
confirmationRef: null,
|
|
273
|
+
receiptPath: null,
|
|
274
|
+
detail: `no prepared review ${input.reviewId} — call review first (a review does not survive a restart)`,
|
|
275
|
+
vicConfirmation: null,
|
|
276
|
+
source: null,
|
|
277
|
+
remainingMinor: null,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
// Amount-bind the confirmation: the pay-call amount must match the
|
|
281
|
+
// reviewed amount. The reviewId already locks the immutable mandate, but
|
|
282
|
+
// re-checking here makes the confirmation explicitly amount-bound so a
|
|
283
|
+
// caller cannot pay a different figure than the human reviewed.
|
|
284
|
+
const payAmountMinor = decimalToMinor(input.amount);
|
|
285
|
+
if (payAmountMinor !== session.amountMinor) {
|
|
286
|
+
await closeSession(input.reviewId);
|
|
287
|
+
return {
|
|
288
|
+
outcome: 'failed',
|
|
289
|
+
confirmationRef: null,
|
|
290
|
+
receiptPath: null,
|
|
291
|
+
detail: `pay amount ${JSON.stringify(input.amount)} does not match the reviewed amount (${session.amountMinor} minor units) — start a fresh review`,
|
|
292
|
+
vicConfirmation: null,
|
|
293
|
+
source: null,
|
|
294
|
+
remainingMinor: null,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
// Reject an expired prepared checkout BEFORE running the hosted passkey
|
|
298
|
+
// approval — the store may have expired/closed the entry while the review
|
|
299
|
+
// waited. Peeking here avoids minting a credential we can't submit.
|
|
300
|
+
if (!store.peek(input.reviewId)) {
|
|
301
|
+
await closeSession(input.reviewId);
|
|
302
|
+
return {
|
|
303
|
+
outcome: 'failed',
|
|
304
|
+
confirmationRef: null,
|
|
305
|
+
receiptPath: null,
|
|
306
|
+
detail: `prepared review ${input.reviewId} expired — start a fresh review`,
|
|
307
|
+
vicConfirmation: null,
|
|
308
|
+
source: null,
|
|
309
|
+
remainingMinor: null,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
clearTimeout(session.cleanupTimer);
|
|
313
|
+
try {
|
|
314
|
+
const credential = JSON.parse(await readFile(input.credentialPath, 'utf8'));
|
|
315
|
+
const host = new URL(session.target.merchantUrl).hostname;
|
|
316
|
+
// Does an ACTIVE card mandate already cover this exact purchase? If so,
|
|
317
|
+
// draw against it TAP-FREE (no hosted passkey). Else fall back to today's
|
|
318
|
+
// 1:1 fresh-tap flow. `mintToken` requirement means a mandate with no
|
|
319
|
+
// persisted token can never silently short-circuit the tap.
|
|
320
|
+
const covering = await ledger.findCovering({
|
|
321
|
+
merchantHost: host,
|
|
322
|
+
currencyCode: session.currency,
|
|
323
|
+
amountMinor: session.amountMinor,
|
|
324
|
+
now: now(),
|
|
325
|
+
});
|
|
326
|
+
let source;
|
|
327
|
+
let confirmBase;
|
|
328
|
+
let confirmToken;
|
|
329
|
+
let drawnRemaining = null;
|
|
330
|
+
let instrument;
|
|
331
|
+
if (covering && covering.mintToken) {
|
|
332
|
+
// --- Tap-free mandate draw ------------------------------------------
|
|
333
|
+
source = 'mandate';
|
|
334
|
+
confirmBase = covering.approvalBaseUrl;
|
|
335
|
+
// The bearer mint token stays the auth for the VIC confirmation POST
|
|
336
|
+
// (the confirmation route accepts only the mint token). The CRYPTOGRAM
|
|
337
|
+
// mint, however, may use a delegated verdict token — see below.
|
|
338
|
+
confirmToken = covering.mintToken;
|
|
339
|
+
const mandateId = covering.mandateId;
|
|
340
|
+
// #5923 delegated card-draw: when the agent holds a local mode='card'
|
|
341
|
+
// binding, mint the cryptogram with a PoP-signed VERDICT token (the
|
|
342
|
+
// verify-web tryVerdictAuth path) INSTEAD of the bearer mint token. No
|
|
343
|
+
// binding (or seam not injected) → `drawToken` stays the bearer token:
|
|
344
|
+
// today's #5917 draw, byte-for-byte unchanged (converge-not-break).
|
|
345
|
+
//
|
|
346
|
+
// #5928 CRITICAL-2: the CLI does NOT settle the reservation. verify-web
|
|
347
|
+
// (which knows whether the cryptogram was payable) commits it on a
|
|
348
|
+
// payable mint and releases it on a decline, server-authoritatively, so
|
|
349
|
+
// the drawer can never release after a payable mint to dodge the ceiling.
|
|
350
|
+
let drawToken = confirmToken;
|
|
351
|
+
const capability = cardDrawVerdict?.loadCapability() ?? null;
|
|
352
|
+
if (capability && cardDrawVerdict) {
|
|
353
|
+
try {
|
|
354
|
+
const { verdict } = await cardDrawVerdict.fetchVerdict({
|
|
355
|
+
authBaseUrl: capability.authBaseUrl,
|
|
356
|
+
agentKey: capability.agentKey,
|
|
357
|
+
mandateId,
|
|
358
|
+
// One draw per review; the reviewId is the server-side idempotency key.
|
|
359
|
+
drawId: input.reviewId,
|
|
360
|
+
draw: {
|
|
361
|
+
tokenId: covering.tokenId,
|
|
362
|
+
amount: session.target.transactionAmount,
|
|
363
|
+
currency: session.currency,
|
|
364
|
+
merchantName: session.target.merchantName,
|
|
365
|
+
merchantUrl: session.target.merchantUrl,
|
|
366
|
+
merchantCountryCode: session.target.merchantCountryCode,
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
drawToken = verdict;
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
// The verdict gate refused (or a transient error) BEFORE the cryptogram
|
|
373
|
+
// mint. This point is OUTSIDE the submitApprovedCheckout catch that
|
|
374
|
+
// gracefully handles later draw errors, so an un-caught throw here
|
|
375
|
+
// escapes pay() raw — making an agent WITH a card binding strictly more
|
|
376
|
+
// fragile than one without (breaks converge-not-break). Convert it into
|
|
377
|
+
// a graceful FAILED result instead. We do NOT silently fall back to the
|
|
378
|
+
// bearer mint token: on an over_ceiling/reserve_refused refusal that
|
|
379
|
+
// would DODGE the server-side ceiling the verdict path exists to enforce.
|
|
380
|
+
//
|
|
381
|
+
// Definitive gate refusal (no_mandate, over_ceiling, reserve_refused,
|
|
382
|
+
// checkout_access_revoked, token_mismatch, …): the mandate cannot be
|
|
383
|
+
// drawn, so disable it — findCovering then skips it and the NEXT
|
|
384
|
+
// pay_merchant falls through to a fresh per-purchase tap. Transient
|
|
385
|
+
// (503 card_draw_disabled, challenge hiccup, network): leave the mandate
|
|
386
|
+
// healthy and surface a retryable message. Duck-typed: the engine cannot
|
|
387
|
+
// import CardDrawRefusedError (it lives in @visa/cli, above this seam).
|
|
388
|
+
const reasons = Array.isArray(err.reasons)
|
|
389
|
+
? err.reasons
|
|
390
|
+
: [];
|
|
391
|
+
const status = typeof err.status === 'number'
|
|
392
|
+
? err.status
|
|
393
|
+
: 0;
|
|
394
|
+
const TRANSIENT = new Set([
|
|
395
|
+
'challenge_failed',
|
|
396
|
+
'challenge_malformed',
|
|
397
|
+
'verdict_refused',
|
|
398
|
+
]);
|
|
399
|
+
const transient = status === 503 || reasons.length === 0 || reasons.every((r) => TRANSIENT.has(r));
|
|
400
|
+
if (!transient) {
|
|
401
|
+
// Best-effort: a mark failure must not turn a clean refusal into a throw.
|
|
402
|
+
await ledger.markUnhonored(mandateId, now()).catch(() => { });
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
outcome: 'failed',
|
|
406
|
+
confirmationRef: null,
|
|
407
|
+
receiptPath: null,
|
|
408
|
+
detail: transient
|
|
409
|
+
? `the card-mandate draw could not be authorized right now (${reasons.join(', ') || 'temporary error'}) — retry shortly`
|
|
410
|
+
: `this card mandate can no longer be drawn (${reasons.join(', ') || 'refused'}); it has been disabled — retry the checkout to use a fresh per-purchase passkey tap`,
|
|
411
|
+
vicConfirmation: null,
|
|
412
|
+
source,
|
|
413
|
+
remainingMinor: null,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// VgsLiveInstrument mints against an EXISTING intent (the mandate) with
|
|
418
|
+
// no fresh assurance — exactly the draw semantics. The fetchCredential
|
|
419
|
+
// seam routes through drawFromMandate so the ledger accounting (reserve
|
|
420
|
+
// -> commit on payable, release on reject) wraps the cryptogram pull.
|
|
421
|
+
const reference = {
|
|
422
|
+
tokenId: covering.tokenId,
|
|
423
|
+
intentId: mandateId,
|
|
424
|
+
merchantName: session.target.merchantName,
|
|
425
|
+
merchantUrl: session.target.merchantUrl,
|
|
426
|
+
merchantCountryCode: session.target.merchantCountryCode,
|
|
427
|
+
transactionAmount: session.target.transactionAmount,
|
|
428
|
+
transactionCurrencyCode: session.currency,
|
|
429
|
+
};
|
|
430
|
+
const fetchCredential = async ({ transaction }) => {
|
|
431
|
+
let draw;
|
|
432
|
+
try {
|
|
433
|
+
draw = await drawFromMandate({
|
|
434
|
+
mandateId,
|
|
435
|
+
amountMinor: session.amountMinor,
|
|
436
|
+
transaction: { ...transaction, transactionCurrencyCode: session.currency },
|
|
437
|
+
}, {
|
|
438
|
+
fetchCryptogram: (i) => fetchMandateCryptogram(confirmBase, drawToken, i),
|
|
439
|
+
ledger,
|
|
440
|
+
now,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
// Disable the mandate ONLY for a post-reservation NETWORK decline
|
|
445
|
+
// (MandateDrawDeclinedError). That case leaves the mandate
|
|
446
|
+
// active+covering, so a naive retry would re-select it and fail
|
|
447
|
+
// identically — trapping the caller; marking it unhonored makes the
|
|
448
|
+
// next pay_merchant skip it and take a fresh per-purchase tap. We do
|
|
449
|
+
// NOT attempt an unsafe same-call browser fallback mid-submit.
|
|
450
|
+
//
|
|
451
|
+
// A PRE-network failure — reserve() failing closed on a concurrent
|
|
452
|
+
// over-budget race or expiry, or commit() throwing on file I/O —
|
|
453
|
+
// is NOT a network decline: the mandate is HEALTHY, so we must
|
|
454
|
+
// rethrow WITHOUT disabling it (michaelyang1 M1). drawFromMandate
|
|
455
|
+
// has already released any reservation, so the budget is intact.
|
|
456
|
+
// Only a DEFINITIVE (hard) decline disables the mandate. A TRANSIENT
|
|
457
|
+
// failure — a gateway 5xx / "not completed, try again" / network
|
|
458
|
+
// reset/timeout — must NOT permanently kill the budget: the mandate
|
|
459
|
+
// may be perfectly healthy (it can have committed a draw seconds
|
|
460
|
+
// earlier) and the rails may recover on retry. Over-disabling on a
|
|
461
|
+
// transient 502 threw away good budgets and forced a fresh passkey
|
|
462
|
+
// every time.
|
|
463
|
+
// Classify on the underlying gateway CAUSE, not the MandateDrawDeclinedError
|
|
464
|
+
// wrapper — the wrapper's own advisory text mentions "try again"/"network",
|
|
465
|
+
// which would otherwise self-classify every decline as transient.
|
|
466
|
+
if (err instanceof MandateDrawDeclinedError &&
|
|
467
|
+
!isTransientDrawFailure(err.cause ?? err)) {
|
|
468
|
+
await ledger.markUnhonored(mandateId, now());
|
|
469
|
+
}
|
|
470
|
+
// #5928 CRITICAL-2: the server-side reservation is released by
|
|
471
|
+
// verify-web (it saw the mint fail), not here — the CLI never settles.
|
|
472
|
+
throw err;
|
|
473
|
+
}
|
|
474
|
+
drawnRemaining = draw.remainingMinor;
|
|
475
|
+
// #5928 CRITICAL-2: the reservation is committed by verify-web on the
|
|
476
|
+
// payable mint (server-authoritative); the CLI does not settle.
|
|
477
|
+
return draw.payment;
|
|
478
|
+
};
|
|
479
|
+
instrument = new VgsLiveInstrument(reference, session.contact.fullName ?? '', fetchCredential);
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
// --- 1:1 fresh-tap flow (unchanged) ---------------------------------
|
|
483
|
+
source = 'fresh-tap';
|
|
484
|
+
const assurance = await runHostedApproval({
|
|
485
|
+
baseUrl: input.approvalBaseUrl,
|
|
486
|
+
tokenId: credential.tokenId,
|
|
487
|
+
target: session.target,
|
|
488
|
+
consumerEmail: session.contact.email,
|
|
489
|
+
onApprovalUrl: input.onApprovalUrl ?? deps.onApprovalUrl,
|
|
490
|
+
});
|
|
491
|
+
// Server-side mint (Phase 1): the approval claim releases a scoped mint
|
|
492
|
+
// token; the credential is minted by the verify-web deployment (which
|
|
493
|
+
// holds the VGS secret), never on this machine. No token means the
|
|
494
|
+
// deployment ran the dev-auth stub — refuse loudly rather than reach for
|
|
495
|
+
// a client-held secret (there is none anymore).
|
|
496
|
+
const mintToken = assurance.mintToken;
|
|
497
|
+
if (!mintToken) {
|
|
498
|
+
return {
|
|
499
|
+
outcome: 'failed',
|
|
500
|
+
confirmationRef: null,
|
|
501
|
+
receiptPath: null,
|
|
502
|
+
detail: 'the approval server issued no mint token — server-side minting requires the ' +
|
|
503
|
+
'verify-web deployment to run real/turnkey auth (not the dev stub). Retry once it does.',
|
|
504
|
+
vicConfirmation: null,
|
|
505
|
+
source: 'fresh-tap',
|
|
506
|
+
remainingMinor: null,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
confirmBase = input.approvalBaseUrl;
|
|
510
|
+
confirmToken = mintToken;
|
|
511
|
+
instrument = new VgsAssuranceInstrument(credential, assurance, session.target, session.contact.fullName ?? '', async (mintInput) => {
|
|
512
|
+
const { intentId, status } = await serverCreateIntent(confirmBase, confirmToken, mintInput);
|
|
513
|
+
try {
|
|
514
|
+
const payment = await serverFetchCryptogram(confirmBase, confirmToken, {
|
|
515
|
+
tokenId: mintInput.tokenId,
|
|
516
|
+
intentId,
|
|
517
|
+
transaction: mintInput.transaction,
|
|
518
|
+
});
|
|
519
|
+
return { payment, intentId };
|
|
520
|
+
}
|
|
521
|
+
catch (err) {
|
|
522
|
+
throw new Error(`${err.message} (intent status at creation: ${status ?? 'unknown'})`);
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
const mode = input.submit ? 'submit' : 'dry-run';
|
|
527
|
+
const result = await submitApprovedCheckout(input.reviewId, {
|
|
528
|
+
approval: { approved: true, reviewId: input.reviewId },
|
|
529
|
+
instrument,
|
|
530
|
+
contact: session.contact,
|
|
531
|
+
mode,
|
|
532
|
+
}, store);
|
|
533
|
+
// Report the observed submit outcome to VIC for the consumed intent
|
|
534
|
+
// (APPROVED/DECLINED). Only definitive submit answers post. Mirrors
|
|
535
|
+
// run-live-fill.ts.
|
|
536
|
+
let vicConfirmation = null;
|
|
537
|
+
if (mode === 'submit') {
|
|
538
|
+
vicConfirmation = await reportVicOutcome({
|
|
539
|
+
target: instrument.confirmationTarget(),
|
|
540
|
+
outcome: result.outcome,
|
|
541
|
+
transaction: {
|
|
542
|
+
transactionAmount: session.target.transactionAmount,
|
|
543
|
+
transactionCurrencyCode: session.currency,
|
|
544
|
+
},
|
|
545
|
+
post: (confInput) => serverPostConfirmation(confirmBase, confirmToken, confInput),
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
let receiptPath = null;
|
|
549
|
+
const report = await writeReceipt(RECEIPT_DIR, buildReceipt({
|
|
550
|
+
mode,
|
|
551
|
+
reviewId: input.reviewId,
|
|
552
|
+
// Derive BOTH name and host from the reviewed session target (not
|
|
553
|
+
// pay()'s input.url), so the receipt always reflects the checkout
|
|
554
|
+
// the human actually reviewed + that reviewId bound for submit.
|
|
555
|
+
merchant: {
|
|
556
|
+
name: session.target.merchantName,
|
|
557
|
+
host: new URL(session.target.merchantUrl).hostname,
|
|
558
|
+
},
|
|
559
|
+
transaction: {
|
|
560
|
+
amount: session.target.transactionAmount,
|
|
561
|
+
amountMinor: session.amountMinor,
|
|
562
|
+
currency: session.currency,
|
|
563
|
+
},
|
|
564
|
+
result,
|
|
565
|
+
vicConfirmation,
|
|
566
|
+
}));
|
|
567
|
+
if (report.written)
|
|
568
|
+
receiptPath = report.path;
|
|
569
|
+
return {
|
|
570
|
+
outcome: result.outcome,
|
|
571
|
+
confirmationRef: result.confirmationRef ?? null,
|
|
572
|
+
receiptPath,
|
|
573
|
+
detail: result.detail ?? null,
|
|
574
|
+
vicConfirmation,
|
|
575
|
+
source,
|
|
576
|
+
remainingMinor: drawnRemaining,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
finally {
|
|
580
|
+
await closeSession(input.reviewId);
|
|
581
|
+
}
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Page } from 'playwright-core';
|
|
2
|
+
export type FieldSource = 'autocomplete' | 'attr-heuristic' | 'label-text' | 'iframe-psp' | 'type-inference';
|
|
3
|
+
export type FieldRole = 'number' | 'expMonth' | 'expYear' | 'expCombined' | 'cvc' | 'name' | 'nameFirst' | 'nameLast' | 'email' | 'oneTimeCode' | 'addressLine1' | 'addressLine2' | 'city' | 'state' | 'postalCode' | 'country';
|
|
4
|
+
export declare const ALL_ROLES: FieldRole[];
|
|
5
|
+
export type SelectOption = {
|
|
6
|
+
value: string;
|
|
7
|
+
text: string;
|
|
8
|
+
};
|
|
9
|
+
export type CandidateMeta = {
|
|
10
|
+
idx: number;
|
|
11
|
+
tag: 'input' | 'select' | 'textarea';
|
|
12
|
+
type: string;
|
|
13
|
+
name: string;
|
|
14
|
+
id: string;
|
|
15
|
+
placeholder: string;
|
|
16
|
+
ariaLabel: string;
|
|
17
|
+
autocomplete: string;
|
|
18
|
+
maxlength: string;
|
|
19
|
+
inputmode: string;
|
|
20
|
+
pattern: string;
|
|
21
|
+
labelText: string;
|
|
22
|
+
options: SelectOption[];
|
|
23
|
+
visible: boolean;
|
|
24
|
+
};
|
|
25
|
+
export type RoleMatch = {
|
|
26
|
+
role: FieldRole;
|
|
27
|
+
confidence: number;
|
|
28
|
+
source: FieldSource;
|
|
29
|
+
};
|
|
30
|
+
export type FieldEntry = {
|
|
31
|
+
locator: string;
|
|
32
|
+
confidence: number;
|
|
33
|
+
source: FieldSource;
|
|
34
|
+
frame?: string;
|
|
35
|
+
tag: 'input' | 'select' | 'textarea';
|
|
36
|
+
inputType: string;
|
|
37
|
+
visible: boolean;
|
|
38
|
+
options?: SelectOption[];
|
|
39
|
+
maxlength?: number;
|
|
40
|
+
};
|
|
41
|
+
export type FieldMap = Partial<Record<FieldRole, FieldEntry>>;
|
|
42
|
+
export type FieldCandidate = {
|
|
43
|
+
role: FieldRole | null;
|
|
44
|
+
confidence: number;
|
|
45
|
+
source: FieldSource | null;
|
|
46
|
+
matches: RoleMatch[];
|
|
47
|
+
frame?: string;
|
|
48
|
+
meta: CandidateMeta;
|
|
49
|
+
};
|
|
50
|
+
export type PspHit = {
|
|
51
|
+
psp: string;
|
|
52
|
+
requiresAdapter: string | null;
|
|
53
|
+
frameSelector: string;
|
|
54
|
+
};
|
|
55
|
+
export type DetectResult = {
|
|
56
|
+
fields: FieldMap;
|
|
57
|
+
candidates: FieldCandidate[];
|
|
58
|
+
psps: PspHit[];
|
|
59
|
+
};
|
|
60
|
+
export declare function classifyCandidate(m: CandidateMeta): RoleMatch[];
|
|
61
|
+
export declare function detectFields(page: Page): Promise<DetectResult>;
|