@visa/cli 4.1.0-rc.66 → 4.1.0-rc.67

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.
@@ -21,6 +21,8 @@ export type CliReviewInput = {
21
21
  amount: string;
22
22
  currency: string;
23
23
  credentialPath: string;
24
+ /** See {@link CardInstrumentSource}. */
25
+ cardTokenId?: string;
24
26
  contact: Contact;
25
27
  approvalBaseUrl: string;
26
28
  merchantName?: string;
@@ -66,6 +68,8 @@ export type CliStartMandateInput = {
66
68
  ceiling: string;
67
69
  currency: string;
68
70
  credentialPath: string;
71
+ /** See {@link CardInstrumentSource}. */
72
+ cardTokenId?: string;
69
73
  contact: Contact;
70
74
  approvalBaseUrl: string;
71
75
  /**
@@ -90,6 +94,16 @@ export type CliMandateFacts = CardMandateFacts & {
90
94
  * mandate-start now refuses before approval when no capability can register.
91
95
  */
92
96
  registerFailed?: boolean;
97
+ /**
98
+ * The server's refusal reason when {@link registerFailed} is true, verbatim.
99
+ * Carried out so the CLI can distinguish causes that need DIFFERENT operator
100
+ * actions — notably `token_mismatch`, which means the on-device grant record's
101
+ * cached token no longer matches the owner's live agentic token (they
102
+ * re-enrolled a card after the grant) and is fixed by re-running `grant-card`,
103
+ * not by retrying `mandate start`. Without the reason every failure reads as
104
+ * "the auth server was unreachable", which sends the operator in a loop.
105
+ */
106
+ registerFailureReason?: string;
93
107
  };
94
108
  type Session = {
95
109
  browser: Browser;
@@ -72,6 +72,33 @@ function classifyCardDrawVerdictFailure(err) {
72
72
  }
73
73
  return null;
74
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
+ }
75
102
  const RECEIPT_DIR = join(homedir(), '.visa-mcp', 'checkout-receipts');
76
103
  // Must match the prepared-checkout store TTL so a session and its store entry
77
104
  // expire together — an abandoned review can't leak the browser + state.
@@ -150,7 +177,9 @@ export function createCliCheckoutEngine(deps = {}) {
150
177
  url: 'https://retail-budget.visa/budget',
151
178
  countryCode: 'US',
152
179
  };
153
- const credential = JSON.parse(await readFile(input.credentialPath, 'utf8'));
180
+ // Legacy credential file OR the activated card:vic grant's token — see
181
+ // resolveCardInstrument. A v2-paired runtime only ever has the latter.
182
+ const credential = await resolveCardInstrument(input);
154
183
  // The passkey ceremony is scoped to the CEILING + merchant (not one
155
184
  // charge) — that scope is the unproven part of the spike.
156
185
  const ceilingTarget = {
@@ -206,6 +235,7 @@ export function createCliCheckoutEngine(deps = {}) {
206
235
  // intent ID. A later draw requires its PoP verdict; the budget token never
207
236
  // falls back as payable authority.
208
237
  let registerFailed = false;
238
+ let registerFailureReason;
209
239
  if (registerCap && cardMandateRegister) {
210
240
  const reg = await cardMandateRegister
211
241
  .register({
@@ -222,6 +252,7 @@ export function createCliCheckoutEngine(deps = {}) {
222
252
  }));
223
253
  if (!reg.ok) {
224
254
  registerFailed = true;
255
+ registerFailureReason = reg.reason;
225
256
  // Register failed: the server `card_mandate_spend` row was never
226
257
  // created, so a delegated draw against this mandate would 404
227
258
  // `no_mandate`. Mark it register-failed so findCovering() SKIPS it and
@@ -244,7 +275,12 @@ export function createCliCheckoutEngine(deps = {}) {
244
275
  `'mandate start'. mandateId=${facts.mandateId}\n`);
245
276
  }
246
277
  }
247
- return { ...facts, merchantHost: new URL(merchant.url).hostname, registerFailed };
278
+ return {
279
+ ...facts,
280
+ merchantHost: new URL(merchant.url).hostname,
281
+ registerFailed,
282
+ ...(registerFailureReason !== undefined ? { registerFailureReason } : {}),
283
+ };
248
284
  },
249
285
  async review(input) {
250
286
  const amountMinor = decimalToMinor(input.amount);
@@ -391,7 +427,11 @@ export function createCliCheckoutEngine(deps = {}) {
391
427
  }
392
428
  clearTimeout(session.cleanupTimer);
393
429
  try {
394
- const credential = JSON.parse(await readFile(input.credentialPath, 'utf8'));
430
+ // NO instrument read here. A tap-free mandate draw spends `covering
431
+ // .tokenId` (frozen into the mandate at start) and needs neither the
432
+ // legacy credential file nor a grant token; only the fresh-tap branch
433
+ // below needs one, and it resolves lazily so a grant-only device with an
434
+ // as-yet-unrefreshed token can still draw on a mandate it already holds.
395
435
  const host = new URL(session.target.merchantUrl).hostname;
396
436
  // Does an ACTIVE card mandate already cover this exact purchase? If so,
397
437
  // draw against it TAP-FREE (no hosted passkey). Else fall back to today's
@@ -550,6 +590,8 @@ export function createCliCheckoutEngine(deps = {}) {
550
590
  else {
551
591
  // --- 1:1 fresh-tap flow (unchanged) ---------------------------------
552
592
  source = 'fresh-tap';
593
+ // Legacy credential file OR the activated card:vic grant's token.
594
+ const credential = await resolveCardInstrument(input);
553
595
  const assurance = await runHostedApproval({
554
596
  baseUrl: input.approvalBaseUrl,
555
597
  tokenId: credential.tokenId,