@visa/cli 4.1.0-rc.21 → 4.1.0-rc.210

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.
Files changed (77) hide show
  1. package/README.md +200 -226
  2. package/dist/checkout-engine/adapters/generic.d.ts +69 -0
  3. package/dist/checkout-engine/adapters/generic.js +383 -58
  4. package/dist/checkout-engine/adapters/index.d.ts +4 -1
  5. package/dist/checkout-engine/adapters/index.js +10 -3
  6. package/dist/checkout-engine/adapters/shopify.d.ts +55 -0
  7. package/dist/checkout-engine/adapters/shopify.js +514 -0
  8. package/dist/checkout-engine/amount.d.ts +15 -0
  9. package/dist/checkout-engine/amount.js +72 -0
  10. package/dist/checkout-engine/cli-engine.d.ts +264 -4
  11. package/dist/checkout-engine/cli-engine.js +803 -43
  12. package/dist/checkout-engine/confirmed-merchants.d.ts +31 -0
  13. package/dist/checkout-engine/confirmed-merchants.js +165 -0
  14. package/dist/checkout-engine/detect.d.ts +1 -1
  15. package/dist/checkout-engine/detect.js +26 -0
  16. package/dist/checkout-engine/evidence.d.ts +4 -1
  17. package/dist/checkout-engine/evidence.js +51 -6
  18. package/dist/checkout-engine/executor.d.ts +47 -4
  19. package/dist/checkout-engine/executor.js +418 -131
  20. package/dist/checkout-engine/hosted-approval.d.ts +124 -7
  21. package/dist/checkout-engine/hosted-approval.js +381 -54
  22. package/dist/checkout-engine/index.d.ts +8 -2
  23. package/dist/checkout-engine/index.js +7 -1
  24. package/dist/checkout-engine/instrument.d.ts +7 -0
  25. package/dist/checkout-engine/instrument.js +4 -0
  26. package/dist/checkout-engine/known-merchants.d.ts +10 -0
  27. package/dist/checkout-engine/known-merchants.js +38 -0
  28. package/dist/checkout-engine/live-fill-approval.d.ts +5 -20
  29. package/dist/checkout-engine/live-fill-approval.js +20 -51
  30. package/dist/checkout-engine/mandate/card-mandate.d.ts +121 -0
  31. package/dist/checkout-engine/mandate/card-mandate.js +226 -0
  32. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +174 -0
  33. package/dist/checkout-engine/mandate/mandate-ledger.js +410 -0
  34. package/dist/checkout-engine/outcome.d.ts +2 -2
  35. package/dist/checkout-engine/outcome.js +36 -1
  36. package/dist/checkout-engine/owner-only-file.d.ts +9 -0
  37. package/dist/checkout-engine/owner-only-file.js +20 -1
  38. package/dist/checkout-engine/receipt-dir.d.ts +6 -0
  39. package/dist/checkout-engine/receipt-dir.js +8 -0
  40. package/dist/checkout-engine/receipt.d.ts +42 -2
  41. package/dist/checkout-engine/receipt.js +43 -14
  42. package/dist/checkout-engine/trace-handles.d.ts +8 -0
  43. package/dist/checkout-engine/trace-handles.js +12 -0
  44. package/dist/checkout-engine/types.d.ts +28 -2
  45. package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
  46. package/dist/checkout-engine/unresolved-charges.js +125 -0
  47. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +53 -1
  48. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +78 -10
  49. package/dist/checkout-engine/vgs-live-instrument.d.ts +38 -35
  50. package/dist/checkout-engine/vgs-live-instrument.js +51 -74
  51. package/dist/checkout-engine/vic-confirmation.d.ts +18 -0
  52. package/dist/checkout-engine/vic-confirmation.js +9 -3
  53. package/dist/checkout-engine/web-bot-auth.d.ts +92 -0
  54. package/dist/checkout-engine/web-bot-auth.js +159 -0
  55. package/dist/cli.js +772 -496
  56. package/dist/mcp-apps/ucp-checkout.html +280 -0
  57. package/dist/mcp-server/index.js +637 -175
  58. package/dist/skills/pair-visa-agent/RUNTIMES.md +93 -0
  59. package/dist/skills/pair-visa-agent/SKILL.md +479 -221
  60. package/dist/subway-direct.mjs +1 -0
  61. package/install.ps1 +5 -43
  62. package/install.sh +5 -37
  63. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  64. package/package.json +33 -27
  65. package/server.json +4 -4
  66. package/dist/checkout-engine/inline-target.d.ts +0 -13
  67. package/dist/checkout-engine/inline-target.js +0 -37
  68. package/dist/checkout-engine/pay-args.d.ts +0 -14
  69. package/dist/checkout-engine/pay-args.js +0 -44
  70. package/dist/checkout-engine/pay.d.ts +0 -1
  71. package/dist/checkout-engine/pay.js +0 -13
  72. package/dist/checkout-engine/repo-env.d.ts +0 -11
  73. package/dist/checkout-engine/repo-env.js +0 -23
  74. package/dist/checkout-engine/run-live-fill.d.ts +0 -1
  75. package/dist/checkout-engine/run-live-fill.js +0 -443
  76. package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
  77. package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +0 -248
@@ -10,32 +10,192 @@
10
10
  //
11
11
  // Every browser/network primitive is injectable (CliEngineDeps) so the session/
12
12
  // timer/store lifecycle is unit-testable without launching Chromium.
13
- import { homedir } from 'node:os';
14
- import { join } from 'node:path';
15
13
  import { readFile } from 'node:fs/promises';
16
14
  import { launchCheckoutBrowser } from './browser-launch.js';
15
+ import { RECEIPT_DIR } from './receipt-dir.js';
17
16
  import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
18
- import { runHostedApproval as realRunHostedApproval } from './hosted-approval.js';
19
- import { VgsAssuranceInstrument, decimalToMinor, } from './vgs-live-instrument.js';
17
+ import { claimMandatePickup as realClaimMandatePickup, runHostedApproval as realRunHostedApproval, } from './hosted-approval.js';
18
+ import { VgsLiveInstrument, decimalToMinor, minorToDecimal, } from './vgs-live-instrument.js';
20
19
  import { serverCreateIntent, serverFetchCryptogram, serverPostConfirmation, } from './vgs-gateway/server-mint-client.js';
20
+ import { createCardMandate, DEFAULT_MANDATE_MAX_DRAWS, drawFromMandate, MandateDrawDeclinedError, } from './mandate/card-mandate.js';
21
+ import { MandateLedger } from './mandate/mandate-ledger.js';
21
22
  import { buildReceipt, writeReceipt as realWriteReceipt } from './receipt.js';
22
23
  import { reportVicOutcome as realReportVicOutcome, } from './vic-confirmation.js';
23
- const RECEIPT_DIR = join(homedir(), '.visa-mcp', 'checkout-receipts');
24
+ /**
25
+ * A card-mandate draw failed transiently (retryable) rather than definitively.
26
+ * Gateway 5xx, "server cryptogram not completed / try again", and network
27
+ * reset/timeout errors are transient: the mandate stays healthy and must NOT be
28
+ * disabled. Walks the error's cause chain so a wrapped MandateDrawDeclinedError
29
+ * is classified by its underlying gateway error. Exported for tests.
30
+ */
31
+ export function isTransientDrawFailure(err) {
32
+ const msgs = [];
33
+ let e = err;
34
+ for (let i = 0; i < 5 && e; i++) {
35
+ // A refusal that declared itself TERMINAL wins outright, before any message
36
+ // matching. Message text is not a safe carrier for this decision: the mint
37
+ // client interpolates provider-supplied `upstream_codes` into its message
38
+ // for diagnosability, and an identifier like `request_timeout` would match
39
+ // the unanchored `tim(e|ed)-out` alternative below and silently reclassify
40
+ // a permanent 422 as transient — skipping markUnhonored and stranding the
41
+ // mandate in the retry-forever loop this classifier exists to prevent.
42
+ // Duck-typed rather than instanceof so it survives bundling and any
43
+ // re-wrapping across package boundaries. Optional chaining rather than an
44
+ // explicit null guard: the loop condition already proved `e` truthy, so a
45
+ // `e !== null` test is dead code, and `?.` stays safe on a primitive or a
46
+ // nullish link if that guard ever changes.
47
+ if (e?.terminal === true) {
48
+ return false;
49
+ }
50
+ if (e instanceof Error && typeof e.message === 'string')
51
+ msgs.push(e.message);
52
+ e = e.cause;
53
+ }
54
+ // Transient = a gateway 5xx / 429 in the mint client's "(last: <status>: …)"
55
+ // framing, or an unambiguous network reset/timeout error name. The status is
56
+ // ANCHORED to `(last:` so a bare 3-digit token elsewhere (an amount, a ref id,
57
+ // an attempt count) can never be mistaken for a status code, and so the
58
+ // advisory DRAW_REMEDY wrapper text ("gateway 5xx / try again") cannot
59
+ // self-classify a hard decline as transient. A hard decline (4xx / a
60
+ // card-decline reason) matches nothing here → the mandate is correctly disabled.
61
+ 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(' '));
62
+ }
63
+ /**
64
+ * A verdict refusal may be wrapped by drawFromMandate after its local
65
+ * reservation is released. Walk the cause chain so the original auth status +
66
+ * reasons still decide whether the mandate is permanently disabled.
67
+ */
68
+ // Exported for the cross-package pin in test/cli-engine.test.ts: the CLI's
69
+ // card-draw client decides the `status` this reads, so the two move together
70
+ // and a test that spans the boundary is the only one that would catch a drift.
71
+ // Not re-exported from index.ts — the public @visa/checkout-engine surface is
72
+ // the explicit allowlist there, same as isTransientDrawFailure above.
73
+ export function classifyCardDrawVerdictFailure(err) {
74
+ const transientReasons = new Set(['challenge_failed', 'challenge_malformed', 'verdict_refused']);
75
+ let current = err;
76
+ for (let i = 0; i < 5 && current; i++) {
77
+ const candidate = current;
78
+ const status = typeof candidate.status === 'number' ? candidate.status : 0;
79
+ const reasons = Array.isArray(candidate.reasons)
80
+ ? candidate.reasons.filter((reason) => typeof reason === 'string')
81
+ : [];
82
+ if (status !== 0 || reasons.length > 0) {
83
+ return {
84
+ transient: status === 503 ||
85
+ reasons.length === 0 ||
86
+ reasons.every((reason) => transientReasons.has(reason)),
87
+ reasons,
88
+ };
89
+ }
90
+ current = candidate.cause;
91
+ }
92
+ return null;
93
+ }
94
+ /**
95
+ * Resolve the card instrument for one flow. Fail-closed: an unusable legacy file
96
+ * with no grant token rethrows (never silently proceeds), and the ONLY thing the
97
+ * fallback contributes is a token id — a non-secret handle the server
98
+ * re-authorizes against the owner (`requireTokenOwnership`) and against the live
99
+ * grant on every draw. Nothing here authorizes anything.
100
+ */
101
+ async function resolveCardInstrument(input) {
102
+ if (typeof input.cardTokenId === 'string' && input.cardTokenId.trim()) {
103
+ return { tokenId: input.cardTokenId.trim(), source: 'card-grant' };
104
+ }
105
+ let credential = null;
106
+ let readError = null;
107
+ try {
108
+ credential = JSON.parse(await readFile(input.credentialPath, 'utf8'));
109
+ }
110
+ catch (err) {
111
+ readError = err;
112
+ }
113
+ if (credential && typeof credential.tokenId === 'string' && credential.tokenId.trim()) {
114
+ return credential;
115
+ }
116
+ throw new Error('no card instrument is available to this runtime: there is no usable credential at ' +
117
+ `${input.credentialPath} and no activated card:vic grant token was supplied. Run ` +
118
+ '`visa agent grant-card <agent-id> --ceiling <usd> --per-transaction <usd> --wait` to ' +
119
+ 'attach one, or use a pre-provisioned VIC runtime.', readError instanceof Error ? { cause: readError } : undefined);
120
+ }
121
+ /**
122
+ * A checkout was inspected successfully but cannot be reviewed safely.
123
+ *
124
+ * The explicit fields survive the CLI's copied-engine boundary structurally,
125
+ * so MCP callers do not have to parse the human-readable message.
126
+ */
127
+ export class CheckoutReviewRefusedError extends Error {
128
+ code = 'CHECKOUT_REVIEW_REFUSED';
129
+ checkoutOutcome;
130
+ failureCode;
131
+ requiresAdapter;
132
+ detectedRoles;
133
+ detail;
134
+ constructor(result) {
135
+ super(result.detail
136
+ ? `review refused: ${result.outcome} — ${result.detail}`
137
+ : `review refused: ${result.outcome}`);
138
+ this.name = 'CheckoutReviewRefusedError';
139
+ this.checkoutOutcome = result.outcome;
140
+ this.failureCode = result.failureCode;
141
+ this.requiresAdapter = [...result.requiresAdapter];
142
+ this.detectedRoles = Object.keys(result.fields);
143
+ this.detail = result.detail;
144
+ }
145
+ }
146
+ function payAttemptFingerprint(input) {
147
+ return JSON.stringify([
148
+ input.url,
149
+ input.amount,
150
+ input.currency,
151
+ input.credentialPath,
152
+ input.cardTokenId ?? null,
153
+ input.agentJkt ?? null,
154
+ input.contact,
155
+ input.approvalBaseUrl,
156
+ input.merchantName ?? null,
157
+ input.merchantCountryCode ?? null,
158
+ input.submit,
159
+ ]);
160
+ }
161
+ function failedPay(detail) {
162
+ return {
163
+ outcome: 'failed',
164
+ confirmationRef: null,
165
+ receiptPath: null,
166
+ detail,
167
+ vicConfirmation: null,
168
+ source: null,
169
+ remainingMinor: null,
170
+ credentialIssued: false,
171
+ credentialDisclosed: false,
172
+ };
173
+ }
24
174
  // Must match the prepared-checkout store TTL so a session and its store entry
25
175
  // expire together — an abandoned review can't leak the browser + state.
26
176
  const PREPARED_TTL_MS = 5 * 60 * 1000;
27
177
  const defaultStore = new InMemoryPreparedCheckoutStore({ ttlMs: PREPARED_TTL_MS });
28
178
  const defaultSessions = new Map();
179
+ const defaultPayAttempts = new Map();
180
+ const defaultLedger = new MandateLedger();
29
181
  export function createCliCheckoutEngine(deps = {}) {
30
182
  const store = deps.store ?? defaultStore;
31
183
  const sessions = deps.sessions ?? defaultSessions;
184
+ const payAttempts = deps.payAttempts ?? defaultPayAttempts;
32
185
  const ttlMs = deps.ttlMs ?? PREPARED_TTL_MS;
33
186
  const launchBrowser = deps.launchBrowser ?? (() => launchCheckoutBrowser());
34
187
  const prepareCheckout = deps.prepareCheckout ?? realPrepareCheckout;
35
188
  const submitApprovedCheckout = deps.submitApprovedCheckout ?? realSubmitApprovedCheckout;
36
189
  const runHostedApproval = deps.runHostedApproval ?? realRunHostedApproval;
190
+ const claimMandatePickup = deps.claimMandatePickup ?? realClaimMandatePickup;
37
191
  const reportVicOutcome = deps.reportVicOutcome ?? realReportVicOutcome;
38
192
  const writeReceipt = deps.writeReceipt ?? realWriteReceipt;
193
+ const ledger = deps.ledger ?? defaultLedger;
194
+ const now = deps.now ?? (() => new Date());
195
+ const fetchMandateCryptogram = deps.serverFetchCryptogram ?? serverFetchCryptogram;
196
+ const postServerConfirmation = deps.serverPostConfirmation ?? serverPostConfirmation;
197
+ const cardDrawVerdict = deps.cardDrawVerdict ?? null;
198
+ const cardMandateRegister = deps.cardMandateRegister ?? null;
39
199
  async function closeSession(reviewId) {
40
200
  const session = sessions.get(reviewId);
41
201
  if (!session)
@@ -53,7 +213,288 @@ export function createCliCheckoutEngine(deps = {}) {
53
213
  transactionCurrencyCode: input.currency,
54
214
  };
55
215
  }
216
+ /**
217
+ * Restate the facts at the ceiling the SERVER approved, when it lowered the
218
+ * one that was requested. Nothing has been spent or reserved on a mandate this
219
+ * new, so its remaining headroom IS its ceiling — see `createCardMandate`,
220
+ * which records `spentMinor: 0` with no reservations.
221
+ *
222
+ * Only ever lowers, mirroring `applyApprovedCeiling`: the server clamps
223
+ * downward, so a higher number means an unexpected response and is ignored
224
+ * rather than reported as headroom no human approved.
225
+ */
226
+ function approvedCeilingFacts(facts, approvedCeilingMinor) {
227
+ if (approvedCeilingMinor === undefined || approvedCeilingMinor >= facts.ceilingMinor)
228
+ return {};
229
+ return { ceilingMinor: approvedCeilingMinor, remainingMinor: approvedCeilingMinor };
230
+ }
231
+ // Seed the one server-authoritative cumulative store keyed by the VGS intent
232
+ // ID (#5942). On failure the local record is marked register-failed so
233
+ // findCovering() SKIPS it — the mandate exists but is never drawn tap-free —
234
+ // and the caller reports the failure honestly. Shared by mandate-start and
235
+ // pickup-claim; both refuse before their ceremony/claim when no capability
236
+ // can register, so registerCap is always present here.
237
+ async function registerMandateOrDisable(args) {
238
+ if (!cardMandateRegister)
239
+ return { registerFailed: true, registerFailureReason: 'no_seam' };
240
+ const reg = await cardMandateRegister
241
+ .register({
242
+ authBaseUrl: args.registerCap.authBaseUrl,
243
+ agentKey: args.registerCap.agentKey,
244
+ ...(args.registerCap.runtimeCertificate
245
+ ? { runtimeCertificate: args.registerCap.runtimeCertificate }
246
+ : {}),
247
+ mandateId: args.mandateId,
248
+ mintToken: args.mintToken,
249
+ ceiling: args.ceiling,
250
+ currency: args.currency,
251
+ })
252
+ .catch((err) => ({
253
+ ok: false,
254
+ reason: err instanceof Error ? err.message : String(err),
255
+ }));
256
+ if (reg.ok) {
257
+ // ONE SPENDING LIMIT: auth just clamped the requested ceiling to the
258
+ // owner's live grant cap and told us what it committed. Adopt it, so the
259
+ // local record — which findCovering() selects on and `mandate list`
260
+ // prints — states the budget the owner actually approved. Best-effort:
261
+ // the mandate is registered and drawable either way, and auth's verdict
262
+ // remains the enforcing cap, so a failed local write must not abort the
263
+ // ceremony. It leaves the record overstating headroom, which is exactly
264
+ // the pre-existing behaviour.
265
+ //
266
+ // If that write fails the returned facts still carry the approved figure —
267
+ // telling the human the truth beats echoing a ceiling their grant refused,
268
+ // and the ledger is left exactly as overstated as it was before this
269
+ // existed. But the two then disagree, so say so out loud rather than let
270
+ // `mandate list` quietly contradict what `mandate start` just printed.
271
+ // Same posture as the mark-failed escalation below.
272
+ if (reg.approvedCeilingMinor !== undefined) {
273
+ const applied = await ledger
274
+ .applyApprovedCeiling(args.mandateId, reg.approvedCeilingMinor)
275
+ .then(() => true)
276
+ .catch(() => false);
277
+ if (!applied) {
278
+ // Rendered through minorToDecimal, never raw /100 — and only when the
279
+ // value is a sane integer, because THIS branch is also where an
280
+ // unusable value lands (applyApprovedCeiling rejects it). A warning
281
+ // must not throw on its way out. The currency is omitted deliberately:
282
+ // both mandate paths refuse anything but USD long before register, so
283
+ // it is known, and passing it would let a non-2-decimal code throw here.
284
+ const approved = Number.isSafeInteger(reg.approvedCeilingMinor) && reg.approvedCeilingMinor > 0
285
+ ? ` (${args.currency} ${minorToDecimal(reg.approvedCeilingMinor)})`
286
+ : '';
287
+ process.stderr.write(`warning: your owner's approved limit for this budget${approved} could NOT be saved ` +
288
+ `locally — 'mandate list' will overstate the remaining balance until you re-run ` +
289
+ `'mandate start'. Spending is still capped at the approved limit; a draw over it ` +
290
+ `is refused. mandateId=${args.mandateId}\n`);
291
+ }
292
+ }
293
+ return {
294
+ registerFailed: false,
295
+ ...(reg.approvedCeilingMinor !== undefined
296
+ ? { approvedCeilingMinor: reg.approvedCeilingMinor }
297
+ : {}),
298
+ };
299
+ }
300
+ // Register failed: the server `card_mandate_spend` row was never created, so
301
+ // a delegated draw against this mandate would 404 `no_mandate`. Mark it
302
+ // register-failed so findCovering() SKIPS it rather than silently selecting
303
+ // a mandate that can't be drawn. A v4 grant then refuses until a usable
304
+ // mandate exists; only a legacy credential-file runtime can still use the
305
+ // old per-purchase approval. Marking is best-effort too.
306
+ const marked = await ledger
307
+ .markRegisterFailed(args.mandateId, now())
308
+ .then(() => true)
309
+ .catch(() => false);
310
+ process.stderr.write(marked
311
+ ? `warning: card-mandate register failed (${reg.reason ?? 'unknown'}) — this mandate ` +
312
+ `will NOT be used for draws. A v4 card grant cannot spend until a usable mandate ` +
313
+ `is registered. Re-run 'mandate start' to try again.\n`
314
+ : // Escalate: the mark write ALSO failed, so the mandate is persisted but
315
+ // NOT disabled — findCovering could still select an undrawable mandate.
316
+ // Tell the owner loudly not to rely on it and how to recover.
317
+ `warning: card-mandate register failed (${reg.reason ?? 'unknown'}) AND the mandate ` +
318
+ `could NOT be disabled locally — do not rely on it. Run 'mandate list' and re-run ` +
319
+ `'mandate start'. mandateId=${args.mandateId}\n`);
320
+ return {
321
+ registerFailed: true,
322
+ ...(reg.reason !== undefined ? { registerFailureReason: reg.reason } : {}),
323
+ };
324
+ }
56
325
  return {
326
+ // BUDGET step: one passkey approves a CEILING; a VGS intent is minted with
327
+ // that ceiling as its decline threshold and the owner-only ledger records
328
+ // the cumulative budget. No browser checkout is prepared — this is purely
329
+ // the passkey ceremony + intent, so later pay() draws need no fresh tap.
330
+ async startCardMandate(input) {
331
+ const ceilingMinor = decimalToMinor(input.ceiling);
332
+ if (ceilingMinor === null || ceilingMinor <= 0) {
333
+ throw new Error(`invalid mandate ceiling ${JSON.stringify(input.ceiling)}`);
334
+ }
335
+ if (input.currency.toUpperCase() !== 'USD') {
336
+ throw new Error('card spend budgets currently support USD only');
337
+ }
338
+ if (input.perTransaction !== undefined) {
339
+ const perTxMinor = decimalToMinor(input.perTransaction);
340
+ if (perTxMinor === null || perTxMinor <= 0 || perTxMinor > ceilingMinor) {
341
+ throw new Error(`invalid mandate perTransaction ${JSON.stringify(input.perTransaction)} — ` +
342
+ 'must be a positive amount at or under the ceiling');
343
+ }
344
+ }
345
+ // A budget token can bootstrap one VGS intent and its register handshake,
346
+ // but it is never payable draw authority. Refuse before the passkey
347
+ // ceremony unless this runtime can both prove the separately provisioned
348
+ // card capability and register the resulting intent server-side.
349
+ const registerCap = cardMandateRegister?.loadCapability(input.agentRef) ?? null;
350
+ if (!cardMandateRegister || !registerCap) {
351
+ throw new Error('startCardMandate requires separately provisioned card authority in this runtime; ' +
352
+ 'identity pairing alone does not grant a card mandate');
353
+ }
354
+ // There is one budget product: eligible retail merchants under the
355
+ // provider's required Retail/5999 network category, with total,
356
+ // per-purchase, count, and time bounds stated on the approval page. The
357
+ // sentinel is provider metadata, never a user-entered merchant route.
358
+ const merchant = {
359
+ name: 'retail spend budget',
360
+ url: 'https://retail-budget.visa/budget',
361
+ countryCode: 'US',
362
+ };
363
+ // Legacy credential file OR the activated card:vic grant's token — see
364
+ // resolveCardInstrument. A v2-paired runtime only ever has the latter.
365
+ const credential = await resolveCardInstrument(input);
366
+ // The passkey ceremony is scoped to the CEILING + merchant (not one
367
+ // charge) — that scope is the unproven part of the spike.
368
+ const ceilingTarget = {
369
+ merchantName: merchant.name,
370
+ merchantUrl: merchant.url,
371
+ merchantCountryCode: merchant.countryCode,
372
+ transactionAmount: input.ceiling,
373
+ transactionCurrencyCode: input.currency,
374
+ };
375
+ // BUDGET mode: the ceiling target's amount IS the approved ceiling, so the
376
+ // server mints a budget mint token bound to that ceiling — later draws pull
377
+ // sub-ceiling amounts against it tap-free.
378
+ const assurance = await runHostedApproval({
379
+ baseUrl: input.approvalBaseUrl,
380
+ tokenId: credential.tokenId,
381
+ target: ceilingTarget,
382
+ consumerEmail: input.contact.email,
383
+ budget: true,
384
+ agentJkt: registerCap.agentJkt,
385
+ onApprovalUrl: input.onApprovalUrl ?? deps.onApprovalUrl,
386
+ maxDraws: DEFAULT_MANDATE_MAX_DRAWS,
387
+ ...(input.perTransaction !== undefined ? { perTransaction: input.perTransaction } : {}),
388
+ ...(input.intent !== undefined ? { intent: input.intent } : {}),
389
+ });
390
+ const mintToken = assurance.mintToken;
391
+ if (!mintToken) {
392
+ throw new Error('the approval server issued no mint token — server-side minting requires the ' +
393
+ 'verify-web deployment to run real/turnkey auth (not the dev stub). Retry once it does.');
394
+ }
395
+ if (!Number.isSafeInteger(assurance.validUntil) ||
396
+ assurance.validUntil <= Math.floor(now().getTime() / 1000)) {
397
+ throw new Error('the approval server issued no valid budget expiry');
398
+ }
399
+ const expiresAt = new Date(assurance.validUntil * 1000).toISOString();
400
+ const facts = await createCardMandate({
401
+ agentJkt: registerCap.agentJkt,
402
+ tokenId: credential.tokenId,
403
+ assuranceData: assurance.assuranceData,
404
+ ceilingMinor,
405
+ merchant,
406
+ currencyCode: input.currency,
407
+ expiresAt,
408
+ maxDraws: DEFAULT_MANDATE_MAX_DRAWS,
409
+ crossMerchant: true,
410
+ }, {
411
+ createIntent: (i) => serverCreateIntent(input.approvalBaseUrl, mintToken, i),
412
+ ledger,
413
+ approvalBaseUrl: input.approvalBaseUrl,
414
+ now,
415
+ });
416
+ // Seed the one server-authoritative cumulative store keyed by the VGS
417
+ // intent ID. A later draw requires its PoP verdict; the budget token never
418
+ // falls back as payable authority.
419
+ const registered = await registerMandateOrDisable({
420
+ registerCap,
421
+ mandateId: facts.mandateId,
422
+ mintToken,
423
+ ceiling: input.ceiling,
424
+ currency: input.currency,
425
+ });
426
+ return {
427
+ ...facts,
428
+ ...approvedCeilingFacts(facts, registered.approvedCeilingMinor),
429
+ merchantHost: new URL(merchant.url).hostname,
430
+ registerFailed: registered.registerFailed,
431
+ ...(registered.registerFailureReason !== undefined
432
+ ? { registerFailureReason: registered.registerFailureReason }
433
+ : {}),
434
+ };
435
+ },
436
+ // PICKUP leg: the owner already approved the ceiling in the account panel
437
+ // and handed this runtime a single-use pickup code. Claim it, verify it was
438
+ // minted for exactly this runtime's request key + card token, then run the
439
+ // same intent → register → ledger sequence as startCardMandate. No approval
440
+ // page, no passkey, no contact profile — the human side already happened.
441
+ async claimCardMandate(input) {
442
+ const registerCap = cardMandateRegister?.loadCapability(input.agentRef) ?? null;
443
+ if (!cardMandateRegister || !registerCap) {
444
+ throw new Error('claimCardMandate requires separately provisioned card authority in this runtime; ' +
445
+ 'identity pairing alone does not grant a card mandate');
446
+ }
447
+ const credential = await resolveCardInstrument(input);
448
+ const claim = await claimMandatePickup({
449
+ baseUrl: input.approvalBaseUrl,
450
+ pickupCode: input.pickupCode,
451
+ agentJkt: registerCap.agentJkt,
452
+ ...(input.expectedHandoffId ? { expectedHandoffId: input.expectedHandoffId } : {}),
453
+ tokenId: credential.tokenId,
454
+ });
455
+ const ceilingMinor = decimalToMinor(claim.ceiling);
456
+ if (ceilingMinor === null || ceilingMinor <= 0) {
457
+ throw new Error(`the handoff carried an invalid ceiling ${JSON.stringify(claim.ceiling)}`);
458
+ }
459
+ // Same single-product bound as mandate-start (the panel initiate route
460
+ // enforces it too; a divergent store entry must not widen it here).
461
+ if (claim.currency.toUpperCase() !== 'USD') {
462
+ throw new Error('card spend budgets currently support USD only');
463
+ }
464
+ const expiresAt = new Date(claim.validUntil * 1000).toISOString();
465
+ const facts = await createCardMandate({
466
+ agentJkt: registerCap.agentJkt,
467
+ tokenId: credential.tokenId,
468
+ assuranceData: claim.assuranceData,
469
+ ceilingMinor,
470
+ merchant: claim.merchant,
471
+ currencyCode: claim.currency,
472
+ expiresAt,
473
+ maxDraws: claim.maxDraws,
474
+ crossMerchant: true,
475
+ }, {
476
+ createIntent: (i) => serverCreateIntent(input.approvalBaseUrl, claim.mintToken, i),
477
+ ledger,
478
+ approvalBaseUrl: input.approvalBaseUrl,
479
+ now,
480
+ });
481
+ const registered = await registerMandateOrDisable({
482
+ registerCap,
483
+ mandateId: facts.mandateId,
484
+ mintToken: claim.mintToken,
485
+ ceiling: claim.ceiling,
486
+ currency: claim.currency,
487
+ });
488
+ return {
489
+ ...facts,
490
+ ...approvedCeilingFacts(facts, registered.approvedCeilingMinor),
491
+ merchantHost: new URL(claim.merchant.url).hostname,
492
+ registerFailed: registered.registerFailed,
493
+ ...(registered.registerFailureReason !== undefined
494
+ ? { registerFailureReason: registered.registerFailureReason }
495
+ : {}),
496
+ };
497
+ },
57
498
  async review(input) {
58
499
  const amountMinor = decimalToMinor(input.amount);
59
500
  if (amountMinor === null)
@@ -72,10 +513,10 @@ export function createCliCheckoutEngine(deps = {}) {
72
513
  amountMinor,
73
514
  currency: input.currency,
74
515
  browser,
516
+ contact: input.contact,
75
517
  }, store);
76
518
  if (prep.status !== 'ready') {
77
- await browser.close();
78
- throw new Error(`review refused: ${prep.result.outcome} — ${prep.result.detail ?? ''}`);
519
+ throw new CheckoutReviewRefusedError(prep.result);
79
520
  }
80
521
  const r = prep.checkout.review;
81
522
  // Expire the companion session on the SAME schedule as the store entry
@@ -88,6 +529,7 @@ export function createCliCheckoutEngine(deps = {}) {
88
529
  amountMinor,
89
530
  currency: input.currency,
90
531
  contact: input.contact,
532
+ ...(input.agentJkt ? { agentJkt: input.agentJkt } : {}),
91
533
  cleanupTimer,
92
534
  });
93
535
  return {
@@ -104,8 +546,43 @@ export function createCliCheckoutEngine(deps = {}) {
104
546
  throw err;
105
547
  }
106
548
  },
549
+ /**
550
+ * Give up a prepared review without paying it (#7100).
551
+ *
552
+ * The retained browser is what lets a same-process MCP `review` → `pay`
553
+ * draw against the checkout it already inspected. A TERMINAL review-only
554
+ * run has no such second call — that process tells the operator to re-RUN
555
+ * with `--submit`, and the new process cannot reach this one's in-memory
556
+ * session. So the browser sat open for the full TTL, and because a live
557
+ * browser connection keeps the event loop alive, a command that had already
558
+ * succeeded looked hung until Ctrl-C.
559
+ *
560
+ * Idempotent and non-throwing: an unknown or already-released id is a
561
+ * no-op, so it is safe on an error path that may not have prepared anything
562
+ * and safe to call twice.
563
+ */
564
+ async releaseReview(reviewId) {
565
+ await closeSession(reviewId);
566
+ payAttempts.delete(reviewId);
567
+ },
107
568
  async pay(input) {
569
+ const noCredentialFacts = {
570
+ credentialIssued: false,
571
+ credentialDisclosed: false,
572
+ };
573
+ const fingerprint = payAttemptFingerprint(input);
574
+ const priorAttempt = payAttempts.get(input.reviewId);
575
+ if (priorAttempt) {
576
+ return priorAttempt.fingerprint === fingerprint
577
+ ? priorAttempt.promise
578
+ : failedPay(`review ${input.reviewId} is already executing with different payment facts — wait for that exact attempt and inspect its receipt`);
579
+ }
108
580
  const session = sessions.get(input.reviewId);
581
+ // One signal for the whole pay lifecycle. `cardTokenId` is populated only
582
+ // by the resolved v4 card-grant authority; legacy credential-file calls
583
+ // omit it. Keeping the classification here prevents copy and fallback
584
+ // behavior from drifting onto different grant detectors.
585
+ const isV4CardGrant = typeof input.cardTokenId === 'string' && input.cardTokenId.trim() !== '';
109
586
  if (!session) {
110
587
  return {
111
588
  outcome: 'failed',
@@ -113,6 +590,72 @@ export function createCliCheckoutEngine(deps = {}) {
113
590
  receiptPath: null,
114
591
  detail: `no prepared review ${input.reviewId} — call review first (a review does not survive a restart)`,
115
592
  vicConfirmation: null,
593
+ source: null,
594
+ remainingMinor: null,
595
+ ...noCredentialFacts,
596
+ };
597
+ }
598
+ // The reviewId selects the prepared browser session, but the pay call also
599
+ // repeats the target facts. Refuse if any repeated fact disagrees so the
600
+ // caller cannot submit one reviewed checkout while labeling the result or
601
+ // telemetry as another. Never echo the full URLs: payment links can carry
602
+ // claimable secrets in their path/query.
603
+ let payUrl;
604
+ let reviewedUrl;
605
+ try {
606
+ payUrl = new URL(input.url).toString();
607
+ reviewedUrl = new URL(session.target.merchantUrl).toString();
608
+ }
609
+ catch {
610
+ await closeSession(input.reviewId);
611
+ return {
612
+ outcome: 'failed',
613
+ confirmationRef: null,
614
+ receiptPath: null,
615
+ detail: 'pay merchant URL is invalid or does not match the reviewed checkout — start a fresh review',
616
+ vicConfirmation: null,
617
+ source: null,
618
+ remainingMinor: null,
619
+ ...noCredentialFacts,
620
+ };
621
+ }
622
+ if (payUrl !== reviewedUrl) {
623
+ await closeSession(input.reviewId);
624
+ return {
625
+ outcome: 'failed',
626
+ confirmationRef: null,
627
+ receiptPath: null,
628
+ detail: 'pay merchant URL does not match the reviewed checkout — start a fresh review',
629
+ vicConfirmation: null,
630
+ source: null,
631
+ remainingMinor: null,
632
+ ...noCredentialFacts,
633
+ };
634
+ }
635
+ if (input.currency.toUpperCase() !== session.currency.toUpperCase()) {
636
+ await closeSession(input.reviewId);
637
+ return {
638
+ outcome: 'failed',
639
+ confirmationRef: null,
640
+ receiptPath: null,
641
+ detail: `pay currency ${JSON.stringify(input.currency)} does not match the reviewed currency (${session.currency}) — start a fresh review`,
642
+ vicConfirmation: null,
643
+ source: null,
644
+ remainingMinor: null,
645
+ ...noCredentialFacts,
646
+ };
647
+ }
648
+ if (input.agentJkt !== session.agentJkt) {
649
+ await closeSession(input.reviewId);
650
+ return {
651
+ outcome: 'failed',
652
+ confirmationRef: null,
653
+ receiptPath: null,
654
+ detail: 'pay agent authority does not match the reviewed request key — start a fresh review',
655
+ vicConfirmation: null,
656
+ source: null,
657
+ remainingMinor: null,
658
+ ...noCredentialFacts,
116
659
  };
117
660
  }
118
661
  // Amount-bind the confirmation: the pay-call amount must match the
@@ -128,6 +671,9 @@ export function createCliCheckoutEngine(deps = {}) {
128
671
  receiptPath: null,
129
672
  detail: `pay amount ${JSON.stringify(input.amount)} does not match the reviewed amount (${session.amountMinor} minor units) — start a fresh review`,
130
673
  vicConfirmation: null,
674
+ source: null,
675
+ remainingMinor: null,
676
+ ...noCredentialFacts,
131
677
  };
132
678
  }
133
679
  // Reject an expired prepared checkout BEFORE running the hosted passkey
@@ -141,68 +687,254 @@ export function createCliCheckoutEngine(deps = {}) {
141
687
  receiptPath: null,
142
688
  detail: `prepared review ${input.reviewId} expired — start a fresh review`,
143
689
  vicConfirmation: null,
690
+ source: null,
691
+ remainingMinor: null,
692
+ ...noCredentialFacts,
144
693
  };
145
694
  }
695
+ let resolveAttempt;
696
+ let rejectAttempt;
697
+ const sharedResult = new Promise((resolve, reject) => {
698
+ resolveAttempt = resolve;
699
+ rejectAttempt = reject;
700
+ });
701
+ // The first caller still receives the direct execution result below; this
702
+ // retained promise exists for overlapping and bounded late duplicates.
703
+ // Mark its rejection handled even when there is no duplicate consumer.
704
+ void sharedResult.catch(() => undefined);
705
+ payAttempts.set(input.reviewId, { fingerprint, promise: sharedResult });
706
+ let completedAttempt;
707
+ const finishAttempt = (result) => {
708
+ completedAttempt = result;
709
+ resolveAttempt(result);
710
+ return result;
711
+ };
146
712
  clearTimeout(session.cleanupTimer);
147
713
  try {
148
- const credential = JSON.parse(await readFile(input.credentialPath, 'utf8'));
149
- const assurance = await runHostedApproval({
150
- baseUrl: input.approvalBaseUrl,
151
- tokenId: credential.tokenId,
152
- target: session.target,
153
- consumerEmail: session.contact.email,
714
+ // NO instrument read here. A tap-free mandate draw spends `covering
715
+ // .tokenId` (frozen into the mandate at start) and needs neither the
716
+ // legacy credential file nor a grant token, so a grant-only device with
717
+ // an as-yet-unrefreshed token can still draw on a mandate it already
718
+ // holds.
719
+ const host = new URL(session.target.merchantUrl).hostname;
720
+ // Does an ACTIVE card mandate already cover this exact purchase? If so,
721
+ // draw against it TAP-FREE (no hosted passkey). Else refuse with the
722
+ // mandate remedy (#7348). Mandates no longer persist the bootstrap
723
+ // token: it is consumed by intent creation + register and has no draw
724
+ // power.
725
+ const covering = await ledger.findCovering({
726
+ merchantHost: host,
727
+ currencyCode: session.currency,
728
+ amountMinor: session.amountMinor,
729
+ agentJkt: session.agentJkt,
730
+ now: now(),
154
731
  });
155
- // Server-side mint (Phase 1): the approval claim releases a scoped mint
156
- // token; the credential is minted by the verify-web deployment (which
157
- // holds the VGS secret), never on this machine. No token means the
158
- // deployment ran the dev-auth stub refuse loudly rather than reach for
159
- // a client-held secret (there is none anymore).
160
- const approvalBase = input.approvalBaseUrl;
161
- const mintToken = assurance.mintToken;
162
- if (!mintToken) {
163
- return {
732
+ let source;
733
+ let confirmBase;
734
+ // Mandate draws set this only after the local reserve succeeds and auth
735
+ // returns the exact per-draw verdict used to mint the credential.
736
+ let confirmationAuthority = null;
737
+ let drawnRemaining = null;
738
+ let instrument;
739
+ if (covering) {
740
+ // --- Tap-free mandate draw ------------------------------------------
741
+ source = 'mandate';
742
+ confirmBase = covering.approvalBaseUrl;
743
+ const mandateId = covering.mandateId;
744
+ // A mandate cryptogram is authorized only by a PoP-signed verdict.
745
+ // The budget mint token is bootstrap-only and cannot bypass the
746
+ // server reserve/commit ledger when local draw authority is absent.
747
+ //
748
+ // #5928 CRITICAL-2: the CLI does NOT settle the reservation. verify-web
749
+ // (which knows whether the cryptogram was payable) commits it on a
750
+ // payable mint and releases it on a decline, server-authoritatively, so
751
+ // the drawer can never release after a payable mint to dodge the ceiling.
752
+ const verdictSeam = cardDrawVerdict;
753
+ const capability = verdictSeam?.loadCapability(covering.agentJkt) ?? null;
754
+ if (!capability || !verdictSeam) {
755
+ return finishAttempt({
756
+ outcome: 'failed',
757
+ confirmationRef: null,
758
+ receiptPath: null,
759
+ detail: 'this mandate cannot draw because its delegated card authority is unavailable; ' +
760
+ 're-pair this runtime to restore the binding, or start a new mandate for this agent',
761
+ vicConfirmation: null,
762
+ source,
763
+ remainingMinor: null,
764
+ ...noCredentialFacts,
765
+ });
766
+ }
767
+ if (covering.agentJkt && capability.agentJkt !== covering.agentJkt) {
768
+ return finishAttempt({
769
+ outcome: 'failed',
770
+ confirmationRef: null,
771
+ receiptPath: null,
772
+ detail: 'this budget belongs to a different request key than the selected card capability; ' +
773
+ 'restore that exact runtime key, or start a new mandate from the current runtime',
774
+ vicConfirmation: null,
775
+ source,
776
+ remainingMinor: null,
777
+ ...noCredentialFacts,
778
+ });
779
+ }
780
+ // VgsLiveInstrument mints against an EXISTING intent (the mandate) with
781
+ // no fresh assurance — exactly the draw semantics. The fetchCredential
782
+ // seam routes through drawFromMandate so the ledger accounting (reserve
783
+ // -> commit on payable, release on reject) wraps the cryptogram pull.
784
+ const reference = {
785
+ tokenId: covering.tokenId,
786
+ intentId: mandateId,
787
+ merchantName: session.target.merchantName,
788
+ merchantUrl: session.target.merchantUrl,
789
+ merchantCountryCode: session.target.merchantCountryCode,
790
+ transactionAmount: session.target.transactionAmount,
791
+ transactionCurrencyCode: session.currency,
792
+ };
793
+ const fetchCredential = async ({ transaction }) => {
794
+ let draw;
795
+ try {
796
+ draw = await drawFromMandate({
797
+ mandateId,
798
+ amountMinor: session.amountMinor,
799
+ transaction: { ...transaction, transactionCurrencyCode: session.currency },
800
+ }, {
801
+ // Ordering matters: reserve the local ledger FIRST, then ask
802
+ // auth to reserve the server-authoritative budget immediately
803
+ // before the payable mint. A local reserve race/expiry/file
804
+ // failure therefore makes zero auth/verdict calls and cannot
805
+ // strand a server reservation until its sweep.
806
+ fetchCryptogram: async (i) => {
807
+ const { verdict } = await verdictSeam.fetchVerdict({
808
+ authBaseUrl: capability.authBaseUrl,
809
+ agentKey: capability.agentKey,
810
+ ...(capability.runtimeCertificate
811
+ ? { runtimeCertificate: capability.runtimeCertificate }
812
+ : {}),
813
+ mandateId,
814
+ // One draw per review; the reviewId is auth's idempotency key.
815
+ drawId: input.reviewId,
816
+ draw: {
817
+ tokenId: covering.tokenId,
818
+ amount: session.target.transactionAmount,
819
+ currency: session.currency,
820
+ merchantName: session.target.merchantName,
821
+ merchantUrl: session.target.merchantUrl,
822
+ merchantCountryCode: session.target.merchantCountryCode,
823
+ },
824
+ });
825
+ confirmationAuthority = verdict;
826
+ return fetchMandateCryptogram(confirmBase, verdict, i);
827
+ },
828
+ ledger,
829
+ now,
830
+ });
831
+ }
832
+ catch (err) {
833
+ // Disable the mandate ONLY for a post-reservation NETWORK decline
834
+ // (MandateDrawDeclinedError). That case leaves the mandate
835
+ // active+covering, so a naive retry would re-select it and fail
836
+ // identically — trapping the caller; marking it unhonored makes the
837
+ // next pay_merchant skip it and surface the no-covering-mandate refusal
838
+ // (#7348) instead of re-failing identically. We do
839
+ // NOT attempt an unsafe same-call browser fallback mid-submit.
840
+ //
841
+ // A PRE-network failure — reserve() failing closed on a concurrent
842
+ // over-budget race or expiry, or commit() throwing on file I/O —
843
+ // is NOT a network decline: the mandate is HEALTHY, so we must
844
+ // rethrow WITHOUT disabling it (michaelyang1 M1). drawFromMandate
845
+ // has already released any reservation, so the budget is intact.
846
+ // Only a DEFINITIVE (hard) decline disables the mandate. A TRANSIENT
847
+ // failure — a gateway 5xx / "not completed, try again" / network
848
+ // reset/timeout — must NOT permanently kill the budget: the mandate
849
+ // may be perfectly healthy (it can have committed a draw seconds
850
+ // earlier) and the rails may recover on retry. Over-disabling on a
851
+ // transient 502 threw away good budgets and forced a fresh passkey
852
+ // every time.
853
+ // Classify on the underlying gateway CAUSE, not the MandateDrawDeclinedError
854
+ // wrapper — the wrapper's own advisory text mentions "try again"/"network",
855
+ // which would otherwise self-classify every decline as transient.
856
+ if (err instanceof MandateDrawDeclinedError) {
857
+ const verdictFailure = classifyCardDrawVerdictFailure(err);
858
+ if (verdictFailure) {
859
+ if (!verdictFailure.transient) {
860
+ await ledger.markUnhonored(mandateId, now()).catch(() => { });
861
+ }
862
+ throw new Error(verdictFailure.transient
863
+ ? `the card-mandate draw could not be authorized right now (${verdictFailure.reasons.join(', ') || 'temporary error'}) — retry shortly`
864
+ : `this card mandate can no longer be drawn (${verdictFailure.reasons.join(', ') || 'refused'}); it has been disabled — create and claim a new mandate before retrying this checkout`, { cause: err });
865
+ }
866
+ if (!isTransientDrawFailure(err.cause ?? err)) {
867
+ await ledger.markUnhonored(mandateId, now());
868
+ }
869
+ }
870
+ // #5928 CRITICAL-2: the server-side reservation is released by
871
+ // verify-web (it saw the mint fail), not here — the CLI never settles.
872
+ throw err;
873
+ }
874
+ drawnRemaining = draw.remainingMinor;
875
+ // #5928 CRITICAL-2: the reservation is committed by verify-web on the
876
+ // payable mint (server-authoritative); the CLI does not settle.
877
+ return draw.payment;
878
+ };
879
+ instrument = new VgsLiveInstrument(reference, session.contact.fullName ?? '', fetchCredential);
880
+ }
881
+ else {
882
+ // #7348 single-purchase retirement: card spend is mandate-only for
883
+ // EVERY runtime. The legacy 1:1 single-purchase flow here minted a
884
+ // payable credential outside the grant/mandate ledger — invisible to
885
+ // spending controls. The server now refuses non-budget approvals at
886
+ // registration and non-budget mint tokens at verification; this
887
+ // client refusal exists to give the honest remedy up front instead
888
+ // of a mid-flow server error.
889
+ return finishAttempt({
164
890
  outcome: 'failed',
165
891
  confirmationRef: null,
166
892
  receiptPath: null,
167
- detail: 'the approval server issued no mint token — server-side minting requires the ' +
168
- 'verify-web deployment to run real/turnkey auth (not the dev stub). Retry once it does.',
893
+ detail: isV4CardGrant
894
+ ? 'this v4 card grant has no active mandate covering the purchase; run ' +
895
+ '`visa mandate start --ceiling <usd> --per-transaction <usd>` for the selected ' +
896
+ 'agent, approve and claim the mandate, then retry this checkout. No payment ' +
897
+ 'credential was requested and no charge was made.'
898
+ : 'one-off card approvals are retired (#7348): card spending always runs through ' +
899
+ 'an owner-approved mandate now. Attach card authority to this agent ' +
900
+ '(`visa agent grant-card <agent-id> --ceiling <usd> --per-transaction <usd> --wait`), ' +
901
+ 'then start and claim a mandate (`visa mandate start <usd> --per-transaction <usd>`), ' +
902
+ 'and retry the checkout. No payment credential was requested and no charge was made.',
169
903
  vicConfirmation: null,
170
- };
904
+ source: null,
905
+ remainingMinor: null,
906
+ ...noCredentialFacts,
907
+ });
171
908
  }
172
- const instrument = new VgsAssuranceInstrument(credential, assurance, session.target, session.contact.fullName ?? '', async (mintInput) => {
173
- const { intentId, status } = await serverCreateIntent(approvalBase, mintToken, mintInput);
174
- try {
175
- const payment = await serverFetchCryptogram(approvalBase, mintToken, {
176
- tokenId: mintInput.tokenId,
177
- intentId,
178
- transaction: mintInput.transaction,
179
- });
180
- return { payment, intentId };
181
- }
182
- catch (err) {
183
- throw new Error(`${err.message} (intent status at creation: ${status ?? 'unknown'})`);
184
- }
185
- });
186
909
  const mode = input.submit ? 'submit' : 'dry-run';
187
910
  const result = await submitApprovedCheckout(input.reviewId, {
188
911
  approval: { approved: true, reviewId: input.reviewId },
189
912
  instrument,
190
913
  contact: session.contact,
191
914
  mode,
915
+ ...(deps.resolveEmailOtp ? { resolveEmailOtp: deps.resolveEmailOtp } : {}),
192
916
  }, store);
193
917
  // Report the observed submit outcome to VIC for the consumed intent
194
918
  // (APPROVED/DECLINED). Only definitive submit answers post. Mirrors
195
919
  // run-live-fill.ts.
196
920
  let vicConfirmation = null;
921
+ let processorIntentId = null;
197
922
  if (mode === 'submit') {
923
+ const confirmationTarget = instrument.confirmationTarget();
924
+ processorIntentId = confirmationTarget?.intentId ?? null;
198
925
  vicConfirmation = await reportVicOutcome({
199
- target: instrument.confirmationTarget(),
926
+ target: confirmationTarget,
200
927
  outcome: result.outcome,
201
928
  transaction: {
202
929
  transactionAmount: session.target.transactionAmount,
203
930
  transactionCurrencyCode: session.currency,
204
931
  },
205
- post: (confInput) => serverPostConfirmation(approvalBase, mintToken, confInput),
932
+ post: (confInput) => {
933
+ if (!confirmationAuthority) {
934
+ throw new Error('confirmation authority missing for the consumed VIC intent');
935
+ }
936
+ return postServerConfirmation(confirmBase, confirmationAuthority, confInput);
937
+ },
206
938
  });
207
939
  }
208
940
  let receiptPath = null;
@@ -215,6 +947,7 @@ export function createCliCheckoutEngine(deps = {}) {
215
947
  merchant: {
216
948
  name: session.target.merchantName,
217
949
  host: new URL(session.target.merchantUrl).hostname,
950
+ url: session.target.merchantUrl,
218
951
  },
219
952
  transaction: {
220
953
  amount: session.target.transactionAmount,
@@ -223,19 +956,46 @@ export function createCliCheckoutEngine(deps = {}) {
223
956
  },
224
957
  result,
225
958
  vicConfirmation,
959
+ agentName: input.agentName,
960
+ cardLast4: input.cardLast4,
226
961
  }));
227
962
  if (report.written)
228
963
  receiptPath = report.path;
229
- return {
964
+ return finishAttempt({
230
965
  outcome: result.outcome,
231
966
  confirmationRef: result.confirmationRef ?? null,
232
967
  receiptPath,
233
968
  detail: result.detail ?? null,
234
969
  vicConfirmation,
235
- };
970
+ source,
971
+ remainingMinor: drawnRemaining,
972
+ processorIntentId,
973
+ credentialIssued: result.credentialLifecycle !== 'not-requested',
974
+ credentialDisclosed: result.credentialLifecycle === 'partially-exposed' ||
975
+ result.credentialLifecycle === 'fully-filled',
976
+ });
977
+ }
978
+ catch (error) {
979
+ rejectAttempt(error);
980
+ throw error;
236
981
  }
237
982
  finally {
238
983
  await closeSession(input.reviewId);
984
+ if (!completedAttempt ||
985
+ completedAttempt.outcome === 'failed' ||
986
+ completedAttempt.outcome === 'declined') {
987
+ if (payAttempts.get(input.reviewId)?.promise === sharedResult) {
988
+ payAttempts.delete(input.reviewId);
989
+ }
990
+ }
991
+ else {
992
+ const expiry = setTimeout(() => {
993
+ if (payAttempts.get(input.reviewId)?.promise === sharedResult) {
994
+ payAttempts.delete(input.reviewId);
995
+ }
996
+ }, ttlMs);
997
+ expiry.unref?.();
998
+ }
239
999
  }
240
1000
  },
241
1001
  };