@visa/cli 4.1.0-rc.38 → 4.1.0-rc.39
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/dist/checkout-engine/cli-engine.d.ts +13 -26
- package/dist/checkout-engine/cli-engine.js +189 -116
- package/dist/checkout-engine/evidence.d.ts +1 -1
- package/dist/checkout-engine/executor.d.ts +3 -1
- package/dist/checkout-engine/executor.js +14 -14
- package/dist/checkout-engine/hosted-approval.d.ts +7 -0
- package/dist/checkout-engine/hosted-approval.js +29 -1
- package/dist/checkout-engine/live-fill-approval.js +3 -3
- package/dist/checkout-engine/mandate/card-mandate.d.ts +5 -1
- package/dist/checkout-engine/mandate/card-mandate.js +7 -1
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +13 -12
- package/dist/checkout-engine/mandate/mandate-ledger.js +15 -4
- package/dist/checkout-engine/run-live-fill.js +6 -5
- package/dist/checkout-engine/vic-confirmation.js +2 -2
- package/dist/cli.js +263 -262
- package/dist/mcp-server/index.js +195 -194
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -2,7 +2,7 @@ import { type Browser } from 'playwright-core';
|
|
|
2
2
|
import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, type PreparedCheckoutSessionStore } from './executor.js';
|
|
3
3
|
import { runHostedApproval as realRunHostedApproval } from './hosted-approval.js';
|
|
4
4
|
import { type VgsCheckoutTarget } from './vgs-live-instrument.js';
|
|
5
|
-
import { serverFetchCryptogram } from './vgs-gateway/server-mint-client.js';
|
|
5
|
+
import { serverFetchCryptogram, serverPostConfirmation } from './vgs-gateway/server-mint-client.js';
|
|
6
6
|
import { type CardMandateFacts } from './mandate/card-mandate.js';
|
|
7
7
|
import { MandateLedger } from './mandate/mandate-ledger.js';
|
|
8
8
|
import { writeReceipt as realWriteReceipt } from './receipt.js';
|
|
@@ -61,20 +61,13 @@ export type CliReceiptFacts = {
|
|
|
61
61
|
remainingMinor: number | null;
|
|
62
62
|
};
|
|
63
63
|
export type CliStartMandateInput = {
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
url?: string;
|
|
64
|
+
/** Optional local card-capability selector (legacy name or exact request-key JKT). */
|
|
65
|
+
agentRef?: string;
|
|
67
66
|
ceiling: string;
|
|
68
67
|
currency: string;
|
|
69
68
|
credentialPath: string;
|
|
70
69
|
contact: Contact;
|
|
71
70
|
approvalBaseUrl: string;
|
|
72
|
-
merchantName?: string;
|
|
73
|
-
merchantCountryCode?: string;
|
|
74
|
-
/** ISO 8601 mandate expiry; defaults to now + 24h. */
|
|
75
|
-
expiresAt?: string;
|
|
76
|
-
/** Max draws the ceiling intent may fulfil. */
|
|
77
|
-
maxDraws?: number;
|
|
78
71
|
/**
|
|
79
72
|
* Per-purchase cap (decimal string, > 0 and <= ceiling). Registered with the
|
|
80
73
|
* approval context so the operator reads it as a worst-case term, and carried
|
|
@@ -86,12 +79,6 @@ export type CliStartMandateInput = {
|
|
|
86
79
|
* "written by the agent" block — provenance for the human, never trusted.
|
|
87
80
|
*/
|
|
88
81
|
intent?: string;
|
|
89
|
-
/**
|
|
90
|
-
* BUDGET mode: create a mandate spendable at ANY merchant (no merchant lock),
|
|
91
|
-
* bounded by the ceiling + per-transaction limit. The passkey approval shows
|
|
92
|
-
* "spend budget mandate" so the owner consents to the broader scope.
|
|
93
|
-
*/
|
|
94
|
-
anyMerchant?: boolean;
|
|
95
82
|
};
|
|
96
83
|
export type CliMandateFacts = CardMandateFacts & {
|
|
97
84
|
merchantHost: string;
|
|
@@ -99,8 +86,8 @@ export type CliMandateFacts = CardMandateFacts & {
|
|
|
99
86
|
* True when the mandate minted its ceiling intent but the #5942 register
|
|
100
87
|
* handshake failed, so `findCovering` will SKIP it and no tap-free draw is
|
|
101
88
|
* possible. The mandate exists but is not usable — the caller must surface
|
|
102
|
-
* this (not report a plain success). Absent/false
|
|
103
|
-
*
|
|
89
|
+
* this (not report a plain success). Absent/false means registration succeeded;
|
|
90
|
+
* mandate-start now refuses before approval when no capability can register.
|
|
104
91
|
*/
|
|
105
92
|
registerFailed?: boolean;
|
|
106
93
|
};
|
|
@@ -128,7 +115,7 @@ export interface CardDrawVerdictCapability {
|
|
|
128
115
|
authBaseUrl: string;
|
|
129
116
|
}
|
|
130
117
|
export interface CardDrawVerdictSeam {
|
|
131
|
-
loadCapability: () => CardDrawVerdictCapability | null;
|
|
118
|
+
loadCapability: (agentRef?: string) => CardDrawVerdictCapability | null;
|
|
132
119
|
fetchVerdict: (input: {
|
|
133
120
|
authBaseUrl: string;
|
|
134
121
|
agentKey: unknown;
|
|
@@ -141,7 +128,7 @@ export interface CardDrawVerdictSeam {
|
|
|
141
128
|
}>;
|
|
142
129
|
}
|
|
143
130
|
export interface CardMandateRegisterSeam {
|
|
144
|
-
loadCapability: () => {
|
|
131
|
+
loadCapability: (agentRef?: string) => {
|
|
145
132
|
agentKey: unknown;
|
|
146
133
|
agentJkt: string;
|
|
147
134
|
authBaseUrl: string;
|
|
@@ -185,18 +172,18 @@ export type CliEngineDeps = {
|
|
|
185
172
|
* markUnhonored path with no network.
|
|
186
173
|
*/
|
|
187
174
|
serverFetchCryptogram?: typeof serverFetchCryptogram;
|
|
175
|
+
/** Injectable confirmation transport; defaults to verify-web. */
|
|
176
|
+
serverPostConfirmation?: typeof serverPostConfirmation;
|
|
188
177
|
/**
|
|
189
178
|
* #5923 delegated card-draw verdict seam (see {@link CardDrawVerdictSeam}).
|
|
190
|
-
* Injected by the CLI when the
|
|
191
|
-
*
|
|
192
|
-
* shipped #5917 flow, unchanged).
|
|
179
|
+
* Injected by the CLI when the runtime holds separately provisioned card
|
|
180
|
+
* authority. When absent, a covering-mandate draw fails before cryptogram mint.
|
|
193
181
|
*/
|
|
194
182
|
cardDrawVerdict?: CardDrawVerdictSeam;
|
|
195
183
|
/**
|
|
196
184
|
* #5942 delegated card-mandate register seam (see {@link CardMandateRegisterSeam}).
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* still works, only the delegated draw needs the register row).
|
|
185
|
+
* Required by mandate-start. When absent, the ceremony is refused before
|
|
186
|
+
* passkey approval because a budget token cannot act as draw authority.
|
|
200
187
|
*/
|
|
201
188
|
cardMandateRegister?: CardMandateRegisterSeam;
|
|
202
189
|
};
|
|
@@ -18,7 +18,7 @@ import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realS
|
|
|
18
18
|
import { runHostedApproval as realRunHostedApproval } from './hosted-approval.js';
|
|
19
19
|
import { VgsAssuranceInstrument, VgsLiveInstrument, decimalToMinor, } from './vgs-live-instrument.js';
|
|
20
20
|
import { serverCreateIntent, serverFetchCryptogram, serverPostConfirmation, } from './vgs-gateway/server-mint-client.js';
|
|
21
|
-
import { createCardMandate, drawFromMandate, MandateDrawDeclinedError, } from './mandate/card-mandate.js';
|
|
21
|
+
import { createCardMandate, DEFAULT_MANDATE_MAX_DRAWS, drawFromMandate, MandateDrawDeclinedError, } from './mandate/card-mandate.js';
|
|
22
22
|
import { MandateLedger } from './mandate/mandate-ledger.js';
|
|
23
23
|
import { buildReceipt, writeReceipt as realWriteReceipt } from './receipt.js';
|
|
24
24
|
import { reportVicOutcome as realReportVicOutcome, } from './vic-confirmation.js';
|
|
@@ -46,6 +46,32 @@ export function isTransientDrawFailure(err) {
|
|
|
46
46
|
// card-decline reason) matches nothing here → the mandate is correctly disabled.
|
|
47
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
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* A verdict refusal may be wrapped by drawFromMandate after its local
|
|
51
|
+
* reservation is released. Walk the cause chain so the original auth status +
|
|
52
|
+
* reasons still decide whether the mandate is permanently disabled.
|
|
53
|
+
*/
|
|
54
|
+
function classifyCardDrawVerdictFailure(err) {
|
|
55
|
+
const transientReasons = new Set(['challenge_failed', 'challenge_malformed', 'verdict_refused']);
|
|
56
|
+
let current = err;
|
|
57
|
+
for (let i = 0; i < 5 && current; i++) {
|
|
58
|
+
const candidate = current;
|
|
59
|
+
const status = typeof candidate.status === 'number' ? candidate.status : 0;
|
|
60
|
+
const reasons = Array.isArray(candidate.reasons)
|
|
61
|
+
? candidate.reasons.filter((reason) => typeof reason === 'string')
|
|
62
|
+
: [];
|
|
63
|
+
if (status !== 0 || reasons.length > 0) {
|
|
64
|
+
return {
|
|
65
|
+
transient: status === 503 ||
|
|
66
|
+
reasons.length === 0 ||
|
|
67
|
+
reasons.every((reason) => transientReasons.has(reason)),
|
|
68
|
+
reasons,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
current = candidate.cause;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
49
75
|
const RECEIPT_DIR = join(homedir(), '.visa-mcp', 'checkout-receipts');
|
|
50
76
|
// Must match the prepared-checkout store TTL so a session and its store entry
|
|
51
77
|
// expire together — an abandoned review can't leak the browser + state.
|
|
@@ -66,6 +92,7 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
66
92
|
const ledger = deps.ledger ?? defaultLedger;
|
|
67
93
|
const now = deps.now ?? (() => new Date());
|
|
68
94
|
const fetchMandateCryptogram = deps.serverFetchCryptogram ?? serverFetchCryptogram;
|
|
95
|
+
const postServerConfirmation = deps.serverPostConfirmation ?? serverPostConfirmation;
|
|
69
96
|
const cardDrawVerdict = deps.cardDrawVerdict ?? null;
|
|
70
97
|
const cardMandateRegister = deps.cardMandateRegister ?? null;
|
|
71
98
|
async function closeSession(reviewId) {
|
|
@@ -95,6 +122,9 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
95
122
|
if (ceilingMinor === null || ceilingMinor <= 0) {
|
|
96
123
|
throw new Error(`invalid mandate ceiling ${JSON.stringify(input.ceiling)}`);
|
|
97
124
|
}
|
|
125
|
+
if (input.currency.toUpperCase() !== 'USD') {
|
|
126
|
+
throw new Error('card spend budgets currently support USD only');
|
|
127
|
+
}
|
|
98
128
|
if (input.perTransaction !== undefined) {
|
|
99
129
|
const perTxMinor = decimalToMinor(input.perTransaction);
|
|
100
130
|
if (perTxMinor === null || perTxMinor <= 0 || perTxMinor > ceilingMinor) {
|
|
@@ -102,24 +132,24 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
102
132
|
'must be a positive amount at or under the ceiling');
|
|
103
133
|
}
|
|
104
134
|
}
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
135
|
+
// A budget token can bootstrap one VGS intent and its register handshake,
|
|
136
|
+
// but it is never payable draw authority. Refuse before the passkey
|
|
137
|
+
// ceremony unless this runtime can both prove the separately provisioned
|
|
138
|
+
// card capability and register the resulting intent server-side.
|
|
139
|
+
const registerCap = cardMandateRegister?.loadCapability(input.agentRef) ?? null;
|
|
140
|
+
if (!cardMandateRegister || !registerCap) {
|
|
141
|
+
throw new Error('startCardMandate requires separately provisioned card authority in this runtime; ' +
|
|
142
|
+
'identity pairing alone does not grant a card mandate');
|
|
111
143
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
countryCode: input.merchantCountryCode ?? 'US',
|
|
122
|
-
};
|
|
144
|
+
// There is one budget product: eligible retail merchants under the
|
|
145
|
+
// provider's required Retail/5999 network category, with total,
|
|
146
|
+
// per-purchase, count, and time bounds stated on the approval page. The
|
|
147
|
+
// sentinel is provider metadata, never a user-entered merchant route.
|
|
148
|
+
const merchant = {
|
|
149
|
+
name: 'retail spend budget',
|
|
150
|
+
url: 'https://retail-budget.visa/budget',
|
|
151
|
+
countryCode: 'US',
|
|
152
|
+
};
|
|
123
153
|
const credential = JSON.parse(await readFile(input.credentialPath, 'utf8'));
|
|
124
154
|
// The passkey ceremony is scoped to the CEILING + merchant (not one
|
|
125
155
|
// charge) — that scope is the unproven part of the spike.
|
|
@@ -140,8 +170,9 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
140
170
|
target: ceilingTarget,
|
|
141
171
|
consumerEmail: input.contact.email,
|
|
142
172
|
budget: true,
|
|
173
|
+
agentJkt: registerCap.agentJkt,
|
|
143
174
|
onApprovalUrl: deps.onApprovalUrl,
|
|
144
|
-
|
|
175
|
+
maxDraws: DEFAULT_MANDATE_MAX_DRAWS,
|
|
145
176
|
...(input.perTransaction !== undefined ? { perTransaction: input.perTransaction } : {}),
|
|
146
177
|
...(input.intent !== undefined ? { intent: input.intent } : {}),
|
|
147
178
|
});
|
|
@@ -150,29 +181,30 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
150
181
|
throw new Error('the approval server issued no mint token — server-side minting requires the ' +
|
|
151
182
|
'verify-web deployment to run real/turnkey auth (not the dev stub). Retry once it does.');
|
|
152
183
|
}
|
|
153
|
-
|
|
184
|
+
if (!Number.isSafeInteger(assurance.validUntil) ||
|
|
185
|
+
assurance.validUntil <= Math.floor(now().getTime() / 1000)) {
|
|
186
|
+
throw new Error('the approval server issued no valid budget expiry');
|
|
187
|
+
}
|
|
188
|
+
const expiresAt = new Date(assurance.validUntil * 1000).toISOString();
|
|
154
189
|
const facts = await createCardMandate({
|
|
190
|
+
agentJkt: registerCap.agentJkt,
|
|
155
191
|
tokenId: credential.tokenId,
|
|
156
192
|
assuranceData: assurance.assuranceData,
|
|
157
193
|
ceilingMinor,
|
|
158
194
|
merchant,
|
|
159
195
|
currencyCode: input.currency,
|
|
160
196
|
expiresAt,
|
|
161
|
-
|
|
162
|
-
|
|
197
|
+
maxDraws: DEFAULT_MANDATE_MAX_DRAWS,
|
|
198
|
+
crossMerchant: true,
|
|
163
199
|
}, {
|
|
164
200
|
createIntent: (i) => serverCreateIntent(input.approvalBaseUrl, mintToken, i),
|
|
165
201
|
ledger,
|
|
166
202
|
approvalBaseUrl: input.approvalBaseUrl,
|
|
167
|
-
mintToken,
|
|
168
203
|
now,
|
|
169
204
|
});
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
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;
|
|
205
|
+
// Seed the one server-authoritative cumulative store keyed by the VGS
|
|
206
|
+
// intent ID. A later draw requires its PoP verdict; the budget token never
|
|
207
|
+
// falls back as payable authority.
|
|
176
208
|
let registerFailed = false;
|
|
177
209
|
if (registerCap && cardMandateRegister) {
|
|
178
210
|
const reg = await cardMandateRegister
|
|
@@ -277,6 +309,53 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
277
309
|
remainingMinor: null,
|
|
278
310
|
};
|
|
279
311
|
}
|
|
312
|
+
// The reviewId selects the prepared browser session, but the pay call also
|
|
313
|
+
// repeats the target facts. Refuse if any repeated fact disagrees so the
|
|
314
|
+
// caller cannot submit one reviewed checkout while labeling the result or
|
|
315
|
+
// telemetry as another. Never echo the full URLs: payment links can carry
|
|
316
|
+
// claimable secrets in their path/query.
|
|
317
|
+
let payUrl;
|
|
318
|
+
let reviewedUrl;
|
|
319
|
+
try {
|
|
320
|
+
payUrl = new URL(input.url).toString();
|
|
321
|
+
reviewedUrl = new URL(session.target.merchantUrl).toString();
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
await closeSession(input.reviewId);
|
|
325
|
+
return {
|
|
326
|
+
outcome: 'failed',
|
|
327
|
+
confirmationRef: null,
|
|
328
|
+
receiptPath: null,
|
|
329
|
+
detail: 'pay merchant URL is invalid or does not match the reviewed checkout — start a fresh review',
|
|
330
|
+
vicConfirmation: null,
|
|
331
|
+
source: null,
|
|
332
|
+
remainingMinor: null,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
if (payUrl !== reviewedUrl) {
|
|
336
|
+
await closeSession(input.reviewId);
|
|
337
|
+
return {
|
|
338
|
+
outcome: 'failed',
|
|
339
|
+
confirmationRef: null,
|
|
340
|
+
receiptPath: null,
|
|
341
|
+
detail: 'pay merchant URL does not match the reviewed checkout — start a fresh review',
|
|
342
|
+
vicConfirmation: null,
|
|
343
|
+
source: null,
|
|
344
|
+
remainingMinor: null,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
if (input.currency.toUpperCase() !== session.currency.toUpperCase()) {
|
|
348
|
+
await closeSession(input.reviewId);
|
|
349
|
+
return {
|
|
350
|
+
outcome: 'failed',
|
|
351
|
+
confirmationRef: null,
|
|
352
|
+
receiptPath: null,
|
|
353
|
+
detail: `pay currency ${JSON.stringify(input.currency)} does not match the reviewed currency (${session.currency}) — start a fresh review`,
|
|
354
|
+
vicConfirmation: null,
|
|
355
|
+
source: null,
|
|
356
|
+
remainingMinor: null,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
280
359
|
// Amount-bind the confirmation: the pay-call amount must match the
|
|
281
360
|
// reviewed amount. The reviewId already locks the immutable mandate, but
|
|
282
361
|
// re-checking here makes the confirmation explicitly amount-bound so a
|
|
@@ -315,8 +394,8 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
315
394
|
const host = new URL(session.target.merchantUrl).hostname;
|
|
316
395
|
// Does an ACTIVE card mandate already cover this exact purchase? If so,
|
|
317
396
|
// draw against it TAP-FREE (no hosted passkey). Else fall back to today's
|
|
318
|
-
// 1:1 fresh-tap flow.
|
|
319
|
-
//
|
|
397
|
+
// 1:1 fresh-tap flow. Mandates no longer persist the bootstrap token:
|
|
398
|
+
// it is consumed by intent creation + register and has no draw power.
|
|
320
399
|
const covering = await ledger.findCovering({
|
|
321
400
|
merchantHost: host,
|
|
322
401
|
currencyCode: session.currency,
|
|
@@ -325,94 +404,50 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
325
404
|
});
|
|
326
405
|
let source;
|
|
327
406
|
let confirmBase;
|
|
328
|
-
|
|
407
|
+
// Fresh purchases confirm with their one-purchase mint token. Mandate
|
|
408
|
+
// draws set this only after the local reserve succeeds and auth returns
|
|
409
|
+
// the exact per-draw verdict used to mint the credential.
|
|
410
|
+
let confirmationAuthority = null;
|
|
329
411
|
let drawnRemaining = null;
|
|
330
412
|
let instrument;
|
|
331
|
-
if (covering
|
|
413
|
+
if (covering) {
|
|
332
414
|
// --- Tap-free mandate draw ------------------------------------------
|
|
333
415
|
source = 'mandate';
|
|
334
416
|
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
417
|
const mandateId = covering.mandateId;
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
// binding (or seam not injected) → `drawToken` stays the bearer token:
|
|
344
|
-
// today's #5917 draw, byte-for-byte unchanged (converge-not-break).
|
|
418
|
+
// A mandate cryptogram is authorized only by a PoP-signed verdict.
|
|
419
|
+
// The budget mint token is bootstrap-only and cannot bypass the
|
|
420
|
+
// server reserve/commit ledger when local draw authority is absent.
|
|
345
421
|
//
|
|
346
422
|
// #5928 CRITICAL-2: the CLI does NOT settle the reservation. verify-web
|
|
347
423
|
// (which knows whether the cryptogram was payable) commits it on a
|
|
348
424
|
// payable mint and releases it on a decline, server-authoritatively, so
|
|
349
425
|
// the drawer can never release after a payable mint to dodge the ceiling.
|
|
350
|
-
|
|
351
|
-
const capability =
|
|
352
|
-
if (capability
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
}
|
|
426
|
+
const verdictSeam = cardDrawVerdict;
|
|
427
|
+
const capability = verdictSeam?.loadCapability(covering.agentJkt) ?? null;
|
|
428
|
+
if (!capability || !verdictSeam) {
|
|
429
|
+
return {
|
|
430
|
+
outcome: 'failed',
|
|
431
|
+
confirmationRef: null,
|
|
432
|
+
receiptPath: null,
|
|
433
|
+
detail: 'this mandate cannot draw because its delegated card authority is unavailable; ' +
|
|
434
|
+
'restore the runtime binding or use a fresh per-purchase passkey approval',
|
|
435
|
+
vicConfirmation: null,
|
|
436
|
+
source,
|
|
437
|
+
remainingMinor: null,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
if (covering.agentJkt && capability.agentJkt !== covering.agentJkt) {
|
|
441
|
+
return {
|
|
442
|
+
outcome: 'failed',
|
|
443
|
+
confirmationRef: null,
|
|
444
|
+
receiptPath: null,
|
|
445
|
+
detail: 'this budget belongs to a different request key than the selected card capability; ' +
|
|
446
|
+
'restore that exact runtime key or use a fresh per-purchase approval',
|
|
447
|
+
vicConfirmation: null,
|
|
448
|
+
source,
|
|
449
|
+
remainingMinor: null,
|
|
450
|
+
};
|
|
416
451
|
}
|
|
417
452
|
// VgsLiveInstrument mints against an EXISTING intent (the mandate) with
|
|
418
453
|
// no fresh assurance — exactly the draw semantics. The fetchCredential
|
|
@@ -435,7 +470,30 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
435
470
|
amountMinor: session.amountMinor,
|
|
436
471
|
transaction: { ...transaction, transactionCurrencyCode: session.currency },
|
|
437
472
|
}, {
|
|
438
|
-
|
|
473
|
+
// Ordering matters: reserve the local ledger FIRST, then ask
|
|
474
|
+
// auth to reserve the server-authoritative budget immediately
|
|
475
|
+
// before the payable mint. A local reserve race/expiry/file
|
|
476
|
+
// failure therefore makes zero auth/verdict calls and cannot
|
|
477
|
+
// strand a server reservation until its sweep.
|
|
478
|
+
fetchCryptogram: async (i) => {
|
|
479
|
+
const { verdict } = await verdictSeam.fetchVerdict({
|
|
480
|
+
authBaseUrl: capability.authBaseUrl,
|
|
481
|
+
agentKey: capability.agentKey,
|
|
482
|
+
mandateId,
|
|
483
|
+
// One draw per review; the reviewId is auth's idempotency key.
|
|
484
|
+
drawId: input.reviewId,
|
|
485
|
+
draw: {
|
|
486
|
+
tokenId: covering.tokenId,
|
|
487
|
+
amount: session.target.transactionAmount,
|
|
488
|
+
currency: session.currency,
|
|
489
|
+
merchantName: session.target.merchantName,
|
|
490
|
+
merchantUrl: session.target.merchantUrl,
|
|
491
|
+
merchantCountryCode: session.target.merchantCountryCode,
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
confirmationAuthority = verdict;
|
|
495
|
+
return fetchMandateCryptogram(confirmBase, verdict, i);
|
|
496
|
+
},
|
|
439
497
|
ledger,
|
|
440
498
|
now,
|
|
441
499
|
});
|
|
@@ -463,9 +521,19 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
463
521
|
// Classify on the underlying gateway CAUSE, not the MandateDrawDeclinedError
|
|
464
522
|
// wrapper — the wrapper's own advisory text mentions "try again"/"network",
|
|
465
523
|
// which would otherwise self-classify every decline as transient.
|
|
466
|
-
if (err instanceof MandateDrawDeclinedError
|
|
467
|
-
|
|
468
|
-
|
|
524
|
+
if (err instanceof MandateDrawDeclinedError) {
|
|
525
|
+
const verdictFailure = classifyCardDrawVerdictFailure(err);
|
|
526
|
+
if (verdictFailure) {
|
|
527
|
+
if (!verdictFailure.transient) {
|
|
528
|
+
await ledger.markUnhonored(mandateId, now()).catch(() => { });
|
|
529
|
+
}
|
|
530
|
+
throw new Error(verdictFailure.transient
|
|
531
|
+
? `the card-mandate draw could not be authorized right now (${verdictFailure.reasons.join(', ') || 'temporary error'}) — retry shortly`
|
|
532
|
+
: `this card mandate can no longer be drawn (${verdictFailure.reasons.join(', ') || 'refused'}); it has been disabled — retry the checkout to use a fresh per-purchase passkey tap`, { cause: err });
|
|
533
|
+
}
|
|
534
|
+
if (!isTransientDrawFailure(err.cause ?? err)) {
|
|
535
|
+
await ledger.markUnhonored(mandateId, now());
|
|
536
|
+
}
|
|
469
537
|
}
|
|
470
538
|
// #5928 CRITICAL-2: the server-side reservation is released by
|
|
471
539
|
// verify-web (it saw the mint fail), not here — the CLI never settles.
|
|
@@ -507,11 +575,11 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
507
575
|
};
|
|
508
576
|
}
|
|
509
577
|
confirmBase = input.approvalBaseUrl;
|
|
510
|
-
|
|
578
|
+
confirmationAuthority = mintToken;
|
|
511
579
|
instrument = new VgsAssuranceInstrument(credential, assurance, session.target, session.contact.fullName ?? '', async (mintInput) => {
|
|
512
|
-
const { intentId, status } = await serverCreateIntent(confirmBase,
|
|
580
|
+
const { intentId, status } = await serverCreateIntent(confirmBase, mintToken, mintInput);
|
|
513
581
|
try {
|
|
514
|
-
const payment = await serverFetchCryptogram(confirmBase,
|
|
582
|
+
const payment = await serverFetchCryptogram(confirmBase, mintToken, {
|
|
515
583
|
tokenId: mintInput.tokenId,
|
|
516
584
|
intentId,
|
|
517
585
|
transaction: mintInput.transaction,
|
|
@@ -542,7 +610,12 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
542
610
|
transactionAmount: session.target.transactionAmount,
|
|
543
611
|
transactionCurrencyCode: session.currency,
|
|
544
612
|
},
|
|
545
|
-
post: (confInput) =>
|
|
613
|
+
post: (confInput) => {
|
|
614
|
+
if (!confirmationAuthority) {
|
|
615
|
+
throw new Error('confirmation authority missing for the consumed VIC intent');
|
|
616
|
+
}
|
|
617
|
+
return postServerConfirmation(confirmBase, confirmationAuthority, confInput);
|
|
618
|
+
},
|
|
546
619
|
});
|
|
547
620
|
}
|
|
548
621
|
let receiptPath = null;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type EvidenceStepType = 'navigation' | 'dom-stable' | 'review' | 'approval' | 'adapter-selected' | 'reveal' | 'detect' | 'field-fill' | 'amount-fill' | 'credential-minted' | 'fill-complete' | 'psp-detected' | 'mandate-verdict' | 'submit' | 'challenge-hold' | 'outcome' | 'note';
|
|
1
|
+
export type EvidenceStepType = 'navigation' | 'dom-stable' | 'review' | 'approval' | 'adapter-selected' | 'reveal' | 'detect' | 'field-fill' | 'amount-fill' | 'credential-minted' | 'credential-skipped' | 'fill-complete' | 'psp-detected' | 'mandate-verdict' | 'submit' | 'challenge-hold' | 'outcome' | 'note';
|
|
2
2
|
export type EvidenceStep = {
|
|
3
3
|
ts: string;
|
|
4
4
|
type: EvidenceStepType;
|
|
@@ -6,7 +6,9 @@ import type { Contact, OtpResolver } from './types.js';
|
|
|
6
6
|
import { EvidenceLog } from './evidence.js';
|
|
7
7
|
import { type ObservedOutcome } from './outcome.js';
|
|
8
8
|
export type CheckoutMode = 'dry-run' | 'submit';
|
|
9
|
-
export type CheckoutOutcome = '
|
|
9
|
+
export type CheckoutOutcome = 'reviewed-dry-run'
|
|
10
|
+
/** Historical receipt value from the credential-disclosing dry-run. */
|
|
11
|
+
| 'filled-dry-run' | 'partial-fill' | 'adapter-required' | 'confirmed' | 'declined' | 'action-required' | 'cancelled' | 'blocked-by-mandate' | 'failed';
|
|
10
12
|
export type CredentialLifecycle = 'not-requested' | 'minted-not-exposed' | 'partially-exposed' | 'fully-filled';
|
|
11
13
|
export type CredentialTiming = {
|
|
12
14
|
approvedAt?: string;
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// prepareCheckout(): navigate -> stabilize -> mandate gate -> resolve the
|
|
3
3
|
// review facts. No credential is requested or filled in this phase.
|
|
4
4
|
// submitApprovedCheckout(): verify the approval is bound to that review ->
|
|
5
|
-
// revalidate ->
|
|
5
|
+
// revalidate -> in dry-run stop without requesting a credential; in submit
|
|
6
|
+
// mode mint -> fill -> revalidate -> submit.
|
|
6
7
|
//
|
|
7
8
|
// runCheckout() remains the one-shot, auto-approved compatibility wrapper.
|
|
8
9
|
//
|
|
@@ -11,8 +12,8 @@
|
|
|
11
12
|
// runs before instrument.getCredential(). No credential is minted and no
|
|
12
13
|
// field is filled on a page the mandate does not cover.
|
|
13
14
|
// - The PRE-SUBMIT gate re-runs the full check with the resolved amount and
|
|
14
|
-
// currency. No submit ever happens without it passing. Dry-run
|
|
15
|
-
// clicks submit.
|
|
15
|
+
// currency. No submit ever happens without it passing. Dry-run requests no
|
|
16
|
+
// credential and neither fills fields nor clicks submit.
|
|
16
17
|
import { randomUUID } from 'node:crypto';
|
|
17
18
|
import { mkdir } from 'node:fs/promises';
|
|
18
19
|
import { join } from 'node:path';
|
|
@@ -967,6 +968,16 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
967
968
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, reason);
|
|
968
969
|
}
|
|
969
970
|
evidence.step('approval', { approved: true, reviewId: checkout.review.id });
|
|
971
|
+
if (opts.mode === 'dry-run') {
|
|
972
|
+
evidence.step('credential-skipped', {
|
|
973
|
+
reason: 'dry-run stops before credential mint or merchant-page disclosure',
|
|
974
|
+
});
|
|
975
|
+
evidence.step('submit', { would: true, target: approvalSubmit?.desc ?? 'none found' });
|
|
976
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
977
|
+
return makeResult('reviewed-dry-run', state.fields, evidence, requiresAdapter, approvalSubmit
|
|
978
|
+
? `validated the reviewed checkout; would click ${approvalSubmit.desc}`
|
|
979
|
+
: 'validated the reviewed checkout; no submit control detected');
|
|
980
|
+
}
|
|
970
981
|
const credential = await opts.instrument.getCredential({
|
|
971
982
|
merchantHost,
|
|
972
983
|
amountMinor: approvedFacts.amountMinor,
|
|
@@ -1099,17 +1110,6 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1099
1110
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1100
1111
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitChangedBeforeClick);
|
|
1101
1112
|
}
|
|
1102
|
-
if (opts.mode === 'dry-run') {
|
|
1103
|
-
evidence.step('submit', { would: true, target: submit?.desc ?? 'none found' });
|
|
1104
|
-
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1105
|
-
if (missingCredentialRoles.length > 0) {
|
|
1106
|
-
const adapterRequired = requiresAdapter.size > 0;
|
|
1107
|
-
return makeResult(adapterRequired ? 'adapter-required' : 'partial-fill', state.fields, evidence, requiresAdapter, adapterRequired
|
|
1108
|
-
? `credential fields require adapter: ${[...requiresAdapter].join(', ')}; missing ${missingCredentialRoles.join(', ')}`
|
|
1109
|
-
: `credential fill incomplete: missing ${missingCredentialRoles.join(', ')}`);
|
|
1110
|
-
}
|
|
1111
|
-
return makeResult('filled-dry-run', state.fields, evidence, requiresAdapter, submit ? `would click ${submit.desc}` : 'no submit control detected');
|
|
1112
|
-
}
|
|
1113
1113
|
if (missingCredentialRoles.length > 0) {
|
|
1114
1114
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1115
1115
|
return makeResult('failed', state.fields, evidence, requiresAdapter, `credential fill incomplete: missing ${missingCredentialRoles.join(', ')}`);
|
|
@@ -39,6 +39,12 @@ export type HostedApprovalOptions = {
|
|
|
39
39
|
* accepts many sub-ceiling draws tap-free. Omit for a single-purchase approval.
|
|
40
40
|
*/
|
|
41
41
|
budget?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Exact current request-key thumbprint receiving a budget. Required for a
|
|
44
|
+
* budget and forbidden for a one-purchase approval; the authenticated page
|
|
45
|
+
* resolves it to the owner's stable agent before displaying or signing.
|
|
46
|
+
*/
|
|
47
|
+
agentJkt?: string;
|
|
42
48
|
/** Advisory max draws the ceiling intent may fulfil — carried onto the token. */
|
|
43
49
|
maxDraws?: number;
|
|
44
50
|
/**
|
|
@@ -132,4 +138,5 @@ export declare function assertApprovalBaseUrl(value: string): string;
|
|
|
132
138
|
*/
|
|
133
139
|
export declare function runHostedApproval(opts: HostedApprovalOptions): Promise<PurchaseAssurance & {
|
|
134
140
|
mintToken?: string;
|
|
141
|
+
validUntil?: number;
|
|
135
142
|
}>;
|