@visa/cli 4.1.0-rc.13 → 4.1.0-rc.130

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 (63) hide show
  1. package/README.md +188 -232
  2. package/dist/checkout-engine/adapters/generic.d.ts +4 -0
  3. package/dist/checkout-engine/adapters/generic.js +28 -13
  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 +31 -0
  7. package/dist/checkout-engine/adapters/shopify.js +423 -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 +207 -2
  11. package/dist/checkout-engine/cli-engine.js +677 -27
  12. package/dist/checkout-engine/detect.d.ts +1 -1
  13. package/dist/checkout-engine/detect.js +26 -0
  14. package/dist/checkout-engine/evidence.d.ts +4 -1
  15. package/dist/checkout-engine/evidence.js +51 -6
  16. package/dist/checkout-engine/executor.d.ts +34 -4
  17. package/dist/checkout-engine/executor.js +266 -115
  18. package/dist/checkout-engine/hosted-approval.d.ts +133 -8
  19. package/dist/checkout-engine/hosted-approval.js +400 -49
  20. package/dist/checkout-engine/index.d.ts +4 -1
  21. package/dist/checkout-engine/index.js +3 -0
  22. package/dist/checkout-engine/instrument.d.ts +7 -0
  23. package/dist/checkout-engine/instrument.js +4 -0
  24. package/dist/checkout-engine/live-fill-approval.d.ts +0 -20
  25. package/dist/checkout-engine/live-fill-approval.js +15 -51
  26. package/dist/checkout-engine/mandate/card-mandate.d.ts +121 -0
  27. package/dist/checkout-engine/mandate/card-mandate.js +227 -0
  28. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +165 -0
  29. package/dist/checkout-engine/mandate/mandate-ledger.js +373 -0
  30. package/dist/checkout-engine/outcome.d.ts +2 -2
  31. package/dist/checkout-engine/outcome.js +36 -1
  32. package/dist/checkout-engine/owner-only-file.d.ts +9 -0
  33. package/dist/checkout-engine/owner-only-file.js +20 -1
  34. package/dist/checkout-engine/trace-handles.d.ts +8 -0
  35. package/dist/checkout-engine/trace-handles.js +12 -0
  36. package/dist/checkout-engine/types.d.ts +20 -2
  37. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +82 -0
  38. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +180 -0
  39. package/dist/checkout-engine/vgs-live-instrument.d.ts +38 -0
  40. package/dist/checkout-engine/vgs-live-instrument.js +52 -8
  41. package/dist/checkout-engine/vic-confirmation.js +2 -2
  42. package/dist/cli.js +579 -494
  43. package/dist/mcp-server/index.js +441 -176
  44. package/dist/skills/pair-visa-agent/RUNTIMES.md +92 -0
  45. package/dist/skills/pair-visa-agent/SKILL.md +467 -0
  46. package/dist/skills/pair-visa-agent/scripts/setup.mjs +48 -0
  47. package/install.ps1 +3 -41
  48. package/install.sh +4 -36
  49. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  50. package/package.json +16 -12
  51. package/server.json +3 -3
  52. package/dist/checkout-engine/inline-target.d.ts +0 -13
  53. package/dist/checkout-engine/inline-target.js +0 -37
  54. package/dist/checkout-engine/pay-args.d.ts +0 -14
  55. package/dist/checkout-engine/pay-args.js +0 -44
  56. package/dist/checkout-engine/pay.d.ts +0 -1
  57. package/dist/checkout-engine/pay.js +0 -13
  58. package/dist/checkout-engine/repo-env.d.ts +0 -11
  59. package/dist/checkout-engine/repo-env.js +0 -23
  60. package/dist/checkout-engine/run-live-fill.d.ts +0 -1
  61. package/dist/checkout-engine/run-live-fill.js +0 -443
  62. package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
  63. package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +0 -240
@@ -1,8 +1,8 @@
1
1
  // createCliCheckoutEngine — the review()/pay() adapter consumed by @visa/cli's
2
- // pay_merchant tool. It COMPOSES the same primitives the `pnpm fill:live` runner
3
- // uses (prepareCheckout, runHostedApproval, the createIntent/fetchCryptogram
4
- // mint, submitApprovedCheckout, reportVicOutcome, receipts) into a two-call API,
5
- // WITHOUT touching run-live-fill.ts so the proven CLI flow keeps working.
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
6
  //
7
7
  // A live browser + prepared-checkout session is held in-process between review
8
8
  // and pay, keyed by reviewId, so the submitted checkout is the exact one the
@@ -15,17 +15,97 @@ import { join } from 'node:path';
15
15
  import { readFile } from 'node:fs/promises';
16
16
  import { launchCheckoutBrowser } from './browser-launch.js';
17
17
  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';
20
- import { createIntent, fetchCryptogram, postConfirmation } from './vgs-gateway/fetch-credential.mjs';
18
+ import { claimMandatePickup as realClaimMandatePickup, runHostedApproval as realRunHostedApproval, } from './hosted-approval.js';
19
+ import { VgsAssuranceInstrument, VgsLiveInstrument, decimalToMinor, minorToDecimal, } from './vgs-live-instrument.js';
20
+ import { serverCreateIntent, serverFetchCryptogram, serverPostConfirmation, } from './vgs-gateway/server-mint-client.js';
21
+ import { createCardMandate, DEFAULT_MANDATE_MAX_DRAWS, drawFromMandate, MandateDrawDeclinedError, } from './mandate/card-mandate.js';
22
+ import { MandateLedger } from './mandate/mandate-ledger.js';
21
23
  import { buildReceipt, writeReceipt as realWriteReceipt } from './receipt.js';
22
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
+ /**
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
+ }
75
+ /**
76
+ * Resolve the card instrument for one flow. Fail-closed: an unusable legacy file
77
+ * with no grant token rethrows (never silently proceeds), and the ONLY thing the
78
+ * fallback contributes is a token id — a non-secret handle the server
79
+ * re-authorizes against the owner (`requireTokenOwnership`) and against the live
80
+ * grant on every draw. Nothing here authorizes anything.
81
+ */
82
+ async function resolveCardInstrument(input) {
83
+ let credential = null;
84
+ let readError = null;
85
+ try {
86
+ credential = JSON.parse(await readFile(input.credentialPath, 'utf8'));
87
+ }
88
+ catch (err) {
89
+ readError = err;
90
+ }
91
+ if (credential && typeof credential.tokenId === 'string' && credential.tokenId.trim()) {
92
+ return credential;
93
+ }
94
+ if (typeof input.cardTokenId === 'string' && input.cardTokenId.trim()) {
95
+ return { tokenId: input.cardTokenId, source: 'card-grant' };
96
+ }
97
+ throw new Error('no card instrument is available to this runtime: there is no usable credential at ' +
98
+ `${input.credentialPath} and no activated card:vic grant token was supplied. Run ` +
99
+ '`visa agent grant-card <agent-id> --ceiling <usd> --per-transaction <usd> --wait` to ' +
100
+ 'attach one, or use a pre-provisioned VIC runtime.', readError instanceof Error ? { cause: readError } : undefined);
101
+ }
23
102
  const RECEIPT_DIR = join(homedir(), '.visa-mcp', 'checkout-receipts');
24
103
  // Must match the prepared-checkout store TTL so a session and its store entry
25
104
  // expire together — an abandoned review can't leak the browser + state.
26
105
  const PREPARED_TTL_MS = 5 * 60 * 1000;
27
106
  const defaultStore = new InMemoryPreparedCheckoutStore({ ttlMs: PREPARED_TTL_MS });
28
107
  const defaultSessions = new Map();
108
+ const defaultLedger = new MandateLedger();
29
109
  export function createCliCheckoutEngine(deps = {}) {
30
110
  const store = deps.store ?? defaultStore;
31
111
  const sessions = deps.sessions ?? defaultSessions;
@@ -34,8 +114,15 @@ export function createCliCheckoutEngine(deps = {}) {
34
114
  const prepareCheckout = deps.prepareCheckout ?? realPrepareCheckout;
35
115
  const submitApprovedCheckout = deps.submitApprovedCheckout ?? realSubmitApprovedCheckout;
36
116
  const runHostedApproval = deps.runHostedApproval ?? realRunHostedApproval;
117
+ const claimMandatePickup = deps.claimMandatePickup ?? realClaimMandatePickup;
37
118
  const reportVicOutcome = deps.reportVicOutcome ?? realReportVicOutcome;
38
119
  const writeReceipt = deps.writeReceipt ?? realWriteReceipt;
120
+ const ledger = deps.ledger ?? defaultLedger;
121
+ const now = deps.now ?? (() => new Date());
122
+ const fetchMandateCryptogram = deps.serverFetchCryptogram ?? serverFetchCryptogram;
123
+ const postServerConfirmation = deps.serverPostConfirmation ?? serverPostConfirmation;
124
+ const cardDrawVerdict = deps.cardDrawVerdict ?? null;
125
+ const cardMandateRegister = deps.cardMandateRegister ?? null;
39
126
  async function closeSession(reviewId) {
40
127
  const session = sessions.get(reviewId);
41
128
  if (!session)
@@ -53,7 +140,286 @@ export function createCliCheckoutEngine(deps = {}) {
53
140
  transactionCurrencyCode: input.currency,
54
141
  };
55
142
  }
143
+ /**
144
+ * Restate the facts at the ceiling the SERVER approved, when it lowered the
145
+ * one that was requested. Nothing has been spent or reserved on a mandate this
146
+ * new, so its remaining headroom IS its ceiling — see `createCardMandate`,
147
+ * which records `spentMinor: 0` with no reservations.
148
+ *
149
+ * Only ever lowers, mirroring `applyApprovedCeiling`: the server clamps
150
+ * downward, so a higher number means an unexpected response and is ignored
151
+ * rather than reported as headroom no human approved.
152
+ */
153
+ function approvedCeilingFacts(facts, approvedCeilingMinor) {
154
+ if (approvedCeilingMinor === undefined || approvedCeilingMinor >= facts.ceilingMinor)
155
+ return {};
156
+ return { ceilingMinor: approvedCeilingMinor, remainingMinor: approvedCeilingMinor };
157
+ }
158
+ // Seed the one server-authoritative cumulative store keyed by the VGS intent
159
+ // ID (#5942). On failure the local record is marked register-failed so
160
+ // findCovering() SKIPS it — the mandate exists but is never drawn tap-free —
161
+ // and the caller reports the failure honestly. Shared by mandate-start and
162
+ // pickup-claim; both refuse before their ceremony/claim when no capability
163
+ // can register, so registerCap is always present here.
164
+ async function registerMandateOrDisable(args) {
165
+ if (!cardMandateRegister)
166
+ return { registerFailed: true, registerFailureReason: 'no_seam' };
167
+ const reg = await cardMandateRegister
168
+ .register({
169
+ authBaseUrl: args.registerCap.authBaseUrl,
170
+ agentKey: args.registerCap.agentKey,
171
+ mandateId: args.mandateId,
172
+ mintToken: args.mintToken,
173
+ ceiling: args.ceiling,
174
+ currency: args.currency,
175
+ })
176
+ .catch((err) => ({
177
+ ok: false,
178
+ reason: err instanceof Error ? err.message : String(err),
179
+ }));
180
+ if (reg.ok) {
181
+ // ONE SPENDING LIMIT: auth just clamped the requested ceiling to the
182
+ // owner's live grant cap and told us what it committed. Adopt it, so the
183
+ // local record — which findCovering() selects on and `mandate list`
184
+ // prints — states the budget the owner actually approved. Best-effort:
185
+ // the mandate is registered and drawable either way, and auth's verdict
186
+ // remains the enforcing cap, so a failed local write must not abort the
187
+ // ceremony. It leaves the record overstating headroom, which is exactly
188
+ // the pre-existing behaviour.
189
+ //
190
+ // If that write fails the returned facts still carry the approved figure —
191
+ // telling the human the truth beats echoing a ceiling their grant refused,
192
+ // and the ledger is left exactly as overstated as it was before this
193
+ // existed. But the two then disagree, so say so out loud rather than let
194
+ // `mandate list` quietly contradict what `mandate start` just printed.
195
+ // Same posture as the mark-failed escalation below.
196
+ if (reg.approvedCeilingMinor !== undefined) {
197
+ const applied = await ledger
198
+ .applyApprovedCeiling(args.mandateId, reg.approvedCeilingMinor)
199
+ .then(() => true)
200
+ .catch(() => false);
201
+ if (!applied) {
202
+ // Rendered through minorToDecimal, never raw /100 — and only when the
203
+ // value is a sane integer, because THIS branch is also where an
204
+ // unusable value lands (applyApprovedCeiling rejects it). A warning
205
+ // must not throw on its way out. The currency is omitted deliberately:
206
+ // both mandate paths refuse anything but USD long before register, so
207
+ // it is known, and passing it would let a non-2-decimal code throw here.
208
+ const approved = Number.isSafeInteger(reg.approvedCeilingMinor) && reg.approvedCeilingMinor > 0
209
+ ? ` (${args.currency} ${minorToDecimal(reg.approvedCeilingMinor)})`
210
+ : '';
211
+ process.stderr.write(`warning: your owner's approved limit for this budget${approved} could NOT be saved ` +
212
+ `locally — 'mandate list' will overstate the remaining balance until you re-run ` +
213
+ `'mandate start'. Spending is still capped at the approved limit; a draw over it ` +
214
+ `is refused. mandateId=${args.mandateId}\n`);
215
+ }
216
+ }
217
+ return {
218
+ registerFailed: false,
219
+ ...(reg.approvedCeilingMinor !== undefined
220
+ ? { approvedCeilingMinor: reg.approvedCeilingMinor }
221
+ : {}),
222
+ };
223
+ }
224
+ // Register failed: the server `card_mandate_spend` row was never created, so
225
+ // a delegated draw against this mandate would 404 `no_mandate`. Mark it
226
+ // register-failed so findCovering() SKIPS it and the owner's next checkout
227
+ // falls through to a fresh per-purchase tap, rather than silently selecting
228
+ // a mandate that can't be drawn. Marking is best-effort too — a failure here
229
+ // must not abort the flow.
230
+ const marked = await ledger
231
+ .markRegisterFailed(args.mandateId, now())
232
+ .then(() => true)
233
+ .catch(() => false);
234
+ process.stderr.write(marked
235
+ ? `warning: card-mandate register failed (${reg.reason ?? 'unknown'}) — this mandate ` +
236
+ `will NOT be used for tap-free draws; your next checkout will interrupt the owner ` +
237
+ `for a fresh per-purchase approval. Re-run 'mandate start' to try again.\n`
238
+ : // Escalate: the mark write ALSO failed, so the mandate is persisted but
239
+ // NOT disabled — findCovering could still select an undrawable mandate.
240
+ // Tell the owner loudly not to rely on it and how to recover.
241
+ `warning: card-mandate register failed (${reg.reason ?? 'unknown'}) AND the mandate ` +
242
+ `could NOT be disabled locally — do not rely on it. Run 'mandate list' and re-run ` +
243
+ `'mandate start'. mandateId=${args.mandateId}\n`);
244
+ return {
245
+ registerFailed: true,
246
+ ...(reg.reason !== undefined ? { registerFailureReason: reg.reason } : {}),
247
+ };
248
+ }
56
249
  return {
250
+ // BUDGET step: one passkey approves a CEILING; a VGS intent is minted with
251
+ // that ceiling as its decline threshold and the owner-only ledger records
252
+ // the cumulative budget. No browser checkout is prepared — this is purely
253
+ // the passkey ceremony + intent, so later pay() draws need no fresh tap.
254
+ async startCardMandate(input) {
255
+ const ceilingMinor = decimalToMinor(input.ceiling);
256
+ if (ceilingMinor === null || ceilingMinor <= 0) {
257
+ throw new Error(`invalid mandate ceiling ${JSON.stringify(input.ceiling)}`);
258
+ }
259
+ if (input.currency.toUpperCase() !== 'USD') {
260
+ throw new Error('card spend budgets currently support USD only');
261
+ }
262
+ if (input.perTransaction !== undefined) {
263
+ const perTxMinor = decimalToMinor(input.perTransaction);
264
+ if (perTxMinor === null || perTxMinor <= 0 || perTxMinor > ceilingMinor) {
265
+ throw new Error(`invalid mandate perTransaction ${JSON.stringify(input.perTransaction)} — ` +
266
+ 'must be a positive amount at or under the ceiling');
267
+ }
268
+ }
269
+ // A budget token can bootstrap one VGS intent and its register handshake,
270
+ // but it is never payable draw authority. Refuse before the passkey
271
+ // ceremony unless this runtime can both prove the separately provisioned
272
+ // card capability and register the resulting intent server-side.
273
+ const registerCap = cardMandateRegister?.loadCapability(input.agentRef) ?? null;
274
+ if (!cardMandateRegister || !registerCap) {
275
+ throw new Error('startCardMandate requires separately provisioned card authority in this runtime; ' +
276
+ 'identity pairing alone does not grant a card mandate');
277
+ }
278
+ // There is one budget product: eligible retail merchants under the
279
+ // provider's required Retail/5999 network category, with total,
280
+ // per-purchase, count, and time bounds stated on the approval page. The
281
+ // sentinel is provider metadata, never a user-entered merchant route.
282
+ const merchant = {
283
+ name: 'retail spend budget',
284
+ url: 'https://retail-budget.visa/budget',
285
+ countryCode: 'US',
286
+ };
287
+ // Legacy credential file OR the activated card:vic grant's token — see
288
+ // resolveCardInstrument. A v2-paired runtime only ever has the latter.
289
+ const credential = await resolveCardInstrument(input);
290
+ // The passkey ceremony is scoped to the CEILING + merchant (not one
291
+ // charge) — that scope is the unproven part of the spike.
292
+ const ceilingTarget = {
293
+ merchantName: merchant.name,
294
+ merchantUrl: merchant.url,
295
+ merchantCountryCode: merchant.countryCode,
296
+ transactionAmount: input.ceiling,
297
+ transactionCurrencyCode: input.currency,
298
+ };
299
+ // BUDGET mode: the ceiling target's amount IS the approved ceiling, so the
300
+ // server mints a budget mint token bound to that ceiling — later draws pull
301
+ // sub-ceiling amounts against it tap-free (the single-purchase fresh-tap
302
+ // path below stays non-budget).
303
+ const assurance = await runHostedApproval({
304
+ baseUrl: input.approvalBaseUrl,
305
+ tokenId: credential.tokenId,
306
+ target: ceilingTarget,
307
+ consumerEmail: input.contact.email,
308
+ budget: true,
309
+ agentJkt: registerCap.agentJkt,
310
+ onApprovalUrl: deps.onApprovalUrl,
311
+ maxDraws: DEFAULT_MANDATE_MAX_DRAWS,
312
+ ...(input.perTransaction !== undefined ? { perTransaction: input.perTransaction } : {}),
313
+ ...(input.intent !== undefined ? { intent: input.intent } : {}),
314
+ });
315
+ const mintToken = assurance.mintToken;
316
+ if (!mintToken) {
317
+ throw new Error('the approval server issued no mint token — server-side minting requires the ' +
318
+ 'verify-web deployment to run real/turnkey auth (not the dev stub). Retry once it does.');
319
+ }
320
+ if (!Number.isSafeInteger(assurance.validUntil) ||
321
+ assurance.validUntil <= Math.floor(now().getTime() / 1000)) {
322
+ throw new Error('the approval server issued no valid budget expiry');
323
+ }
324
+ const expiresAt = new Date(assurance.validUntil * 1000).toISOString();
325
+ const facts = await createCardMandate({
326
+ agentJkt: registerCap.agentJkt,
327
+ tokenId: credential.tokenId,
328
+ assuranceData: assurance.assuranceData,
329
+ ceilingMinor,
330
+ merchant,
331
+ currencyCode: input.currency,
332
+ expiresAt,
333
+ maxDraws: DEFAULT_MANDATE_MAX_DRAWS,
334
+ crossMerchant: true,
335
+ }, {
336
+ createIntent: (i) => serverCreateIntent(input.approvalBaseUrl, mintToken, i),
337
+ ledger,
338
+ approvalBaseUrl: input.approvalBaseUrl,
339
+ now,
340
+ });
341
+ // Seed the one server-authoritative cumulative store keyed by the VGS
342
+ // intent ID. A later draw requires its PoP verdict; the budget token never
343
+ // falls back as payable authority.
344
+ const registered = await registerMandateOrDisable({
345
+ registerCap,
346
+ mandateId: facts.mandateId,
347
+ mintToken,
348
+ ceiling: input.ceiling,
349
+ currency: input.currency,
350
+ });
351
+ return {
352
+ ...facts,
353
+ ...approvedCeilingFacts(facts, registered.approvedCeilingMinor),
354
+ merchantHost: new URL(merchant.url).hostname,
355
+ registerFailed: registered.registerFailed,
356
+ ...(registered.registerFailureReason !== undefined
357
+ ? { registerFailureReason: registered.registerFailureReason }
358
+ : {}),
359
+ };
360
+ },
361
+ // PICKUP leg: the owner already approved the ceiling in the account panel
362
+ // and handed this runtime a single-use pickup code. Claim it, verify it was
363
+ // minted for exactly this runtime's request key + card token, then run the
364
+ // same intent → register → ledger sequence as startCardMandate. No approval
365
+ // page, no passkey, no contact profile — the human side already happened.
366
+ async claimCardMandate(input) {
367
+ const registerCap = cardMandateRegister?.loadCapability(input.agentRef) ?? null;
368
+ if (!cardMandateRegister || !registerCap) {
369
+ throw new Error('claimCardMandate requires separately provisioned card authority in this runtime; ' +
370
+ 'identity pairing alone does not grant a card mandate');
371
+ }
372
+ const credential = await resolveCardInstrument(input);
373
+ const claim = await claimMandatePickup({
374
+ baseUrl: input.approvalBaseUrl,
375
+ pickupCode: input.pickupCode,
376
+ agentJkt: registerCap.agentJkt,
377
+ ...(input.expectedHandoffId ? { expectedHandoffId: input.expectedHandoffId } : {}),
378
+ tokenId: credential.tokenId,
379
+ });
380
+ const ceilingMinor = decimalToMinor(claim.ceiling);
381
+ if (ceilingMinor === null || ceilingMinor <= 0) {
382
+ throw new Error(`the handoff carried an invalid ceiling ${JSON.stringify(claim.ceiling)}`);
383
+ }
384
+ // Same single-product bound as mandate-start (the panel initiate route
385
+ // enforces it too; a divergent store entry must not widen it here).
386
+ if (claim.currency.toUpperCase() !== 'USD') {
387
+ throw new Error('card spend budgets currently support USD only');
388
+ }
389
+ const expiresAt = new Date(claim.validUntil * 1000).toISOString();
390
+ const facts = await createCardMandate({
391
+ agentJkt: registerCap.agentJkt,
392
+ tokenId: credential.tokenId,
393
+ assuranceData: claim.assuranceData,
394
+ ceilingMinor,
395
+ merchant: claim.merchant,
396
+ currencyCode: claim.currency,
397
+ expiresAt,
398
+ maxDraws: claim.maxDraws,
399
+ crossMerchant: true,
400
+ }, {
401
+ createIntent: (i) => serverCreateIntent(input.approvalBaseUrl, claim.mintToken, i),
402
+ ledger,
403
+ approvalBaseUrl: input.approvalBaseUrl,
404
+ now,
405
+ });
406
+ const registered = await registerMandateOrDisable({
407
+ registerCap,
408
+ mandateId: facts.mandateId,
409
+ mintToken: claim.mintToken,
410
+ ceiling: claim.ceiling,
411
+ currency: claim.currency,
412
+ });
413
+ return {
414
+ ...facts,
415
+ ...approvedCeilingFacts(facts, registered.approvedCeilingMinor),
416
+ merchantHost: new URL(claim.merchant.url).hostname,
417
+ registerFailed: registered.registerFailed,
418
+ ...(registered.registerFailureReason !== undefined
419
+ ? { registerFailureReason: registered.registerFailureReason }
420
+ : {}),
421
+ };
422
+ },
57
423
  async review(input) {
58
424
  const amountMinor = decimalToMinor(input.amount);
59
425
  if (amountMinor === null)
@@ -72,6 +438,7 @@ export function createCliCheckoutEngine(deps = {}) {
72
438
  amountMinor,
73
439
  currency: input.currency,
74
440
  browser,
441
+ contact: input.contact,
75
442
  }, store);
76
443
  if (prep.status !== 'ready') {
77
444
  await browser.close();
@@ -88,6 +455,7 @@ export function createCliCheckoutEngine(deps = {}) {
88
455
  amountMinor,
89
456
  currency: input.currency,
90
457
  contact: input.contact,
458
+ ...(input.agentJkt ? { agentJkt: input.agentJkt } : {}),
91
459
  cleanupTimer,
92
460
  });
93
461
  return {
@@ -104,6 +472,24 @@ export function createCliCheckoutEngine(deps = {}) {
104
472
  throw err;
105
473
  }
106
474
  },
475
+ /**
476
+ * Give up a prepared review without paying it (#7100).
477
+ *
478
+ * The retained browser is what lets a same-process MCP `review` → `pay`
479
+ * draw against the checkout it already inspected. A TERMINAL review-only
480
+ * run has no such second call — that process tells the operator to re-RUN
481
+ * with `--submit`, and the new process cannot reach this one's in-memory
482
+ * session. So the browser sat open for the full TTL, and because a live
483
+ * browser connection keeps the event loop alive, a command that had already
484
+ * succeeded looked hung until Ctrl-C.
485
+ *
486
+ * Idempotent and non-throwing: an unknown or already-released id is a
487
+ * no-op, so it is safe on an error path that may not have prepared anything
488
+ * and safe to call twice.
489
+ */
490
+ async releaseReview(reviewId) {
491
+ await closeSession(reviewId);
492
+ },
107
493
  async pay(input) {
108
494
  const session = sessions.get(input.reviewId);
109
495
  if (!session) {
@@ -113,6 +499,67 @@ export function createCliCheckoutEngine(deps = {}) {
113
499
  receiptPath: null,
114
500
  detail: `no prepared review ${input.reviewId} — call review first (a review does not survive a restart)`,
115
501
  vicConfirmation: null,
502
+ source: null,
503
+ remainingMinor: null,
504
+ };
505
+ }
506
+ // The reviewId selects the prepared browser session, but the pay call also
507
+ // repeats the target facts. Refuse if any repeated fact disagrees so the
508
+ // caller cannot submit one reviewed checkout while labeling the result or
509
+ // telemetry as another. Never echo the full URLs: payment links can carry
510
+ // claimable secrets in their path/query.
511
+ let payUrl;
512
+ let reviewedUrl;
513
+ try {
514
+ payUrl = new URL(input.url).toString();
515
+ reviewedUrl = new URL(session.target.merchantUrl).toString();
516
+ }
517
+ catch {
518
+ await closeSession(input.reviewId);
519
+ return {
520
+ outcome: 'failed',
521
+ confirmationRef: null,
522
+ receiptPath: null,
523
+ detail: 'pay merchant URL is invalid or does not match the reviewed checkout — start a fresh review',
524
+ vicConfirmation: null,
525
+ source: null,
526
+ remainingMinor: null,
527
+ };
528
+ }
529
+ if (payUrl !== reviewedUrl) {
530
+ await closeSession(input.reviewId);
531
+ return {
532
+ outcome: 'failed',
533
+ confirmationRef: null,
534
+ receiptPath: null,
535
+ detail: 'pay merchant URL does not match the reviewed checkout — start a fresh review',
536
+ vicConfirmation: null,
537
+ source: null,
538
+ remainingMinor: null,
539
+ };
540
+ }
541
+ if (input.currency.toUpperCase() !== session.currency.toUpperCase()) {
542
+ await closeSession(input.reviewId);
543
+ return {
544
+ outcome: 'failed',
545
+ confirmationRef: null,
546
+ receiptPath: null,
547
+ detail: `pay currency ${JSON.stringify(input.currency)} does not match the reviewed currency (${session.currency}) — start a fresh review`,
548
+ vicConfirmation: null,
549
+ source: null,
550
+ remainingMinor: null,
551
+ };
552
+ }
553
+ if (input.agentJkt !== session.agentJkt) {
554
+ await closeSession(input.reviewId);
555
+ return {
556
+ outcome: 'failed',
557
+ confirmationRef: null,
558
+ receiptPath: null,
559
+ detail: 'pay agent authority does not match the reviewed request key — start a fresh review',
560
+ vicConfirmation: null,
561
+ source: null,
562
+ remainingMinor: null,
116
563
  };
117
564
  }
118
565
  // Amount-bind the confirmation: the pay-call amount must match the
@@ -128,6 +575,8 @@ export function createCliCheckoutEngine(deps = {}) {
128
575
  receiptPath: null,
129
576
  detail: `pay amount ${JSON.stringify(input.amount)} does not match the reviewed amount (${session.amountMinor} minor units) — start a fresh review`,
130
577
  vicConfirmation: null,
578
+ source: null,
579
+ remainingMinor: null,
131
580
  };
132
581
  }
133
582
  // Reject an expired prepared checkout BEFORE running the hosted passkey
@@ -141,51 +590,249 @@ export function createCliCheckoutEngine(deps = {}) {
141
590
  receiptPath: null,
142
591
  detail: `prepared review ${input.reviewId} expired — start a fresh review`,
143
592
  vicConfirmation: null,
593
+ source: null,
594
+ remainingMinor: null,
144
595
  };
145
596
  }
146
597
  clearTimeout(session.cleanupTimer);
147
598
  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,
599
+ // NO instrument read here. A tap-free mandate draw spends `covering
600
+ // .tokenId` (frozen into the mandate at start) and needs neither the
601
+ // legacy credential file nor a grant token; only the fresh-tap branch
602
+ // below needs one, and it resolves lazily so a grant-only device with an
603
+ // as-yet-unrefreshed token can still draw on a mandate it already holds.
604
+ const host = new URL(session.target.merchantUrl).hostname;
605
+ // Does an ACTIVE card mandate already cover this exact purchase? If so,
606
+ // draw against it TAP-FREE (no hosted passkey). Else fall back to today's
607
+ // 1:1 fresh-tap flow. Mandates no longer persist the bootstrap token:
608
+ // it is consumed by intent creation + register and has no draw power.
609
+ const covering = await ledger.findCovering({
610
+ merchantHost: host,
611
+ currencyCode: session.currency,
612
+ amountMinor: session.amountMinor,
613
+ agentJkt: session.agentJkt,
614
+ now: now(),
154
615
  });
155
- const instrument = new VgsAssuranceInstrument(credential, assurance, session.target, session.contact.fullName ?? '', async (mintInput) => {
156
- const { intentId, status } = await createIntent(mintInput);
157
- try {
158
- const payment = await fetchCryptogram({
159
- tokenId: mintInput.tokenId,
160
- intentId,
161
- transaction: mintInput.transaction,
162
- });
163
- return { payment, intentId };
616
+ let source;
617
+ let confirmBase;
618
+ // Fresh purchases confirm with their one-purchase mint token. Mandate
619
+ // draws set this only after the local reserve succeeds and auth returns
620
+ // the exact per-draw verdict used to mint the credential.
621
+ let confirmationAuthority = null;
622
+ let drawnRemaining = null;
623
+ let instrument;
624
+ if (covering) {
625
+ // --- Tap-free mandate draw ------------------------------------------
626
+ source = 'mandate';
627
+ confirmBase = covering.approvalBaseUrl;
628
+ const mandateId = covering.mandateId;
629
+ // A mandate cryptogram is authorized only by a PoP-signed verdict.
630
+ // The budget mint token is bootstrap-only and cannot bypass the
631
+ // server reserve/commit ledger when local draw authority is absent.
632
+ //
633
+ // #5928 CRITICAL-2: the CLI does NOT settle the reservation. verify-web
634
+ // (which knows whether the cryptogram was payable) commits it on a
635
+ // payable mint and releases it on a decline, server-authoritatively, so
636
+ // the drawer can never release after a payable mint to dodge the ceiling.
637
+ const verdictSeam = cardDrawVerdict;
638
+ const capability = verdictSeam?.loadCapability(covering.agentJkt) ?? null;
639
+ if (!capability || !verdictSeam) {
640
+ return {
641
+ outcome: 'failed',
642
+ confirmationRef: null,
643
+ receiptPath: null,
644
+ detail: 'this mandate cannot draw because its delegated card authority is unavailable; ' +
645
+ 'restore the runtime binding or use a fresh per-purchase approval',
646
+ vicConfirmation: null,
647
+ source,
648
+ remainingMinor: null,
649
+ };
164
650
  }
165
- catch (err) {
166
- throw new Error(`${err.message} (intent status at creation: ${status ?? 'unknown'})`);
651
+ if (covering.agentJkt && capability.agentJkt !== covering.agentJkt) {
652
+ return {
653
+ outcome: 'failed',
654
+ confirmationRef: null,
655
+ receiptPath: null,
656
+ detail: 'this budget belongs to a different request key than the selected card capability; ' +
657
+ 'restore that exact runtime key or use a fresh per-purchase approval',
658
+ vicConfirmation: null,
659
+ source,
660
+ remainingMinor: null,
661
+ };
167
662
  }
168
- });
663
+ // VgsLiveInstrument mints against an EXISTING intent (the mandate) with
664
+ // no fresh assurance — exactly the draw semantics. The fetchCredential
665
+ // seam routes through drawFromMandate so the ledger accounting (reserve
666
+ // -> commit on payable, release on reject) wraps the cryptogram pull.
667
+ const reference = {
668
+ tokenId: covering.tokenId,
669
+ intentId: mandateId,
670
+ merchantName: session.target.merchantName,
671
+ merchantUrl: session.target.merchantUrl,
672
+ merchantCountryCode: session.target.merchantCountryCode,
673
+ transactionAmount: session.target.transactionAmount,
674
+ transactionCurrencyCode: session.currency,
675
+ };
676
+ const fetchCredential = async ({ transaction }) => {
677
+ let draw;
678
+ try {
679
+ draw = await drawFromMandate({
680
+ mandateId,
681
+ amountMinor: session.amountMinor,
682
+ transaction: { ...transaction, transactionCurrencyCode: session.currency },
683
+ }, {
684
+ // Ordering matters: reserve the local ledger FIRST, then ask
685
+ // auth to reserve the server-authoritative budget immediately
686
+ // before the payable mint. A local reserve race/expiry/file
687
+ // failure therefore makes zero auth/verdict calls and cannot
688
+ // strand a server reservation until its sweep.
689
+ fetchCryptogram: async (i) => {
690
+ const { verdict } = await verdictSeam.fetchVerdict({
691
+ authBaseUrl: capability.authBaseUrl,
692
+ agentKey: capability.agentKey,
693
+ mandateId,
694
+ // One draw per review; the reviewId is auth's idempotency key.
695
+ drawId: input.reviewId,
696
+ draw: {
697
+ tokenId: covering.tokenId,
698
+ amount: session.target.transactionAmount,
699
+ currency: session.currency,
700
+ merchantName: session.target.merchantName,
701
+ merchantUrl: session.target.merchantUrl,
702
+ merchantCountryCode: session.target.merchantCountryCode,
703
+ },
704
+ });
705
+ confirmationAuthority = verdict;
706
+ return fetchMandateCryptogram(confirmBase, verdict, i);
707
+ },
708
+ ledger,
709
+ now,
710
+ });
711
+ }
712
+ catch (err) {
713
+ // Disable the mandate ONLY for a post-reservation NETWORK decline
714
+ // (MandateDrawDeclinedError). That case leaves the mandate
715
+ // active+covering, so a naive retry would re-select it and fail
716
+ // identically — trapping the caller; marking it unhonored makes the
717
+ // next pay_merchant skip it and take a fresh per-purchase tap. We do
718
+ // NOT attempt an unsafe same-call browser fallback mid-submit.
719
+ //
720
+ // A PRE-network failure — reserve() failing closed on a concurrent
721
+ // over-budget race or expiry, or commit() throwing on file I/O —
722
+ // is NOT a network decline: the mandate is HEALTHY, so we must
723
+ // rethrow WITHOUT disabling it (michaelyang1 M1). drawFromMandate
724
+ // has already released any reservation, so the budget is intact.
725
+ // Only a DEFINITIVE (hard) decline disables the mandate. A TRANSIENT
726
+ // failure — a gateway 5xx / "not completed, try again" / network
727
+ // reset/timeout — must NOT permanently kill the budget: the mandate
728
+ // may be perfectly healthy (it can have committed a draw seconds
729
+ // earlier) and the rails may recover on retry. Over-disabling on a
730
+ // transient 502 threw away good budgets and forced a fresh passkey
731
+ // every time.
732
+ // Classify on the underlying gateway CAUSE, not the MandateDrawDeclinedError
733
+ // wrapper — the wrapper's own advisory text mentions "try again"/"network",
734
+ // which would otherwise self-classify every decline as transient.
735
+ if (err instanceof MandateDrawDeclinedError) {
736
+ const verdictFailure = classifyCardDrawVerdictFailure(err);
737
+ if (verdictFailure) {
738
+ if (!verdictFailure.transient) {
739
+ await ledger.markUnhonored(mandateId, now()).catch(() => { });
740
+ }
741
+ throw new Error(verdictFailure.transient
742
+ ? `the card-mandate draw could not be authorized right now (${verdictFailure.reasons.join(', ') || 'temporary error'}) — retry shortly`
743
+ : `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 approval`, { cause: err });
744
+ }
745
+ if (!isTransientDrawFailure(err.cause ?? err)) {
746
+ await ledger.markUnhonored(mandateId, now());
747
+ }
748
+ }
749
+ // #5928 CRITICAL-2: the server-side reservation is released by
750
+ // verify-web (it saw the mint fail), not here — the CLI never settles.
751
+ throw err;
752
+ }
753
+ drawnRemaining = draw.remainingMinor;
754
+ // #5928 CRITICAL-2: the reservation is committed by verify-web on the
755
+ // payable mint (server-authoritative); the CLI does not settle.
756
+ return draw.payment;
757
+ };
758
+ instrument = new VgsLiveInstrument(reference, session.contact.fullName ?? '', fetchCredential);
759
+ }
760
+ else {
761
+ // --- 1:1 fresh-tap flow (unchanged) ---------------------------------
762
+ source = 'fresh-tap';
763
+ // Legacy credential file OR the activated card:vic grant's token.
764
+ const credential = await resolveCardInstrument(input);
765
+ const assurance = await runHostedApproval({
766
+ baseUrl: input.approvalBaseUrl,
767
+ tokenId: credential.tokenId,
768
+ target: session.target,
769
+ consumerEmail: session.contact.email,
770
+ onApprovalUrl: input.onApprovalUrl ?? deps.onApprovalUrl,
771
+ });
772
+ // Server-side mint (Phase 1): the approval claim releases a scoped mint
773
+ // token; the credential is minted by the verify-web deployment (which
774
+ // holds the VGS secret), never on this machine. No token means the
775
+ // deployment ran the dev-auth stub — refuse loudly rather than reach for
776
+ // a client-held secret (there is none anymore).
777
+ const mintToken = assurance.mintToken;
778
+ if (!mintToken) {
779
+ return {
780
+ outcome: 'failed',
781
+ confirmationRef: null,
782
+ receiptPath: null,
783
+ detail: 'the approval server issued no mint token — server-side minting requires the ' +
784
+ 'verify-web deployment to run real/turnkey auth (not the dev stub). Retry once it does.',
785
+ vicConfirmation: null,
786
+ source: 'fresh-tap',
787
+ remainingMinor: null,
788
+ };
789
+ }
790
+ confirmBase = input.approvalBaseUrl;
791
+ confirmationAuthority = mintToken;
792
+ instrument = new VgsAssuranceInstrument(credential, assurance, session.target, session.contact.fullName ?? '', async (mintInput) => {
793
+ const { intentId, status } = await serverCreateIntent(confirmBase, mintToken, mintInput);
794
+ try {
795
+ const payment = await serverFetchCryptogram(confirmBase, mintToken, {
796
+ tokenId: mintInput.tokenId,
797
+ intentId,
798
+ transaction: mintInput.transaction,
799
+ });
800
+ return { payment, intentId };
801
+ }
802
+ catch (err) {
803
+ throw new Error(`${err.message} (intent status at creation: ${status ?? 'unknown'})`);
804
+ }
805
+ });
806
+ }
169
807
  const mode = input.submit ? 'submit' : 'dry-run';
170
808
  const result = await submitApprovedCheckout(input.reviewId, {
171
809
  approval: { approved: true, reviewId: input.reviewId },
172
810
  instrument,
173
811
  contact: session.contact,
174
812
  mode,
813
+ ...(deps.resolveEmailOtp ? { resolveEmailOtp: deps.resolveEmailOtp } : {}),
175
814
  }, store);
176
815
  // Report the observed submit outcome to VIC for the consumed intent
177
816
  // (APPROVED/DECLINED). Only definitive submit answers post. Mirrors
178
817
  // run-live-fill.ts.
179
818
  let vicConfirmation = null;
819
+ let processorIntentId = null;
180
820
  if (mode === 'submit') {
821
+ const confirmationTarget = instrument.confirmationTarget();
822
+ processorIntentId = confirmationTarget?.intentId ?? null;
181
823
  vicConfirmation = await reportVicOutcome({
182
- target: instrument.confirmationTarget(),
824
+ target: confirmationTarget,
183
825
  outcome: result.outcome,
184
826
  transaction: {
185
827
  transactionAmount: session.target.transactionAmount,
186
828
  transactionCurrencyCode: session.currency,
187
829
  },
188
- post: postConfirmation,
830
+ post: (confInput) => {
831
+ if (!confirmationAuthority) {
832
+ throw new Error('confirmation authority missing for the consumed VIC intent');
833
+ }
834
+ return postServerConfirmation(confirmBase, confirmationAuthority, confInput);
835
+ },
189
836
  });
190
837
  }
191
838
  let receiptPath = null;
@@ -215,6 +862,9 @@ export function createCliCheckoutEngine(deps = {}) {
215
862
  receiptPath,
216
863
  detail: result.detail ?? null,
217
864
  vicConfirmation,
865
+ source,
866
+ remainingMinor: drawnRemaining,
867
+ processorIntentId,
218
868
  };
219
869
  }
220
870
  finally {