@visa/cli 4.1.0-rc.25 → 4.1.0-rc.27

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 (40) hide show
  1. package/README.md +132 -242
  2. package/dist/checkout-engine/cli-engine.d.ts +142 -0
  3. package/dist/checkout-engine/cli-engine.js +377 -35
  4. package/dist/checkout-engine/detect.d.ts +1 -1
  5. package/dist/checkout-engine/detect.js +20 -0
  6. package/dist/checkout-engine/evidence.d.ts +3 -0
  7. package/dist/checkout-engine/evidence.js +51 -6
  8. package/dist/checkout-engine/executor.d.ts +3 -1
  9. package/dist/checkout-engine/executor.js +75 -2
  10. package/dist/checkout-engine/hosted-approval.d.ts +64 -7
  11. package/dist/checkout-engine/hosted-approval.js +194 -54
  12. package/dist/checkout-engine/index.d.ts +4 -1
  13. package/dist/checkout-engine/index.js +3 -0
  14. package/dist/checkout-engine/instrument.d.ts +1 -0
  15. package/dist/checkout-engine/instrument.js +4 -0
  16. package/dist/checkout-engine/live-fill-approval.d.ts +0 -9
  17. package/dist/checkout-engine/live-fill-approval.js +0 -17
  18. package/dist/checkout-engine/mandate/card-mandate.d.ts +117 -0
  19. package/dist/checkout-engine/mandate/card-mandate.js +221 -0
  20. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +135 -0
  21. package/dist/checkout-engine/mandate/mandate-ledger.js +318 -0
  22. package/dist/checkout-engine/outcome.d.ts +2 -2
  23. package/dist/checkout-engine/outcome.js +36 -1
  24. package/dist/checkout-engine/owner-only-file.d.ts +9 -0
  25. package/dist/checkout-engine/owner-only-file.js +20 -1
  26. package/dist/checkout-engine/run-live-fill.js +151 -101
  27. package/dist/checkout-engine/types.d.ts +13 -0
  28. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +34 -1
  29. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +35 -7
  30. package/dist/checkout-engine/vgs-live-instrument.d.ts +27 -0
  31. package/dist/checkout-engine/vgs-live-instrument.js +37 -0
  32. package/dist/cli.js +268 -385
  33. package/dist/mcp-server/index.js +249 -159
  34. package/dist/skills/pair-visa-agent/RUNTIMES.md +1 -1
  35. package/dist/skills/pair-visa-agent/SKILL.md +89 -47
  36. package/install.ps1 +3 -41
  37. package/install.sh +3 -35
  38. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  39. package/package.json +5 -4
  40. package/server.json +3 -3
@@ -16,16 +16,43 @@ 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
18
  import { runHostedApproval as realRunHostedApproval } from './hosted-approval.js';
19
- import { VgsAssuranceInstrument, decimalToMinor, } from './vgs-live-instrument.js';
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';
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
+ }
23
49
  const RECEIPT_DIR = join(homedir(), '.visa-mcp', 'checkout-receipts');
24
50
  // Must match the prepared-checkout store TTL so a session and its store entry
25
51
  // expire together — an abandoned review can't leak the browser + state.
26
52
  const PREPARED_TTL_MS = 5 * 60 * 1000;
27
53
  const defaultStore = new InMemoryPreparedCheckoutStore({ ttlMs: PREPARED_TTL_MS });
28
54
  const defaultSessions = new Map();
55
+ const defaultLedger = new MandateLedger();
29
56
  export function createCliCheckoutEngine(deps = {}) {
30
57
  const store = deps.store ?? defaultStore;
31
58
  const sessions = deps.sessions ?? defaultSessions;
@@ -36,6 +63,11 @@ export function createCliCheckoutEngine(deps = {}) {
36
63
  const runHostedApproval = deps.runHostedApproval ?? realRunHostedApproval;
37
64
  const reportVicOutcome = deps.reportVicOutcome ?? realReportVicOutcome;
38
65
  const writeReceipt = deps.writeReceipt ?? realWriteReceipt;
66
+ const ledger = deps.ledger ?? defaultLedger;
67
+ const now = deps.now ?? (() => new Date());
68
+ const fetchMandateCryptogram = deps.serverFetchCryptogram ?? serverFetchCryptogram;
69
+ const cardDrawVerdict = deps.cardDrawVerdict ?? null;
70
+ const cardMandateRegister = deps.cardMandateRegister ?? null;
39
71
  async function closeSession(reviewId) {
40
72
  const session = sessions.get(reviewId);
41
73
  if (!session)
@@ -54,6 +86,134 @@ export function createCliCheckoutEngine(deps = {}) {
54
86
  };
55
87
  }
56
88
  return {
89
+ // BUDGET step: one passkey approves a CEILING; a VGS intent is minted with
90
+ // that ceiling as its decline threshold and the owner-only ledger records
91
+ // the cumulative budget. No browser checkout is prepared — this is purely
92
+ // the passkey ceremony + intent, so later pay() draws need no fresh tap.
93
+ async startCardMandate(input) {
94
+ const ceilingMinor = decimalToMinor(input.ceiling);
95
+ if (ceilingMinor === null || ceilingMinor <= 0) {
96
+ throw new Error(`invalid mandate ceiling ${JSON.stringify(input.ceiling)}`);
97
+ }
98
+ if (input.perTransaction !== undefined) {
99
+ const perTxMinor = decimalToMinor(input.perTransaction);
100
+ if (perTxMinor === null || perTxMinor <= 0 || perTxMinor > ceilingMinor) {
101
+ throw new Error(`invalid mandate perTransaction ${JSON.stringify(input.perTransaction)} — ` +
102
+ 'must be a positive amount at or under the ceiling');
103
+ }
104
+ }
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');
111
+ }
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
+ };
123
+ const credential = JSON.parse(await readFile(input.credentialPath, 'utf8'));
124
+ // The passkey ceremony is scoped to the CEILING + merchant (not one
125
+ // charge) — that scope is the unproven part of the spike.
126
+ const ceilingTarget = {
127
+ merchantName: merchant.name,
128
+ merchantUrl: merchant.url,
129
+ merchantCountryCode: merchant.countryCode,
130
+ transactionAmount: input.ceiling,
131
+ transactionCurrencyCode: input.currency,
132
+ };
133
+ // BUDGET mode: the ceiling target's amount IS the approved ceiling, so the
134
+ // server mints a budget mint token bound to that ceiling — later draws pull
135
+ // sub-ceiling amounts against it tap-free (the single-purchase fresh-tap
136
+ // path below stays non-budget).
137
+ const assurance = await runHostedApproval({
138
+ baseUrl: input.approvalBaseUrl,
139
+ tokenId: credential.tokenId,
140
+ target: ceilingTarget,
141
+ consumerEmail: input.contact.email,
142
+ budget: true,
143
+ onApprovalUrl: deps.onApprovalUrl,
144
+ ...(input.maxDraws !== undefined ? { maxDraws: input.maxDraws } : {}),
145
+ ...(input.perTransaction !== undefined ? { perTransaction: input.perTransaction } : {}),
146
+ ...(input.intent !== undefined ? { intent: input.intent } : {}),
147
+ });
148
+ const mintToken = assurance.mintToken;
149
+ if (!mintToken) {
150
+ throw new Error('the approval server issued no mint token — server-side minting requires the ' +
151
+ 'verify-web deployment to run real/turnkey auth (not the dev stub). Retry once it does.');
152
+ }
153
+ const expiresAt = input.expiresAt ?? new Date(now().getTime() + 24 * 60 * 60 * 1000).toISOString();
154
+ const facts = await createCardMandate({
155
+ tokenId: credential.tokenId,
156
+ assuranceData: assurance.assuranceData,
157
+ ceilingMinor,
158
+ merchant,
159
+ currencyCode: input.currency,
160
+ expiresAt,
161
+ ...(input.maxDraws !== undefined ? { maxDraws: input.maxDraws } : {}),
162
+ ...(anyMerchant ? { crossMerchant: true } : {}),
163
+ }, {
164
+ createIntent: (i) => serverCreateIntent(input.approvalBaseUrl, mintToken, i),
165
+ ledger,
166
+ approvalBaseUrl: input.approvalBaseUrl,
167
+ mintToken,
168
+ now,
169
+ });
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;
176
+ let registerFailed = false;
177
+ if (registerCap && cardMandateRegister) {
178
+ const reg = await cardMandateRegister
179
+ .register({
180
+ authBaseUrl: registerCap.authBaseUrl,
181
+ agentKey: registerCap.agentKey,
182
+ mandateId: facts.mandateId,
183
+ mintToken,
184
+ ceiling: input.ceiling,
185
+ currency: input.currency,
186
+ })
187
+ .catch((err) => ({
188
+ ok: false,
189
+ reason: err instanceof Error ? err.message : String(err),
190
+ }));
191
+ if (!reg.ok) {
192
+ registerFailed = true;
193
+ // Register failed: the server `card_mandate_spend` row was never
194
+ // created, so a delegated draw against this mandate would 404
195
+ // `no_mandate`. Mark it register-failed so findCovering() SKIPS it and
196
+ // the owner's next checkout falls through to a fresh per-purchase tap,
197
+ // rather than silently selecting a mandate that can't be drawn. Marking
198
+ // is best-effort too — a failure here must not abort mandate-start.
199
+ const marked = await ledger
200
+ .markRegisterFailed(facts.mandateId, now())
201
+ .then(() => true)
202
+ .catch(() => false);
203
+ process.stderr.write(marked
204
+ ? `warning: card-mandate register failed (${reg.reason ?? 'unknown'}) — this mandate ` +
205
+ `will NOT be used for tap-free draws; your next checkout will use a fresh ` +
206
+ `per-purchase passkey tap. Re-run 'mandate start' to try again.\n`
207
+ : // Escalate: the mark write ALSO failed, so the mandate is persisted but
208
+ // NOT disabled — findCovering could still select an undrawable mandate.
209
+ // Tell the owner loudly not to rely on it and how to recover.
210
+ `warning: card-mandate register failed (${reg.reason ?? 'unknown'}) AND the mandate ` +
211
+ `could NOT be disabled locally — do not rely on it. Run 'mandate list' and re-run ` +
212
+ `'mandate start'. mandateId=${facts.mandateId}\n`);
213
+ }
214
+ }
215
+ return { ...facts, merchantHost: new URL(merchant.url).hostname, registerFailed };
216
+ },
57
217
  async review(input) {
58
218
  const amountMinor = decimalToMinor(input.amount);
59
219
  if (amountMinor === null)
@@ -113,6 +273,8 @@ export function createCliCheckoutEngine(deps = {}) {
113
273
  receiptPath: null,
114
274
  detail: `no prepared review ${input.reviewId} — call review first (a review does not survive a restart)`,
115
275
  vicConfirmation: null,
276
+ source: null,
277
+ remainingMinor: null,
116
278
  };
117
279
  }
118
280
  // Amount-bind the confirmation: the pay-call amount must match the
@@ -128,6 +290,8 @@ export function createCliCheckoutEngine(deps = {}) {
128
290
  receiptPath: null,
129
291
  detail: `pay amount ${JSON.stringify(input.amount)} does not match the reviewed amount (${session.amountMinor} minor units) — start a fresh review`,
130
292
  vicConfirmation: null,
293
+ source: null,
294
+ remainingMinor: null,
131
295
  };
132
296
  }
133
297
  // Reject an expired prepared checkout BEFORE running the hosted passkey
@@ -141,48 +305,224 @@ export function createCliCheckoutEngine(deps = {}) {
141
305
  receiptPath: null,
142
306
  detail: `prepared review ${input.reviewId} expired — start a fresh review`,
143
307
  vicConfirmation: null,
308
+ source: null,
309
+ remainingMinor: null,
144
310
  };
145
311
  }
146
312
  clearTimeout(session.cleanupTimer);
147
313
  try {
148
314
  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,
315
+ const host = new URL(session.target.merchantUrl).hostname;
316
+ // Does an ACTIVE card mandate already cover this exact purchase? If so,
317
+ // 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.
320
+ const covering = await ledger.findCovering({
321
+ merchantHost: host,
322
+ currencyCode: session.currency,
323
+ amountMinor: session.amountMinor,
324
+ now: now(),
154
325
  });
155
- // Server-side mint (Phase 1): the approval claim releases a scoped mint
156
- // token; the credential is minted by the verify-web deployment (which
157
- // holds the VGS secret), never on this machine. No token means the
158
- // deployment ran the dev-auth stub — refuse loudly rather than reach for
159
- // a client-held secret (there is none anymore).
160
- const approvalBase = input.approvalBaseUrl;
161
- const mintToken = assurance.mintToken;
162
- if (!mintToken) {
163
- return {
164
- outcome: 'failed',
165
- confirmationRef: null,
166
- receiptPath: null,
167
- detail: 'the approval server issued no mint token — server-side minting requires the ' +
168
- 'verify-web deployment to run real/turnkey auth (not the dev stub). Retry once it does.',
169
- vicConfirmation: null,
326
+ let source;
327
+ let confirmBase;
328
+ let confirmToken;
329
+ let drawnRemaining = null;
330
+ let instrument;
331
+ if (covering && covering.mintToken) {
332
+ // --- Tap-free mandate draw ------------------------------------------
333
+ source = 'mandate';
334
+ 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
+ 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).
345
+ //
346
+ // #5928 CRITICAL-2: the CLI does NOT settle the reservation. verify-web
347
+ // (which knows whether the cryptogram was payable) commits it on a
348
+ // payable mint and releases it on a decline, server-authoritatively, so
349
+ // 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
+ }
416
+ }
417
+ // VgsLiveInstrument mints against an EXISTING intent (the mandate) with
418
+ // no fresh assurance — exactly the draw semantics. The fetchCredential
419
+ // seam routes through drawFromMandate so the ledger accounting (reserve
420
+ // -> commit on payable, release on reject) wraps the cryptogram pull.
421
+ const reference = {
422
+ tokenId: covering.tokenId,
423
+ intentId: mandateId,
424
+ merchantName: session.target.merchantName,
425
+ merchantUrl: session.target.merchantUrl,
426
+ merchantCountryCode: session.target.merchantCountryCode,
427
+ transactionAmount: session.target.transactionAmount,
428
+ transactionCurrencyCode: session.currency,
429
+ };
430
+ const fetchCredential = async ({ transaction }) => {
431
+ let draw;
432
+ try {
433
+ draw = await drawFromMandate({
434
+ mandateId,
435
+ amountMinor: session.amountMinor,
436
+ transaction: { ...transaction, transactionCurrencyCode: session.currency },
437
+ }, {
438
+ fetchCryptogram: (i) => fetchMandateCryptogram(confirmBase, drawToken, i),
439
+ ledger,
440
+ now,
441
+ });
442
+ }
443
+ catch (err) {
444
+ // Disable the mandate ONLY for a post-reservation NETWORK decline
445
+ // (MandateDrawDeclinedError). That case leaves the mandate
446
+ // active+covering, so a naive retry would re-select it and fail
447
+ // identically — trapping the caller; marking it unhonored makes the
448
+ // next pay_merchant skip it and take a fresh per-purchase tap. We do
449
+ // NOT attempt an unsafe same-call browser fallback mid-submit.
450
+ //
451
+ // A PRE-network failure — reserve() failing closed on a concurrent
452
+ // over-budget race or expiry, or commit() throwing on file I/O —
453
+ // is NOT a network decline: the mandate is HEALTHY, so we must
454
+ // rethrow WITHOUT disabling it (michaelyang1 M1). drawFromMandate
455
+ // has already released any reservation, so the budget is intact.
456
+ // Only a DEFINITIVE (hard) decline disables the mandate. A TRANSIENT
457
+ // failure — a gateway 5xx / "not completed, try again" / network
458
+ // reset/timeout — must NOT permanently kill the budget: the mandate
459
+ // may be perfectly healthy (it can have committed a draw seconds
460
+ // earlier) and the rails may recover on retry. Over-disabling on a
461
+ // transient 502 threw away good budgets and forced a fresh passkey
462
+ // every time.
463
+ // Classify on the underlying gateway CAUSE, not the MandateDrawDeclinedError
464
+ // wrapper — the wrapper's own advisory text mentions "try again"/"network",
465
+ // 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());
469
+ }
470
+ // #5928 CRITICAL-2: the server-side reservation is released by
471
+ // verify-web (it saw the mint fail), not here — the CLI never settles.
472
+ throw err;
473
+ }
474
+ drawnRemaining = draw.remainingMinor;
475
+ // #5928 CRITICAL-2: the reservation is committed by verify-web on the
476
+ // payable mint (server-authoritative); the CLI does not settle.
477
+ return draw.payment;
170
478
  };
479
+ instrument = new VgsLiveInstrument(reference, session.contact.fullName ?? '', fetchCredential);
171
480
  }
172
- const instrument = new VgsAssuranceInstrument(credential, assurance, session.target, session.contact.fullName ?? '', async (mintInput) => {
173
- const { intentId, status } = await serverCreateIntent(approvalBase, mintToken, mintInput);
174
- try {
175
- const payment = await serverFetchCryptogram(approvalBase, mintToken, {
176
- tokenId: mintInput.tokenId,
177
- intentId,
178
- transaction: mintInput.transaction,
179
- });
180
- return { payment, intentId };
181
- }
182
- catch (err) {
183
- throw new Error(`${err.message} (intent status at creation: ${status ?? 'unknown'})`);
481
+ else {
482
+ // --- 1:1 fresh-tap flow (unchanged) ---------------------------------
483
+ source = 'fresh-tap';
484
+ const assurance = await runHostedApproval({
485
+ baseUrl: input.approvalBaseUrl,
486
+ tokenId: credential.tokenId,
487
+ target: session.target,
488
+ consumerEmail: session.contact.email,
489
+ onApprovalUrl: deps.onApprovalUrl,
490
+ });
491
+ // Server-side mint (Phase 1): the approval claim releases a scoped mint
492
+ // token; the credential is minted by the verify-web deployment (which
493
+ // holds the VGS secret), never on this machine. No token means the
494
+ // deployment ran the dev-auth stub — refuse loudly rather than reach for
495
+ // a client-held secret (there is none anymore).
496
+ const mintToken = assurance.mintToken;
497
+ if (!mintToken) {
498
+ return {
499
+ outcome: 'failed',
500
+ confirmationRef: null,
501
+ receiptPath: null,
502
+ detail: 'the approval server issued no mint token — server-side minting requires the ' +
503
+ 'verify-web deployment to run real/turnkey auth (not the dev stub). Retry once it does.',
504
+ vicConfirmation: null,
505
+ source: 'fresh-tap',
506
+ remainingMinor: null,
507
+ };
184
508
  }
185
- });
509
+ confirmBase = input.approvalBaseUrl;
510
+ confirmToken = mintToken;
511
+ instrument = new VgsAssuranceInstrument(credential, assurance, session.target, session.contact.fullName ?? '', async (mintInput) => {
512
+ const { intentId, status } = await serverCreateIntent(confirmBase, confirmToken, mintInput);
513
+ try {
514
+ const payment = await serverFetchCryptogram(confirmBase, confirmToken, {
515
+ tokenId: mintInput.tokenId,
516
+ intentId,
517
+ transaction: mintInput.transaction,
518
+ });
519
+ return { payment, intentId };
520
+ }
521
+ catch (err) {
522
+ throw new Error(`${err.message} (intent status at creation: ${status ?? 'unknown'})`);
523
+ }
524
+ });
525
+ }
186
526
  const mode = input.submit ? 'submit' : 'dry-run';
187
527
  const result = await submitApprovedCheckout(input.reviewId, {
188
528
  approval: { approved: true, reviewId: input.reviewId },
@@ -202,7 +542,7 @@ export function createCliCheckoutEngine(deps = {}) {
202
542
  transactionAmount: session.target.transactionAmount,
203
543
  transactionCurrencyCode: session.currency,
204
544
  },
205
- post: (confInput) => serverPostConfirmation(approvalBase, mintToken, confInput),
545
+ post: (confInput) => serverPostConfirmation(confirmBase, confirmToken, confInput),
206
546
  });
207
547
  }
208
548
  let receiptPath = null;
@@ -232,6 +572,8 @@ export function createCliCheckoutEngine(deps = {}) {
232
572
  receiptPath,
233
573
  detail: result.detail ?? null,
234
574
  vicConfirmation,
575
+ source,
576
+ remainingMinor: drawnRemaining,
235
577
  };
236
578
  }
237
579
  finally {
@@ -1,6 +1,6 @@
1
1
  import type { Page } from 'playwright-core';
2
2
  export type FieldSource = 'autocomplete' | 'attr-heuristic' | 'label-text' | 'iframe-psp' | 'type-inference';
3
- export type FieldRole = 'number' | 'expMonth' | 'expYear' | 'expCombined' | 'cvc' | 'name' | 'nameFirst' | 'nameLast' | 'email' | 'addressLine1' | 'addressLine2' | 'city' | 'state' | 'postalCode' | 'country';
3
+ export type FieldRole = 'number' | 'expMonth' | 'expYear' | 'expCombined' | 'cvc' | 'name' | 'nameFirst' | 'nameLast' | 'email' | 'oneTimeCode' | 'addressLine1' | 'addressLine2' | 'city' | 'state' | 'postalCode' | 'country';
4
4
  export declare const ALL_ROLES: FieldRole[];
5
5
  export type SelectOption = {
6
6
  value: string;
@@ -21,6 +21,7 @@ export const ALL_ROLES = [
21
21
  'nameFirst',
22
22
  'nameLast',
23
23
  'email',
24
+ 'oneTimeCode',
24
25
  'addressLine1',
25
26
  'addressLine2',
26
27
  'city',
@@ -37,6 +38,17 @@ const expMonthRe = /(exp.?month|expmonth|exp_mm|(^|[^a-z])mm([^a-z]|$)|(^|[^a-z]
37
38
  const expYearRe = /(exp.?year|expyear|exp_yy|(^|[^a-z])yy(yy)?([^a-z]|$)|(^|[^a-z])year([^a-z]|$)|jahr|annee|(^|[^a-z])ano([^a-z]|$))/;
38
39
  const expCombinedRe = /(exp(iry|iration)?(.?date)?|mm.?\/.?yy|mm.?jj|valid.?thru|ablauf|caducidad)/;
39
40
  const emailRe = /(e-?mail|correo|courriel)/;
41
+ // One-time verification code (merchant email/SMS OTP). Ordered AFTER cvcRe in
42
+ // attrClassify so a card CVC ("security code"/"card code") still wins — this
43
+ // regex deliberately omits those card phrasings. It also OMITS a bare "code":
44
+ // `postal_code`/`zip_code`/`country_code`/`promo_code` all contain "code" and
45
+ // would otherwise misclassify as an OTP field and starve the address fill. A
46
+ // bare "code" is matched LAST (bareCodeRe), after every address role.
47
+ const oneTimeCodeRe = /(one[-_ ]?time|(^|[^a-z])otp([^a-z]|$)|passcode|(^|[^a-z])pin([^a-z]|$)|verif(y|ication)?.?code|auth.?code)/;
48
+ // Standalone verification "code" — a last-resort OTP match tried only after the
49
+ // specific address roles (postal/country/state/city/line) have had their say,
50
+ // so a compound `*_code` address/product field is never stolen by the OTP role.
51
+ const bareCodeRe = /(^|[^a-z])code([^a-z]|$)/;
40
52
  const postalRe = /(zip|postal|postcode|(^|[^a-z])plz([^a-z]|$)|(^|[^a-z])cep([^a-z]|$)|codigo.?postal)/;
41
53
  const countryRe = /(country|(^|[^a-z])land([^a-z]|$)|(^|[^a-z])pais)/;
42
54
  const stateRe = /((^|[^a-z])state([^a-z]|$)|province|region|bundesland|provincia)/;
@@ -55,6 +67,7 @@ const AUTOCOMPLETE_MAP = {
55
67
  'cc-name': 'name',
56
68
  name: 'name',
57
69
  email: 'email',
70
+ 'one-time-code': 'oneTimeCode',
58
71
  'given-name': 'nameFirst',
59
72
  'family-name': 'nameLast',
60
73
  'address-line1': 'addressLine1',
@@ -151,6 +164,9 @@ function attrClassify(text, m) {
151
164
  return 'expCombined';
152
165
  if (emailRe.test(text))
153
166
  return 'email';
167
+ // OTP comes after cvc (checked above) so card CVC still classifies as 'cvc'.
168
+ if (oneTimeCodeRe.test(text))
169
+ return 'oneTimeCode';
154
170
  if (postalRe.test(text))
155
171
  return 'postalCode';
156
172
  if (countryRe.test(text))
@@ -163,6 +179,10 @@ function attrClassify(text, m) {
163
179
  return 'addressLine2';
164
180
  if (addr1Re.test(text))
165
181
  return 'addressLine1';
182
+ // Last-resort bare "code" → OTP, only once every address role has been ruled
183
+ // out, so `postal_code`/`country_code`/etc. keep their own role above.
184
+ if (bareCodeRe.test(text))
185
+ return 'oneTimeCode';
166
186
  if (nameFirstRe.test(text))
167
187
  return 'nameFirst';
168
188
  if (nameLastRe.test(text))
@@ -7,6 +7,9 @@ export type EvidenceStep = {
7
7
  export declare function maskPan(pan: string): string;
8
8
  export declare function maskCvc(_cvc: string): string;
9
9
  export declare function maskExpiry(): string;
10
+ export declare function maskOtp(): string;
11
+ export declare function hostOf(hostOrEmail: string): string;
12
+ export declare function verificationLinkAllowed(link: string, messageFrom: string): boolean;
10
13
  export declare function redactContact(role: string, value: string): string;
11
14
  export declare class EvidenceLog {
12
15
  private steps;
@@ -3,12 +3,15 @@
3
3
  // the mandate verdict, the submit, and the final outcome, plus a compact
4
4
  // accessibility snapshot of the end state.
5
5
  //
6
- // Money-adjacent hygiene rule: the full PAN, the cvc, and the card expiry
7
- // NEVER enter the log. PANs are masked to last-4; cvc and expiry are redacted
8
- // entirely. maskPan/maskCvc/maskExpiry are the only sanctioned way to put a
9
- // card value anywhere near a log line. Contact PII (cardholder name, email,
10
- // address, ...) is redacted via redactContact: the log records THAT a field
11
- // was filled, never the personal value.
6
+ // Money-adjacent hygiene rule: the full PAN, the cvc, the card expiry, and any
7
+ // one-time verification code NEVER enter the log. PANs are masked to last-4;
8
+ // cvc, expiry, and OTP codes are redacted entirely. maskPan/maskCvc/maskExpiry/
9
+ // maskOtp are the only sanctioned way to put such a value anywhere near a log
10
+ // line. (receipt.ts redactPanLikeDigits only catches 12-19-digit PANs, so a
11
+ // 4-8-digit OTP would slip straight through — maskOtp at the fill site is the
12
+ // load-bearing guarantee it never reaches the evidence log.) Contact PII
13
+ // (cardholder name, email, address, ...) is redacted via redactContact: the
14
+ // log records THAT a field was filled, never the personal value.
12
15
  // Mask a PAN to its last four digits. Non-digits are stripped for the count.
13
16
  export function maskPan(pan) {
14
17
  const digits = (pan || '').replace(/\D/g, '');
@@ -25,6 +28,48 @@ export function maskCvc(_cvc) {
25
28
  export function maskExpiry() {
26
29
  return 'redacted';
27
30
  }
31
+ // A merchant one-time verification code is single-use secret material — it
32
+ // never enters the log, in any format. Used at the oneTimeCode fill site so
33
+ // the code is masked exactly as cvc/PAN are (see the header note above).
34
+ export function maskOtp() {
35
+ return 'redacted';
36
+ }
37
+ // The registrable domain (approx eTLD+1) of an email address or hostname, for
38
+ // the caller-side from-domain guard on verification links: before navigating a
39
+ // link parsed from an untrusted OTP email, require the link host to match the
40
+ // message sender's domain. extract.ts guarantees mechanical extraction, NOT
41
+ // navigation safety — this guard is the anti-injection backstop.
42
+ export function hostOf(hostOrEmail) {
43
+ return (hostOrEmail.split('@').pop() ?? '').trim().toLowerCase().replace(/\.$/, '');
44
+ }
45
+ // Strict bidirectional host-suffix match (exact / child / parent). Deliberately
46
+ // NOT approximate eTLD+1 — that collapses shared-tenant hosts (`x.myshopify.com`,
47
+ // `x.co.uk`) to a common suffix and lets a sibling tenant match; siblings never
48
+ // match here. Fails SAFE: an unrelated-but-legitimate sender is skipped, not
49
+ // trusted.
50
+ function hostsRelated(a, b) {
51
+ const x = hostOf(a);
52
+ const y = hostOf(b);
53
+ if (!x || !y)
54
+ return false;
55
+ return x === y || x.endsWith('.' + y) || y.endsWith('.' + x);
56
+ }
57
+ // Whether a verification LINK is safe to navigate: the sender must NOT be the
58
+ // unauthenticated inbound variant (AgentMail marks spoofable inbound with an
59
+ // `.unauthenticated` sub-label), and the link host must belong to the sender's
60
+ // domain. Returns false to SKIP a link that fails either check.
61
+ export function verificationLinkAllowed(link, messageFrom) {
62
+ if (/\.unauthenticated\b/i.test(messageFrom))
63
+ return false;
64
+ let linkHost;
65
+ try {
66
+ linkHost = new URL(link).hostname;
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ return hostsRelated(linkHost, messageFrom);
72
+ }
28
73
  // Contact values (cardholder name, email, address, ...) are PII and never
29
74
  // enter the log raw either. Email keeps its domain for debuggability;
30
75
  // everything else becomes a bare presence marker.