@visa/cli 4.1.0-rc.229 → 4.1.0-rc.230

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.
@@ -11,6 +11,7 @@
11
11
  // Every browser/network primitive is injectable (CliEngineDeps) so the session/
12
12
  // timer/store lifecycle is unit-testable without launching Chromium.
13
13
  import { readFile } from 'node:fs/promises';
14
+ import { randomUUID } from 'node:crypto';
14
15
  import { launchCheckoutBrowser } from './browser-launch.js';
15
16
  import { RECEIPT_DIR } from './receipt-dir.js';
16
17
  import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
@@ -19,7 +20,7 @@ import { VgsLiveInstrument, decimalToMinor, minorToDecimal, } from './vgs-live-i
19
20
  import { serverCreateIntent, serverFetchCryptogram, serverPostConfirmation, } from './vgs-gateway/server-mint-client.js';
20
21
  import { createCardMandate, DEFAULT_MANDATE_MAX_DRAWS, drawFromMandate, MandateDrawDeclinedError, } from './mandate/card-mandate.js';
21
22
  import { MandateLedger } from './mandate/mandate-ledger.js';
22
- import { buildReceipt, writeReceipt as realWriteReceipt } from './receipt.js';
23
+ import { buildReceipt, writeReceipt as realWriteReceipt, } from './receipt.js';
23
24
  import { reportVicOutcome as realReportVicOutcome, } from './vic-confirmation.js';
24
25
  /**
25
26
  * A card-mandate draw failed transiently (retryable) rather than definitively.
@@ -60,6 +61,31 @@ export function isTransientDrawFailure(err) {
60
61
  // card-decline reason) matches nothing here → the mandate is correctly disabled.
61
62
  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
+ /**
65
+ * Refusals that are about the OUTER card GRANT's remaining capacity, not about
66
+ * this mandate and not about the card network (#7491).
67
+ *
68
+ * The atomic reserve (#7233) charges the grant's shared `agent_grant_usage`
69
+ * epoch, so an exhausted or replaced grant refuses BEFORE any credential is
70
+ * minted: no cryptogram is issued, no processor intent is created, no money
71
+ * moves. Treating that as a mandate decline burned the budget the human had
72
+ * just approved and told the caller to create another mandate — which, over the
73
+ * same exhausted grant, is guaranteed to fail identically. The honest remedy is
74
+ * a new/replacement card grant, and the mandate must stay honored so it can
75
+ * draw once the owner supplies one.
76
+ */
77
+ // Deliberately NARROW. `grant_aggregate_no_mandate` is NOT here: that refusal
78
+ // means the mandate row itself vanished between the read and the reserve, which
79
+ // is a statement about the mandate, not about the grant's capacity.
80
+ const GRANT_CAPACITY_REASONS = new Set([
81
+ 'over_grant_aggregate',
82
+ 'no_active_grant',
83
+ 'no_active_grant_or_card_scope',
84
+ 'card_grant_exhausted',
85
+ ]);
86
+ function isGrantCapacityReason(reason) {
87
+ return GRANT_CAPACITY_REASONS.has(reason);
88
+ }
63
89
  /**
64
90
  * A verdict refusal may be wrapped by drawFromMandate after its local
65
91
  * reservation is released. Walk the cause chain so the original auth status +
@@ -80,10 +106,17 @@ export function classifyCardDrawVerdictFailure(err) {
80
106
  ? candidate.reasons.filter((reason) => typeof reason === 'string')
81
107
  : [];
82
108
  if (status !== 0 || reasons.length > 0) {
109
+ const transient = status === 503 ||
110
+ reasons.length === 0 ||
111
+ reasons.every((reason) => transientReasons.has(reason));
83
112
  return {
84
- transient: status === 503 ||
85
- reasons.length === 0 ||
86
- reasons.every((reason) => transientReasons.has(reason)),
113
+ transient,
114
+ // A capacity refusal is NEITHER transient nor a decline. Retrying now
115
+ // fails identically (so it is not transient), but nothing about the
116
+ // MANDATE was refused (so it is not a decline). Only a refusal that is
117
+ // exclusively about the outer grant qualifies: a mixed reason list
118
+ // still carries a real mandate/network refusal and stays a decline.
119
+ grantCapacity: !transient && reasons.length > 0 && reasons.every(isGrantCapacityReason),
87
120
  reasons,
88
121
  };
89
122
  }
@@ -130,8 +163,9 @@ export class CheckoutReviewRefusedError extends Error {
130
163
  failureCode;
131
164
  requiresAdapter;
132
165
  detectedRoles;
166
+ receiptWrite;
133
167
  detail;
134
- constructor(result) {
168
+ constructor(result, receiptWrite) {
135
169
  super(result.detail
136
170
  ? `review refused: ${result.outcome} — ${result.detail}`
137
171
  : `review refused: ${result.outcome}`);
@@ -140,12 +174,14 @@ export class CheckoutReviewRefusedError extends Error {
140
174
  this.failureCode = result.failureCode;
141
175
  this.requiresAdapter = [...result.requiresAdapter];
142
176
  this.detectedRoles = Object.keys(result.fields);
177
+ this.receiptWrite = receiptWrite;
143
178
  this.detail = result.detail;
144
179
  }
145
180
  }
146
181
  function payAttemptFingerprint(input) {
147
182
  return JSON.stringify([
148
183
  input.url,
184
+ input.checkoutRoute,
149
185
  input.amount,
150
186
  input.currency,
151
187
  input.credentialPath,
@@ -171,6 +207,9 @@ function failedPay(detail) {
171
207
  credentialDisclosed: false,
172
208
  };
173
209
  }
210
+ function boundedReceiptWriteErrorCode(reason) {
211
+ return reason.match(/\b(?:EACCES|EEXIST|ENOSPC|ENOTDIR|EPERM|EROFS)\b/)?.[0] ?? 'UNKNOWN';
212
+ }
174
213
  // Must match the prepared-checkout store TTL so a session and its store entry
175
214
  // expire together — an abandoned review can't leak the browser + state.
176
215
  const PREPARED_TTL_MS = 5 * 60 * 1000;
@@ -194,6 +233,39 @@ export function createCliCheckoutEngine(deps = {}) {
194
233
  const now = deps.now ?? (() => new Date());
195
234
  const fetchMandateCryptogram = deps.serverFetchCryptogram ?? serverFetchCryptogram;
196
235
  const postServerConfirmation = deps.serverPostConfirmation ?? serverPostConfirmation;
236
+ const createObservationId = deps.createObservationId ?? randomUUID;
237
+ async function writeObservedReceipt(mode, receipt) {
238
+ let report;
239
+ try {
240
+ report = await writeReceipt(RECEIPT_DIR, receipt);
241
+ }
242
+ catch (err) {
243
+ // The real writer is fail-open already. Keep that invariant even for an
244
+ // injected writer so a filesystem or test-double failure cannot replace
245
+ // the checkout result after a charge may have completed.
246
+ report = { written: false, reason: err instanceof Error ? err.message : String(err) };
247
+ }
248
+ const event = {
249
+ event: 'checkout_receipt_write',
250
+ status: report.written ? 'written' : 'failed',
251
+ mode,
252
+ checkoutOutcome: receipt.outcome,
253
+ merchantHost: receipt.merchant.host,
254
+ observationId: receipt.observationId ?? receipt.receiptId ?? createObservationId(),
255
+ failureCode: receipt.capability?.failureCode ?? null,
256
+ errorCode: report.written ? null : boundedReceiptWriteErrorCode(report.reason),
257
+ panRedactions: report.written ? report.panRedactions : null,
258
+ };
259
+ try {
260
+ const observerResult = deps.onReceiptWrite?.(event);
261
+ if (observerResult)
262
+ void Promise.resolve(observerResult).catch(() => undefined);
263
+ }
264
+ catch {
265
+ // Observability is non-authoritative and must not affect payment state.
266
+ }
267
+ return { report, event };
268
+ }
197
269
  const cardDrawVerdict = deps.cardDrawVerdict ?? null;
198
270
  const cardMandateRegister = deps.cardMandateRegister ?? null;
199
271
  async function closeSession(reviewId) {
@@ -504,6 +576,7 @@ export function createCliCheckoutEngine(deps = {}) {
504
576
  try {
505
577
  const prep = await prepareCheckout({
506
578
  url: input.url,
579
+ checkoutRoute: input.checkoutRoute,
507
580
  mandate: {
508
581
  maxAmountMinor: amountMinor,
509
582
  currency: input.currency,
@@ -514,18 +587,56 @@ export function createCliCheckoutEngine(deps = {}) {
514
587
  currency: input.currency,
515
588
  browser,
516
589
  contact: input.contact,
590
+ ...(input.trustedMerchantIdentity
591
+ ? { trustedMerchantIdentity: input.trustedMerchantIdentity }
592
+ : {}),
517
593
  }, store);
518
594
  if (prep.status !== 'ready') {
519
- throw new CheckoutReviewRefusedError(prep.result);
595
+ // A terminal review is still a capability observation. Persist the
596
+ // same compact, owner-only artifact as a pay attempt so the local
597
+ // ledger records unsupported merchant surfaces and the safe card
598
+ // display identity instead of retaining successes only.
599
+ const observationId = createObservationId();
600
+ const receiptWrite = await writeObservedReceipt('dry-run', buildReceipt({
601
+ mode: 'dry-run',
602
+ reviewId: null,
603
+ observationId,
604
+ merchant: {
605
+ name: input.merchantName ?? host,
606
+ host,
607
+ url: input.url,
608
+ },
609
+ transaction: {
610
+ amount: input.amount,
611
+ amountMinor,
612
+ currency: input.currency,
613
+ },
614
+ result: prep.result,
615
+ vicConfirmation: null,
616
+ agentName: input.agentName,
617
+ cardLast4: input.cardLast4,
618
+ recordedAt: now(),
619
+ }));
620
+ throw new CheckoutReviewRefusedError(prep.result, receiptWrite.event);
520
621
  }
521
622
  const r = prep.checkout.review;
522
623
  // Expire the companion session on the SAME schedule as the store entry
523
624
  // (unref'd so a pending timer never keeps the process alive).
524
625
  const cleanupTimer = setTimeout(() => void closeSession(r.id), ttlMs);
525
626
  cleanupTimer.unref?.();
627
+ const target = buildTarget(input);
628
+ if (r.merchantOrigin) {
629
+ // The credential and mandate bind to the exact origin the browser
630
+ // actually reviewed, not the Shopify service continuation that led
631
+ // there. Keep the latter separately for review/pay replay binding.
632
+ target.merchantUrl = r.merchantOrigin;
633
+ target.merchantName = input.merchantName ?? r.merchantHost;
634
+ }
526
635
  sessions.set(r.id, {
527
636
  browser,
528
- target: buildTarget(input),
637
+ requestUrl: new URL(input.url).toString(),
638
+ checkoutRoute: input.checkoutRoute,
639
+ target,
529
640
  amountMinor,
530
641
  currency: input.currency,
531
642
  contact: input.contact,
@@ -604,7 +715,7 @@ export function createCliCheckoutEngine(deps = {}) {
604
715
  let reviewedUrl;
605
716
  try {
606
717
  payUrl = new URL(input.url).toString();
607
- reviewedUrl = new URL(session.target.merchantUrl).toString();
718
+ reviewedUrl = session.requestUrl;
608
719
  }
609
720
  catch {
610
721
  await closeSession(input.reviewId);
@@ -658,6 +769,19 @@ export function createCliCheckoutEngine(deps = {}) {
658
769
  ...noCredentialFacts,
659
770
  };
660
771
  }
772
+ if (input.checkoutRoute !== session.checkoutRoute) {
773
+ await closeSession(input.reviewId);
774
+ return {
775
+ outcome: 'failed',
776
+ confirmationRef: null,
777
+ receiptPath: null,
778
+ detail: 'pay checkout route does not match the reviewed guest-card route — start a fresh review',
779
+ vicConfirmation: null,
780
+ source: null,
781
+ remainingMinor: null,
782
+ ...noCredentialFacts,
783
+ };
784
+ }
661
785
  // Amount-bind the confirmation: the pay-call amount must match the
662
786
  // reviewed amount. The reviewId already locks the immutable mandate, but
663
787
  // re-checking here makes the confirmation explicitly amount-bound so a
@@ -856,9 +980,18 @@ export function createCliCheckoutEngine(deps = {}) {
856
980
  if (err instanceof MandateDrawDeclinedError) {
857
981
  const verdictFailure = classifyCardDrawVerdictFailure(err);
858
982
  if (verdictFailure) {
859
- if (!verdictFailure.transient) {
983
+ // #7491: a GRANT-capacity refusal is pre-credential and says
984
+ // nothing about this mandate, so it must NOT disable it. The
985
+ // mandate is healthy and becomes drawable again the moment the
986
+ // owner supplies a grant with room; disabling it here threw
987
+ // away a just-approved budget and pointed the caller at a new
988
+ // mandate that would die on the same wall.
989
+ if (!verdictFailure.transient && !verdictFailure.grantCapacity) {
860
990
  await ledger.markUnhonored(mandateId, now()).catch(() => { });
861
991
  }
992
+ if (verdictFailure.grantCapacity) {
993
+ throw new Error(`this agent's card grant has no remaining capacity (${verdictFailure.reasons.join(', ') || 'grant exhausted'}) — nothing was charged and the mandate is still valid; ask the owner for a new or replenished card grant (visa agent grant-card) before retrying this checkout`, { cause: err });
994
+ }
862
995
  throw new Error(verdictFailure.transient
863
996
  ? `the card-mandate draw could not be authorized right now (${verdictFailure.reasons.join(', ') || 'temporary error'}) — retry shortly`
864
997
  : `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 });
@@ -938,7 +1071,7 @@ export function createCliCheckoutEngine(deps = {}) {
938
1071
  });
939
1072
  }
940
1073
  let receiptPath = null;
941
- const report = await writeReceipt(RECEIPT_DIR, buildReceipt({
1074
+ const receiptWrite = await writeObservedReceipt(mode, buildReceipt({
942
1075
  mode,
943
1076
  reviewId: input.reviewId,
944
1077
  // Derive BOTH name and host from the reviewed session target (not
@@ -959,8 +1092,8 @@ export function createCliCheckoutEngine(deps = {}) {
959
1092
  agentName: input.agentName,
960
1093
  cardLast4: input.cardLast4,
961
1094
  }));
962
- if (report.written)
963
- receiptPath = report.path;
1095
+ if (receiptWrite.report.written)
1096
+ receiptPath = receiptWrite.report.path;
964
1097
  return finishAttempt({
965
1098
  outcome: result.outcome,
966
1099
  confirmationRef: result.confirmationRef ?? null,
@@ -973,6 +1106,7 @@ export function createCliCheckoutEngine(deps = {}) {
973
1106
  credentialIssued: result.credentialLifecycle !== 'not-requested',
974
1107
  credentialDisclosed: result.credentialLifecycle === 'partially-exposed' ||
975
1108
  result.credentialLifecycle === 'fully-filled',
1109
+ receiptWrite: receiptWrite.event,
976
1110
  });
977
1111
  }
978
1112
  catch (error) {
@@ -8,6 +8,7 @@ import { type ObservedOutcome } from './outcome.js';
8
8
  import { type WebBotAuthConfig } from './web-bot-auth.js';
9
9
  export { minorFromDecimal, pageCurrency } from './amount.js';
10
10
  export type CheckoutMode = 'dry-run' | 'submit';
11
+ export type CheckoutRoute = 'guest-card';
11
12
  export type CheckoutOutcome = 'reviewed-dry-run'
12
13
  /** Historical receipt value from the credential-disclosing dry-run. */
13
14
  | 'filled-dry-run' | 'partial-fill' | 'adapter-required' | 'confirmed' | 'declined' | 'action-required' | 'cancelled' | 'blocked-by-mandate'
@@ -56,6 +57,7 @@ export type CheckoutResult = {
56
57
  };
57
58
  export type PrepareCheckoutOptions = {
58
59
  url: string;
60
+ checkoutRoute: CheckoutRoute;
59
61
  mandate: Mandate;
60
62
  browser: Browser;
61
63
  contact?: Contact;
@@ -63,6 +65,16 @@ export type PrepareCheckoutOptions = {
63
65
  currency?: string;
64
66
  debugShotsDir?: string;
65
67
  webBotAuth?: WebBotAuthConfig | null;
68
+ /**
69
+ * Process-internal UCP provenance. Public callers may relay only handoffId;
70
+ * this origin set is recovered by the CLI from its single-use registry.
71
+ */
72
+ trustedMerchantIdentity?: Readonly<{
73
+ handoffId: string;
74
+ checkoutId: string;
75
+ allowedOrigins: readonly string[];
76
+ expiresAt: string;
77
+ }>;
66
78
  };
67
79
  export type RunCheckoutOptions = PrepareCheckoutOptions & {
68
80
  instrument: Instrument;
@@ -75,6 +87,8 @@ export type CheckoutReview = {
75
87
  id: string;
76
88
  url: string;
77
89
  merchantHost: string;
90
+ /** Exact origin frozen for a trusted UCP checkout; omitted for generic flows. */
91
+ merchantOrigin?: string;
78
92
  amountMinor: number;
79
93
  currency: string;
80
94
  mandateMaxAmountMinor: number;
@@ -82,6 +96,7 @@ export type CheckoutReview = {
82
96
  submitTarget: string | null;
83
97
  submitTargetFingerprint: Readonly<CheckoutSubmitTargetFingerprint> | null;
84
98
  detectedRoles: readonly string[];
99
+ checkoutRoute: CheckoutRoute;
85
100
  };
86
101
  export type CheckoutSubmitTargetFingerprint = {
87
102
  kind: 'submit-control' | 'text-button';
@@ -99,6 +114,7 @@ export type PreparedCheckout = {
99
114
  readonly fields: Readonly<FieldMap>;
100
115
  readonly evidence: EvidenceLog;
101
116
  readonly requiresAdapter: readonly string[];
117
+ readonly checkoutRoute: CheckoutRoute;
102
118
  };
103
119
  export type PrepareCheckoutResult = {
104
120
  status: 'ready';
@@ -174,6 +190,7 @@ export declare class InMemoryPreparedCheckoutStore implements PreparedCheckoutSe
174
190
  private scheduleReaper;
175
191
  private closeState;
176
192
  }
193
+ export declare function trustedMerchantOriginRefusal(options: PrepareCheckoutOptions, pageUrl: string, expectedOrigin?: string): string | null;
177
194
  export declare function reconcileHeldOutcome(original: ObservedOutcome, held: ObservedOutcome): ObservedOutcome;
178
195
  export declare function debugShotMaskPlan(fields: FieldMap): {
179
196
  skipReason: string | null;