@visa/cli 4.1.0-rc.148 → 4.1.0-rc.149

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.
@@ -65,11 +65,11 @@ export type CliReceiptFacts = {
65
65
  vicConfirmation: VicConfirmationReport | null;
66
66
  /**
67
67
  * Which credential path actually ran: `mandate` = tap-free draw against an
68
- * existing card mandate; `fresh-tap` = today's 1:1 hosted-passkey mint; `null`
69
- * = neither ran (a pre-flight refusal, e.g. no prepared review). Transparency,
70
- * never magic — the caller can always see whether a passkey was skipped.
68
+ * existing card mandate; `null` = none ran (a pre-flight refusal, e.g. no
69
+ * prepared review or no covering mandate). Transparency, never magic — the
70
+ * caller can always see whether a passkey was skipped.
71
71
  */
72
- source: 'mandate' | 'fresh-tap' | null;
72
+ source: 'mandate' | null;
73
73
  /** Remaining mandate budget (minor units) after a mandate draw; else null. */
74
74
  remainingMinor: number | null;
75
75
  /**
@@ -318,8 +318,7 @@ export function createCliCheckoutEngine(deps = {}) {
318
318
  };
319
319
  // BUDGET mode: the ceiling target's amount IS the approved ceiling, so the
320
320
  // server mints a budget mint token bound to that ceiling — later draws pull
321
- // sub-ceiling amounts against it tap-free (the single-purchase fresh-tap
322
- // path below stays non-budget).
321
+ // sub-ceiling amounts against it tap-free.
323
322
  const assurance = await runHostedApproval({
324
323
  baseUrl: input.approvalBaseUrl,
325
324
  tokenId: credential.tokenId,
@@ -623,14 +622,15 @@ export function createCliCheckoutEngine(deps = {}) {
623
622
  try {
624
623
  // NO instrument read here. A tap-free mandate draw spends `covering
625
624
  // .tokenId` (frozen into the mandate at start) and needs neither the
626
- // legacy credential file nor a grant token; only the fresh-tap branch
627
- // below needs one, and it resolves lazily so a grant-only device with an
628
- // as-yet-unrefreshed token can still draw on a mandate it already holds.
625
+ // legacy credential file nor a grant token, so a grant-only device with
626
+ // an as-yet-unrefreshed token can still draw on a mandate it already
627
+ // holds.
629
628
  const host = new URL(session.target.merchantUrl).hostname;
630
629
  // Does an ACTIVE card mandate already cover this exact purchase? If so,
631
- // draw against it TAP-FREE (no hosted passkey). Else fall back to today's
632
- // 1:1 fresh-tap flow. Mandates no longer persist the bootstrap token:
633
- // it is consumed by intent creation + register and has no draw power.
630
+ // draw against it TAP-FREE (no hosted passkey). Else refuse with the
631
+ // mandate remedy (#7348). Mandates no longer persist the bootstrap
632
+ // token: it is consumed by intent creation + register and has no draw
633
+ // power.
634
634
  const covering = await ledger.findCovering({
635
635
  merchantHost: host,
636
636
  currencyCode: session.currency,
@@ -640,9 +640,8 @@ export function createCliCheckoutEngine(deps = {}) {
640
640
  });
641
641
  let source;
642
642
  let confirmBase;
643
- // Fresh purchases confirm with their one-purchase mint token. Mandate
644
- // draws set this only after the local reserve succeeds and auth returns
645
- // the exact per-draw verdict used to mint the credential.
643
+ // Mandate draws set this only after the local reserve succeeds and auth
644
+ // returns the exact per-draw verdict used to mint the credential.
646
645
  let confirmationAuthority = null;
647
646
  let drawnRemaining = null;
648
647
  let instrument;
@@ -667,7 +666,7 @@ export function createCliCheckoutEngine(deps = {}) {
667
666
  confirmationRef: null,
668
667
  receiptPath: null,
669
668
  detail: 'this mandate cannot draw because its delegated card authority is unavailable; ' +
670
- 'restore the runtime binding or use a fresh per-purchase approval',
669
+ 're-pair this runtime to restore the binding, or start a new mandate for this agent',
671
670
  vicConfirmation: null,
672
671
  source,
673
672
  remainingMinor: null,
@@ -679,7 +678,7 @@ export function createCliCheckoutEngine(deps = {}) {
679
678
  confirmationRef: null,
680
679
  receiptPath: null,
681
680
  detail: 'this budget belongs to a different request key than the selected card capability; ' +
682
- 'restore that exact runtime key or use a fresh per-purchase approval',
681
+ 'restore that exact runtime key, or start a new mandate from the current runtime',
683
682
  vicConfirmation: null,
684
683
  source,
685
684
  remainingMinor: null,
@@ -739,7 +738,8 @@ export function createCliCheckoutEngine(deps = {}) {
739
738
  // (MandateDrawDeclinedError). That case leaves the mandate
740
739
  // active+covering, so a naive retry would re-select it and fail
741
740
  // identically — trapping the caller; marking it unhonored makes the
742
- // next pay_merchant skip it and take a fresh per-purchase tap. We do
741
+ // next pay_merchant skip it and surface the no-covering-mandate refusal
742
+ // (#7348) instead of re-failing identically. We do
743
743
  // NOT attempt an unsafe same-call browser fallback mid-submit.
744
744
  //
745
745
  // A PRE-network failure — reserve() failing closed on a concurrent
@@ -765,9 +765,7 @@ export function createCliCheckoutEngine(deps = {}) {
765
765
  }
766
766
  throw new Error(verdictFailure.transient
767
767
  ? `the card-mandate draw could not be authorized right now (${verdictFailure.reasons.join(', ') || 'temporary error'}) — retry shortly`
768
- : `this card mandate can no longer be drawn (${verdictFailure.reasons.join(', ') || 'refused'}); it has been disabled — ${isV4CardGrant
769
- ? 'create and claim a new mandate before retrying this v4 checkout'
770
- : 'retry the checkout to use a legacy fresh per-purchase approval'}`, { cause: err });
768
+ : `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 });
771
769
  }
772
770
  if (!isTransientDrawFailure(err.cause ?? err)) {
773
771
  await ledger.markUnhonored(mandateId, now());
@@ -785,8 +783,8 @@ export function createCliCheckoutEngine(deps = {}) {
785
783
  instrument = new VgsLiveInstrument(reference, session.contact.fullName ?? '', fetchCredential);
786
784
  }
787
785
  else {
788
- // #7348 fresh-tap retirement: card spend is mandate-only for EVERY
789
- // runtime. The legacy 1:1 fresh-tap flow that lived here minted a
786
+ // #7348 single-purchase retirement: card spend is mandate-only for
787
+ // EVERY runtime. The legacy 1:1 single-purchase flow here minted a
790
788
  // payable credential outside the grant/mandate ledger — invisible to
791
789
  // spending controls. The server now refuses non-budget approvals at
792
790
  // registration and non-budget mint tokens at verification; this
@@ -34,23 +34,24 @@ export type HostedApprovalOptions = {
34
34
  /** Prefills the approval page's enrollment-email field (optional). */
35
35
  consumerEmail?: string;
36
36
  /**
37
- * BUDGET/mandate approval: `target.transactionAmount` is the approved spend
38
- * CEILING (not one charge), so the server mints a BUDGET mint token that later
39
- * accepts many sub-ceiling draws tap-free. Omit for a single-purchase approval.
37
+ * BUDGET/mandate approval the only kind since #7348 retired the
38
+ * single-purchase plane: `target.transactionAmount` is the approved spend
39
+ * CEILING (not one charge), so the server mints a BUDGET mint token that
40
+ * later accepts many sub-ceiling draws tap-free.
40
41
  */
41
- budget?: boolean;
42
+ budget: true;
42
43
  /**
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.
44
+ * Exact current request-key thumbprint receiving the budget; the
45
+ * authenticated page resolves it to the owner's stable agent before
46
+ * displaying or signing.
46
47
  */
47
- agentJkt?: string;
48
- /** Advisory max draws the ceiling intent may fulfil — carried onto the token. */
49
- maxDraws?: number;
48
+ agentJkt: string;
49
+ /** Max draws the ceiling intent may fulfil — carried onto the token. */
50
+ maxDraws: number;
50
51
  /**
51
- * Per-purchase cap (decimal string, <= the ceiling) on a BUDGET approval
52
- * shown on the approval page as a worst-case term and carried onto the mint
53
- * token so the displayed cap is the enforced cap. Budget mode only.
52
+ * Per-purchase cap (decimal string, <= the ceiling) — shown on the approval
53
+ * page as a worst-case term and carried onto the mint token so the displayed
54
+ * cap is the enforced cap.
54
55
  */
55
56
  perTransaction?: string;
56
57
  /**
@@ -128,7 +128,7 @@ function stripTrailingSlashes(value) {
128
128
  * rather than falling back to a client-held secret.
129
129
  */
130
130
  export async function runHostedApproval(opts) {
131
- const { baseUrl, tokenId, target, consumerEmail, budget, agentJkt, maxDraws, perTransaction, intent,
131
+ const { baseUrl, tokenId, target, consumerEmail, agentJkt, maxDraws, perTransaction, intent,
132
132
  // Default to stderr, NOT a no-op: the approval URL is the one thing a remote /
133
133
  // SSH / headless-terminal human needs to proceed, and swallowing it (the old
134
134
  // `() => {}` default) left it emitted nowhere readable. Tests inject their own.
@@ -138,12 +138,9 @@ export async function runHostedApproval(opts) {
138
138
  // override without threading a flag through every caller.
139
139
  timeoutMs = resolveApprovalTimeoutMs(), } = opts;
140
140
  const canonicalJkt = typeof agentJkt === 'string' && /^[A-Za-z0-9_-]{43}$/.test(agentJkt);
141
- if (budget && !canonicalJkt) {
141
+ if (!canonicalJkt) {
142
142
  throw new Error('a budget approval requires the exact current agent request-key JKT');
143
143
  }
144
- if (!budget && agentJkt !== undefined) {
145
- throw new Error('agentJkt is accepted only for a budget approval');
146
- }
147
144
  const base = stripTrailingSlashes(assertApprovalBaseUrl(baseUrl));
148
145
  // The server's currency map is uppercase ISO 4217; the loader normalizes the
149
146
  // target once, but normalize here too so a direct caller with a lowercase
@@ -194,10 +191,10 @@ export async function runHostedApproval(opts) {
194
191
  amount: target.transactionAmount,
195
192
  currency,
196
193
  ...(consumerEmail ? { consumerEmail } : {}),
197
- ...(budget ? { budget: true } : {}),
198
- ...(budget ? { agentJkt } : {}),
199
- ...(budget && maxDraws !== undefined ? { maxDraws } : {}),
200
- ...(budget && perTransaction !== undefined ? { perTransaction } : {}),
194
+ budget: true,
195
+ agentJkt,
196
+ maxDraws,
197
+ ...(perTransaction !== undefined ? { perTransaction } : {}),
201
198
  ...(intentSanitized !== undefined ? { intent: intentSanitized } : {}),
202
199
  },
203
200
  }),
@@ -214,9 +211,7 @@ export async function runHostedApproval(opts) {
214
211
  // effort browser open for the interactive case.
215
212
  onApprovalUrl?.(approveUrl);
216
213
  log(`approve the purchase in your browser: ${approveUrl}`);
217
- if (budget && agentJkt) {
218
- log(`budget recipient request key: ${agentJkt.slice(0, 8)}…${agentJkt.slice(-6)}`);
219
- }
214
+ log(`budget recipient request key: ${agentJkt.slice(0, 8)}…${agentJkt.slice(-6)}`);
220
215
  openUrl(approveUrl);
221
216
  let lastHeartbeat = now();
222
217
  // Keep the event loop alive for the whole poll wait. `defaultSleep` unref()'s
@@ -270,33 +265,29 @@ export async function runHostedApproval(opts) {
270
265
  }
271
266
  }
272
267
  // A budget approval must complete AS a budget approval — a relay that
273
- // dropped the flag would mint a single-purchase token that then rejects
274
- // the first sub-ceiling draw. Compare the boolean explicitly (it is not a
275
- // string, so it lives outside the string-map loop above).
276
- if (Boolean(doc.context?.budget) !== Boolean(budget)) {
268
+ // dropped the flag did not complete OUR registration. Compare the
269
+ // boolean explicitly (it is not a string, so it lives outside the
270
+ // string-map loop above).
271
+ if (doc.context?.budget !== true) {
277
272
  throw new Error('hosted approval context mismatch on budget — the approval was not for this ' +
278
273
  'exact purchase; run the checkout again for a fresh link');
279
274
  }
280
- if (budget && doc.context?.agentJkt !== agentJkt) {
275
+ if (doc.context?.agentJkt !== agentJkt) {
281
276
  throw new Error('hosted approval context mismatch on agentJkt — the approval was not for this ' +
282
277
  'exact request key; run the checkout again for a fresh link');
283
278
  }
284
- if (!budget && doc.context?.agentJkt !== undefined) {
285
- throw new Error('hosted approval unexpectedly carried an agentJkt for a one-purchase request');
286
- }
287
- // The advisory draw ceiling must survive the relay intact too — a relay
288
- // that dropped or altered maxDraws would mint a budget token whose draw
289
- // count no longer matches what the operator approved. Only the budget
290
- // path registers maxDraws, so only enforce it there; compare exactly,
279
+ // The draw ceiling must survive the relay intact too — a relay that
280
+ // dropped or altered maxDraws would mint a budget token whose draw
281
+ // count no longer matches what the operator approved. Compare exactly,
291
282
  // the omitted/undefined case included, so a silently dropped value is
292
283
  // caught the same as a mutated one.
293
- if (budget && doc.context?.maxDraws !== maxDraws) {
284
+ if (doc.context?.maxDraws !== maxDraws) {
294
285
  throw new Error('hosted approval context mismatch on maxDraws — the approval was not for this ' +
295
286
  'exact purchase; run the checkout again for a fresh link');
296
287
  }
297
288
  // Same drop-or-mutate rule for the per-purchase cap: the term the
298
289
  // operator read must be the term the token enforces.
299
- if (budget && doc.context?.perTransaction !== perTransaction) {
290
+ if (doc.context?.perTransaction !== perTransaction) {
300
291
  throw new Error('hosted approval context mismatch on perTransaction — the approval was not for ' +
301
292
  'this exact purchase; run the checkout again for a fresh link');
302
293
  }
@@ -306,14 +297,9 @@ export async function runHostedApproval(opts) {
306
297
  throw new Error('hosted approval context mismatch on intent — the approval was not for this ' +
307
298
  'exact purchase; run the checkout again for a fresh link');
308
299
  }
309
- if (budget) {
310
- if (!Number.isSafeInteger(doc.validUntil) ||
311
- doc.validUntil <= Math.floor(now() / 1000)) {
312
- throw new Error('hosted budget approval completed without a valid future expiry');
313
- }
314
- }
315
- else if (doc.validUntil !== undefined) {
316
- throw new Error('hosted one-purchase approval unexpectedly carried a budget expiry');
300
+ if (!Number.isSafeInteger(doc.validUntil) ||
301
+ doc.validUntil <= Math.floor(now() / 1000)) {
302
+ throw new Error('hosted budget approval completed without a valid future expiry');
317
303
  }
318
304
  log(assuranceExempt
319
305
  ? 'approval received from the hosted page (code-verified card, no passkey).'
@@ -324,7 +310,7 @@ export async function runHostedApproval(opts) {
324
310
  ...(typeof doc.mintToken === 'string' && doc.mintToken
325
311
  ? { mintToken: doc.mintToken }
326
312
  : {}),
327
- ...(budget ? { validUntil: doc.validUntil } : {}),
313
+ validUntil: doc.validUntil,
328
314
  };
329
315
  }
330
316
  // status 'pending' — the operator is still signing in / tapping.
@@ -18,7 +18,7 @@
18
18
  // stays FAIL-CLEAN as defense-in-depth: the budget is decremented only after a
19
19
  // payable cryptogram returns; a network rejection leaves the budget untouched.
20
20
  // The caller (cli-engine) then marks the mandate unhonored so the NEXT
21
- // pay_merchant skips it and takes a fresh per-purchase tap — the same call is
21
+ // pay_merchant skips it and surfaces the no-covering refusal (#7348) — the same call is
22
22
  // not retried in-flight. Never fail-open.
23
23
  import { decimalToMinor, minorToDecimal, validateCredential, } from '../vgs-live-instrument.js';
24
24
  import { CARD_MANDATE_LEDGER_VERSION, remainingMinor, } from './mandate-ledger.js';
@@ -47,7 +47,7 @@ export type CardMandateRecord = {
47
47
  * ceiling-scoped assurance the acquirer would not honor for a sub-amount
48
48
  * draw). Distinct from a reservation `status`: it disables the whole mandate.
49
49
  * Once set, findCovering() SKIPS this mandate so the caller's next
50
- * pay_merchant falls through to a fresh per-purchase tap instead of
50
+ * pay_merchant surfaces the no-covering-mandate refusal (#7348) instead of
51
51
  * re-selecting the same failing mandate forever. ISO 8601.
52
52
  */
53
53
  unhonoredAt?: string;
@@ -55,8 +55,8 @@ export type CardMandateRecord = {
55
55
  * Set when the delegated-draw register handshake FAILED at mandate-start (the
56
56
  * server-side `card_mandate_spend` row was never created), so a later
57
57
  * delegated (verdict-signed) draw would 404 `no_mandate`. Like `unhonoredAt`,
58
- * findCovering() SKIPS a register-failed mandate so the next checkout falls
59
- * through to a fresh per-purchase tap instead of surfacing a confusing
58
+ * findCovering() SKIPS a register-failed mandate so the next checkout
59
+ * surfaces the no-covering-mandate refusal (#7348) instead of a confusing
60
60
  * `no_mandate`. Mandate-start requires delegated card authority, so every
61
61
  * created record attempted registration. ISO 8601.
62
62
  */
@@ -109,7 +109,7 @@ export declare class MandateLedger {
109
109
  /**
110
110
  * First ACTIVE mandate (not expired) whose merchant + currency match and whose
111
111
  * remaining headroom covers amountMinor. Used by pay_merchant to decide the
112
- * tap-free draw path vs a fresh per-purchase tap.
112
+ * tap-free draw path vs the no-covering-mandate refusal (#7348).
113
113
  */
114
114
  findCovering(query: CoverQuery): Promise<CardMandateRecord | null>;
115
115
  /**
@@ -174,7 +174,7 @@ export class MandateLedger {
174
174
  /**
175
175
  * First ACTIVE mandate (not expired) whose merchant + currency match and whose
176
176
  * remaining headroom covers amountMinor. Used by pay_merchant to decide the
177
- * tap-free draw path vs a fresh per-purchase tap.
177
+ * tap-free draw path vs the no-covering-mandate refusal (#7348).
178
178
  */
179
179
  async findCovering(query) {
180
180
  const now = query.now ?? new Date();
@@ -184,7 +184,7 @@ export class MandateLedger {
184
184
  // mandate the network refused is skipped via `!m.unhonoredAt` so it can never
185
185
  // be re-selected. A register-failed mandate is skipped the same way
186
186
  // (`!m.registerFailedAt`): it has no server row, so a delegated draw would 404
187
- // `no_mandate` — better to fall through to a fresh per-purchase tap.
187
+ // `no_mandate` — better to surface the no-covering refusal (#7348).
188
188
  // Selection order among covering candidates:
189
189
  // 1. Prefer a merchant-SCOPED mandate over a crossMerchant budget one —
190
190
  // spend the dedicated grant for this merchant first and keep the broader
@@ -37,13 +37,13 @@ export declare class ServerCryptogramRefusedError extends Error {
37
37
  constructor(status: number, detail: string);
38
38
  }
39
39
  /**
40
- * Optional mandate override for serverCreateIntent. Absent → the historical
41
- * 1:1 fresh-tap shape is preserved byte-for-byte (cap = ceil(amount)+10 min 25,
42
- * quantity 1, 30-day window). Present → the card-mandate (budget) layer sets a
43
- * CEILING decline threshold and a multi-draw quantity so one passkey approves a
44
- * spend ceiling and later draws pull cryptograms under it without a fresh tap.
45
- * Whether the network honors a ceiling-scoped assurance across multiple
46
- * sub-amount draws is UNPROVEN — see packages/checkout-engine/src/mandate/.
40
+ * Optional mandate override for serverCreateIntent. Absent → the default
41
+ * intent shape (cap = ceil(amount)+10 min 25, quantity 1, 30-day window).
42
+ * Present → the card-mandate (budget) layer sets a CEILING decline threshold
43
+ * and a multi-draw quantity so one passkey approves a spend ceiling and later
44
+ * draws pull cryptograms under it without a fresh tap. Whether the network
45
+ * honors a ceiling-scoped assurance across multiple sub-amount draws is
46
+ * UNPROVEN — see packages/checkout-engine/src/mandate/.
47
47
  */
48
48
  export type ServerIntentMandateOverride = {
49
49
  /** Wire decline-threshold amount (major-unit decimal string), e.g. "500.00". */
@@ -80,14 +80,6 @@ export type FetchVgsPaymentCredential = (input: {
80
80
  transactionCurrencyCode: string;
81
81
  };
82
82
  }) => Promise<VgsPaymentCredential>;
83
- export type MintFreshVgsPaymentCredential = (input: {
84
- tokenId: string;
85
- assuranceData: unknown;
86
- transaction: VgsCheckoutTarget;
87
- }) => Promise<{
88
- payment: VgsPaymentCredential;
89
- intentId: string;
90
- }>;
91
83
  /** The (token, intent) pair a VIC confirmation is posted against. */
92
84
  export type VicConfirmationTarget = {
93
85
  tokenId: string;
@@ -150,30 +142,3 @@ export declare class VgsLiveInstrument implements Instrument {
150
142
  confirmationTarget(): VicConfirmationTarget | null;
151
143
  getCredential(ctx: InstrumentContext): Promise<CardCredential>;
152
144
  }
153
- /**
154
- * Consumes #5614's claimed CLI enrollment artifact (for the tokenId) plus a
155
- * FRESH purchase-scoped assurance, and creates a fresh, transaction-scoped VIC
156
- * intent + credential after checkout approval. This is the production-shaped
157
- * bridge; unlike VgsLiveInstrument it never needs a pre-created intent ID.
158
- *
159
- * The enrollment artifact's stored assuranceData is deliberately never sent:
160
- * replaying it makes intent creation succeed (HTTP 201) while the cryptogram
161
- * deterministically never completes — the #5709 dead end. Purchase
162
- * authorization is the fresh, merchant+amount+currency-scoped assurance,
163
- * validated against the checkout target BEFORE any intent is minted so a
164
- * doomed intent is never created.
165
- */
166
- export declare class VgsAssuranceInstrument implements Instrument {
167
- private readonly enrollment;
168
- private readonly purchase;
169
- private readonly target;
170
- private readonly cardholderName;
171
- private readonly mintCredential;
172
- readonly kind: "agentic-token";
173
- private used;
174
- private minted;
175
- constructor(enrollment: CliAgentCredential, purchase: PurchaseAssurance, target: VgsCheckoutTarget, cardholderName: string, mintCredential: MintFreshVgsPaymentCredential);
176
- /** See VgsLiveInstrument.confirmationTarget — same contract. */
177
- confirmationTarget(): VicConfirmationTarget | null;
178
- getCredential(ctx: InstrumentContext): Promise<CardCredential>;
179
- }
@@ -87,13 +87,6 @@ function validateTarget(reference, ctx) {
87
87
  throw new Error(`VGS credential currency mismatch: ${currency} vs ${ctx.currency}`);
88
88
  }
89
89
  }
90
- function validateCliCredential(value) {
91
- if (typeof value.tokenId !== 'string' || !value.tokenId.trim()) {
92
- throw new Error('CLI agent credential requires tokenId');
93
- }
94
- // Deliberately no assuranceData requirement: the artifact's enrollment-time
95
- // assurance is identity/enrollment material and is never read here (#5709).
96
- }
97
90
  /**
98
91
  * Refuse to mint an intent unless the assurance is fresh and its declared
99
92
  * scope matches the checkout target exactly. `now` is injectable so the
@@ -234,63 +227,3 @@ export class VgsLiveInstrument {
234
227
  };
235
228
  }
236
229
  }
237
- /**
238
- * Consumes #5614's claimed CLI enrollment artifact (for the tokenId) plus a
239
- * FRESH purchase-scoped assurance, and creates a fresh, transaction-scoped VIC
240
- * intent + credential after checkout approval. This is the production-shaped
241
- * bridge; unlike VgsLiveInstrument it never needs a pre-created intent ID.
242
- *
243
- * The enrollment artifact's stored assuranceData is deliberately never sent:
244
- * replaying it makes intent creation succeed (HTTP 201) while the cryptogram
245
- * deterministically never completes — the #5709 dead end. Purchase
246
- * authorization is the fresh, merchant+amount+currency-scoped assurance,
247
- * validated against the checkout target BEFORE any intent is minted so a
248
- * doomed intent is never created.
249
- */
250
- export class VgsAssuranceInstrument {
251
- enrollment;
252
- purchase;
253
- target;
254
- cardholderName;
255
- mintCredential;
256
- kind = 'agentic-token';
257
- used = false;
258
- minted = null;
259
- constructor(enrollment, purchase, target, cardholderName, mintCredential) {
260
- this.enrollment = enrollment;
261
- this.purchase = purchase;
262
- this.target = target;
263
- this.cardholderName = cardholderName;
264
- this.mintCredential = mintCredential;
265
- }
266
- /** See VgsLiveInstrument.confirmationTarget — same contract. */
267
- confirmationTarget() {
268
- return this.minted;
269
- }
270
- async getCredential(ctx) {
271
- if (this.used)
272
- throw new Error('VGS assurance instrument is single-use');
273
- this.used = true;
274
- validateCliCredential(this.enrollment);
275
- validateTarget(this.target, ctx);
276
- validatePurchaseAssurance(this.purchase, this.target);
277
- if (!this.cardholderName.trim())
278
- throw new Error('cardholder name is required');
279
- const { payment: value, intentId } = await this.mintCredential({
280
- tokenId: this.enrollment.tokenId,
281
- assuranceData: this.purchase.assuranceData,
282
- transaction: this.target,
283
- });
284
- this.minted = { tokenId: this.enrollment.tokenId, intentId };
285
- validateCredential(value);
286
- return {
287
- pan: value.networkToken,
288
- expMonth: value.expMonth,
289
- expYear: value.expYear,
290
- cvc: value.cryptogramValue,
291
- cardholderName: this.cardholderName.trim(),
292
- ...(value.cryptogramExpiresAt ? { credentialExpiresAt: value.cryptogramExpiresAt } : {}),
293
- ...traceHandleFields(value),
294
- };
295
- }
296
- }