@visa/cli 4.1.0-rc.38 → 4.1.0-rc.39

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