@visa/cli 4.1.0-rc.298 → 4.1.0-rc.299

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 (77) hide show
  1. package/README.md +29 -45
  2. package/dist/cli.js +556 -786
  3. package/dist/mcp-server/index.js +408 -622
  4. package/dist/merchant-ucp-mcp/index.js +6 -6
  5. package/dist/skills/pair-visa-agent/SKILL.md +175 -240
  6. package/native/bin/win32-arm64/visa-runtime-signer.exe +0 -0
  7. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  8. package/native/bin/win32-x64/visa-runtime-signer.exe +0 -0
  9. package/package.json +2 -4
  10. package/server.json +2 -2
  11. package/dist/checkout-engine/adapters/generic.d.ts +0 -88
  12. package/dist/checkout-engine/adapters/generic.js +0 -526
  13. package/dist/checkout-engine/adapters/index.d.ts +0 -10
  14. package/dist/checkout-engine/adapters/index.js +0 -24
  15. package/dist/checkout-engine/adapters/shopify.d.ts +0 -98
  16. package/dist/checkout-engine/adapters/shopify.js +0 -744
  17. package/dist/checkout-engine/adapters/stripe-like.d.ts +0 -10
  18. package/dist/checkout-engine/adapters/stripe-like.js +0 -21
  19. package/dist/checkout-engine/amount.d.ts +0 -17
  20. package/dist/checkout-engine/amount.js +0 -72
  21. package/dist/checkout-engine/browser-launch.d.ts +0 -51
  22. package/dist/checkout-engine/browser-launch.js +0 -96
  23. package/dist/checkout-engine/browserbase-browser.d.ts +0 -24
  24. package/dist/checkout-engine/browserbase-browser.js +0 -186
  25. package/dist/checkout-engine/ceremony.d.ts +0 -64
  26. package/dist/checkout-engine/ceremony.js +0 -261
  27. package/dist/checkout-engine/cli-engine.d.ts +0 -417
  28. package/dist/checkout-engine/cli-engine.js +0 -1331
  29. package/dist/checkout-engine/confirmed-merchants.d.ts +0 -31
  30. package/dist/checkout-engine/confirmed-merchants.js +0 -165
  31. package/dist/checkout-engine/detect.d.ts +0 -61
  32. package/dist/checkout-engine/detect.js +0 -398
  33. package/dist/checkout-engine/evidence.d.ts +0 -25
  34. package/dist/checkout-engine/evidence.js +0 -104
  35. package/dist/checkout-engine/executor.d.ts +0 -262
  36. package/dist/checkout-engine/executor.js +0 -1837
  37. package/dist/checkout-engine/hosted-approval.d.ts +0 -195
  38. package/dist/checkout-engine/hosted-approval.js +0 -501
  39. package/dist/checkout-engine/index.d.ts +0 -12
  40. package/dist/checkout-engine/index.js +0 -13
  41. package/dist/checkout-engine/instrument.d.ts +0 -61
  42. package/dist/checkout-engine/instrument.js +0 -87
  43. package/dist/checkout-engine/known-merchants.d.ts +0 -10
  44. package/dist/checkout-engine/known-merchants.js +0 -38
  45. package/dist/checkout-engine/live-fill-approval.d.ts +0 -37
  46. package/dist/checkout-engine/live-fill-approval.js +0 -76
  47. package/dist/checkout-engine/mandate/card-mandate.d.ts +0 -121
  48. package/dist/checkout-engine/mandate/card-mandate.js +0 -226
  49. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +0 -175
  50. package/dist/checkout-engine/mandate/mandate-ledger.js +0 -425
  51. package/dist/checkout-engine/mandate.d.ts +0 -33
  52. package/dist/checkout-engine/mandate.js +0 -135
  53. package/dist/checkout-engine/outcome.d.ts +0 -30
  54. package/dist/checkout-engine/outcome.js +0 -225
  55. package/dist/checkout-engine/owner-only-file.d.ts +0 -19
  56. package/dist/checkout-engine/owner-only-file.js +0 -41
  57. package/dist/checkout-engine/package.json +0 -3
  58. package/dist/checkout-engine/receipt-dir.d.ts +0 -6
  59. package/dist/checkout-engine/receipt-dir.js +0 -8
  60. package/dist/checkout-engine/receipt.d.ts +0 -135
  61. package/dist/checkout-engine/receipt.js +0 -148
  62. package/dist/checkout-engine/shopify-primary-domain.d.ts +0 -25
  63. package/dist/checkout-engine/shopify-primary-domain.js +0 -96
  64. package/dist/checkout-engine/trace-handles.d.ts +0 -8
  65. package/dist/checkout-engine/trace-handles.js +0 -12
  66. package/dist/checkout-engine/types.d.ts +0 -52
  67. package/dist/checkout-engine/types.js +0 -2
  68. package/dist/checkout-engine/unresolved-charges.d.ts +0 -34
  69. package/dist/checkout-engine/unresolved-charges.js +0 -134
  70. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +0 -155
  71. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +0 -493
  72. package/dist/checkout-engine/vgs-live-instrument.d.ts +0 -144
  73. package/dist/checkout-engine/vgs-live-instrument.js +0 -229
  74. package/dist/checkout-engine/vic-confirmation.d.ts +0 -52
  75. package/dist/checkout-engine/vic-confirmation.js +0 -45
  76. package/dist/checkout-engine/web-bot-auth.d.ts +0 -98
  77. package/dist/checkout-engine/web-bot-auth.js +0 -218
@@ -1,1331 +0,0 @@
1
- // createCliCheckoutEngine — the review()/pay() adapter consumed by @visa/cli's
2
- // pay_merchant tool. It COMPOSES prepareCheckout, runHostedApproval, the
3
- // server-side mint (serverCreateIntent/serverFetchCryptogram — the credential is
4
- // minted by the verify-web deployment, not this machine), submitApprovedCheckout,
5
- // reportVicOutcome, and receipts into a two-call API.
6
- //
7
- // A live browser + prepared-checkout session is held in-process between review
8
- // and pay, keyed by reviewId, so the submitted checkout is the exact one the
9
- // human approved. Passkey approval uses the hosted /approve page only.
10
- //
11
- // Every browser/network primitive is injectable (CliEngineDeps) so the session/
12
- // timer/store lifecycle is unit-testable without launching Chromium.
13
- import { readFile } from 'node:fs/promises';
14
- import { randomBytes, randomUUID } from 'node:crypto';
15
- import { launchCheckoutBrowser } from './browser-launch.js';
16
- import { RECEIPT_DIR } from './receipt-dir.js';
17
- import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
18
- import { claimMandatePickup as realClaimMandatePickup, runHostedApproval as realRunHostedApproval, } from './hosted-approval.js';
19
- import { VgsLiveInstrument, decimalToMinor, minorToDecimal, } from './vgs-live-instrument.js';
20
- import { serverCreateIntent, serverReadIntentBootstrap, ServerIntentError, serverFetchCryptogram, serverPostConfirmation, } from './vgs-gateway/server-mint-client.js';
21
- import { createCardMandate, DEFAULT_MANDATE_MAX_DRAWS, drawFromMandate, MandateDrawDeclinedError, } from './mandate/card-mandate.js';
22
- import { MandateLedger } from './mandate/mandate-ledger.js';
23
- import { buildReceipt, writeReceipt as realWriteReceipt, } from './receipt.js';
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
- // A refusal that declared itself TERMINAL wins outright, before any message
37
- // matching. Message text is not a safe carrier for this decision: the mint
38
- // client interpolates provider-supplied `upstream_codes` into its message
39
- // for diagnosability, and an identifier like `request_timeout` would match
40
- // the unanchored `tim(e|ed)-out` alternative below and silently reclassify
41
- // a permanent 422 as transient — skipping markUnhonored and stranding the
42
- // mandate in the retry-forever loop this classifier exists to prevent.
43
- // Duck-typed rather than instanceof so it survives bundling and any
44
- // re-wrapping across package boundaries. Optional chaining rather than an
45
- // explicit null guard: the loop condition already proved `e` truthy, so a
46
- // `e !== null` test is dead code, and `?.` stays safe on a primitive or a
47
- // nullish link if that guard ever changes.
48
- if (e?.terminal === true) {
49
- return false;
50
- }
51
- if (e instanceof Error && typeof e.message === 'string')
52
- msgs.push(e.message);
53
- e = e.cause;
54
- }
55
- // Transient = a gateway 5xx / 429 in the mint client's "(last: <status>: …)"
56
- // framing, or an unambiguous network reset/timeout error name. The status is
57
- // ANCHORED to `(last:` so a bare 3-digit token elsewhere (an amount, a ref id,
58
- // an attempt count) can never be mistaken for a status code, and so the
59
- // advisory DRAW_REMEDY wrapper text ("gateway 5xx / try again") cannot
60
- // self-classify a hard decline as transient. A hard decline (4xx / a
61
- // card-decline reason) matches nothing here → the mandate is correctly disabled.
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(' '));
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
- }
89
- /**
90
- * A verdict refusal may be wrapped by drawFromMandate after its local
91
- * reservation is released. Walk the cause chain so the original auth status +
92
- * reasons still decide whether the mandate is permanently disabled.
93
- */
94
- // Exported for the cross-package pin in test/cli-engine.test.ts: the CLI's
95
- // card-draw client decides the `status` this reads, so the two move together
96
- // and a test that spans the boundary is the only one that would catch a drift.
97
- // Not re-exported from index.ts — the public @visa/checkout-engine surface is
98
- // the explicit allowlist there, same as isTransientDrawFailure above.
99
- export function classifyCardDrawVerdictFailure(err) {
100
- const transientReasons = new Set(['challenge_failed', 'challenge_malformed', 'verdict_refused']);
101
- let current = err;
102
- for (let i = 0; i < 5 && current; i++) {
103
- const candidate = current;
104
- const status = typeof candidate.status === 'number' ? candidate.status : 0;
105
- const reasons = Array.isArray(candidate.reasons)
106
- ? candidate.reasons.filter((reason) => typeof reason === 'string')
107
- : [];
108
- if (status !== 0 || reasons.length > 0) {
109
- const transient = status === 503 ||
110
- reasons.length === 0 ||
111
- reasons.every((reason) => transientReasons.has(reason));
112
- return {
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),
120
- reasons,
121
- };
122
- }
123
- current = candidate.cause;
124
- }
125
- return null;
126
- }
127
- /**
128
- * Resolve the card instrument for one flow. Fail-closed: an unusable legacy file
129
- * with no grant token rethrows (never silently proceeds), and the ONLY thing the
130
- * fallback contributes is a token id — a non-secret handle the server
131
- * re-authorizes against the owner (`requireTokenOwnership`) and against the live
132
- * grant on every draw. Nothing here authorizes anything.
133
- */
134
- async function resolveCardInstrument(input) {
135
- if (typeof input.cardTokenId === 'string' && input.cardTokenId.trim()) {
136
- return { tokenId: input.cardTokenId.trim(), source: 'card-grant' };
137
- }
138
- let credential = null;
139
- let readError = null;
140
- try {
141
- credential = JSON.parse(await readFile(input.credentialPath, 'utf8'));
142
- }
143
- catch (err) {
144
- readError = err;
145
- }
146
- if (credential && typeof credential.tokenId === 'string' && credential.tokenId.trim()) {
147
- return credential;
148
- }
149
- throw new Error('no card instrument is available to this runtime: there is no usable credential at ' +
150
- `${input.credentialPath} and no activated card:vic grant token was supplied. Run ` +
151
- '`visa agent grant-card <agent-id> --ceiling <usd> --per-transaction <usd> --wait` to ' +
152
- 'attach one, or use a pre-provisioned VIC runtime.', readError instanceof Error ? { cause: readError } : undefined);
153
- }
154
- /**
155
- * A checkout was inspected successfully but cannot be reviewed safely.
156
- *
157
- * The explicit fields survive the CLI's copied-engine boundary structurally,
158
- * so MCP callers do not have to parse the human-readable message.
159
- */
160
- export class CheckoutReviewRefusedError extends Error {
161
- code = 'CHECKOUT_REVIEW_REFUSED';
162
- checkoutOutcome;
163
- failureCode;
164
- /** Bounded mandate/trusted-identity reason (#8669); absent for other outcomes. */
165
- refusalCode;
166
- requiresAdapter;
167
- detectedRoles;
168
- receiptWrite;
169
- detail;
170
- constructor(result, receiptWrite) {
171
- super(result.detail
172
- ? `review refused: ${result.outcome} — ${result.detail}`
173
- : `review refused: ${result.outcome}`);
174
- this.name = 'CheckoutReviewRefusedError';
175
- this.checkoutOutcome = result.outcome;
176
- this.failureCode = result.failureCode;
177
- this.refusalCode = result.refusalCode;
178
- this.requiresAdapter = [...result.requiresAdapter];
179
- this.detectedRoles = Object.keys(result.fields);
180
- this.receiptWrite = receiptWrite;
181
- this.detail = result.detail;
182
- }
183
- }
184
- function payAttemptFingerprint(input) {
185
- return JSON.stringify([
186
- input.url,
187
- input.checkoutRoute,
188
- input.amount,
189
- input.currency,
190
- input.credentialPath,
191
- input.cardTokenId ?? null,
192
- input.agentJkt ?? null,
193
- input.contact,
194
- input.approvalBaseUrl,
195
- input.merchantName ?? null,
196
- input.merchantCountryCode ?? null,
197
- input.submit,
198
- ]);
199
- }
200
- function failedPay(detail) {
201
- return {
202
- outcome: 'failed',
203
- confirmationRef: null,
204
- receiptPath: null,
205
- detail,
206
- vicConfirmation: null,
207
- source: null,
208
- remainingMinor: null,
209
- credentialIssued: false,
210
- credentialDisclosed: false,
211
- };
212
- }
213
- export class CardMandateActivationError extends Error {
214
- facts;
215
- code = 'CARD_MANDATE_ACTIVATION_INCOMPLETE';
216
- constructor(message, facts) {
217
- super(message);
218
- this.facts = facts;
219
- this.name = 'CardMandateActivationError';
220
- }
221
- }
222
- /**
223
- * Sort a budget-intent route failure into resume semantics. Exported for the
224
- * regression net; the truth table is the product contract of #8470.
225
- */
226
- export function classifyServerIntentFailure(err) {
227
- const { status, errorCode, retryable, bootstrapState, outcome } = err.facts;
228
- if (bootstrapState === 'created')
229
- return { outcome: 'not_created', resumable: true };
230
- if (bootstrapState === 'ambiguous' || errorCode === 'budget_intent_creation_ambiguous') {
231
- return { outcome: 'uncertain', resumable: true };
232
- }
233
- if (bootstrapState === 'pending' || errorCode === 'budget_intent_creation_pending') {
234
- return { outcome: 'uncertain', resumable: true };
235
- }
236
- // An explicit uncertain or unknown outcome wins over retry advice: a
237
- // retryable failure of the STATUS read says nothing about whether the
238
- // original dispatch created the intent, so it must not read as not_created.
239
- // A terminal refusal of the read itself (the approval credential is no
240
- // longer accepted, or is not a budget token) is still uncertain about the
241
- // intent but cannot be resumed under that credential.
242
- if (outcome === 'uncertain' || outcome === 'unknown') {
243
- const terminalRead = status === 401 || status === 403 || status === 404;
244
- return { outcome: 'uncertain', resumable: !terminalRead };
245
- }
246
- // #8981: this exact admission refusal is known to precede provider dispatch.
247
- // Never generalize retryable:false or a 503 into evidence of non-creation.
248
- if (status === 503 &&
249
- errorCode === 'LEGACY_AGENT_ADMISSION_STOPPED' &&
250
- retryable === false &&
251
- bootstrapState === 'none' &&
252
- outcome === 'not_created') {
253
- return { outcome: 'not_created', resumable: false };
254
- }
255
- if (retryable)
256
- return { outcome: 'not_created', resumable: true };
257
- if (status === 0 || status >= 500) {
258
- return { outcome: 'uncertain', resumable: true };
259
- }
260
- // A completed 4xx refusal (claims, binding, conflict, already registered)
261
- // proves no intent exists and no resume can change the answer.
262
- return { outcome: 'not_created', resumable: false };
263
- }
264
- function mintTokenExpiryMs(mintToken, fallbackMs) {
265
- try {
266
- const payload = mintToken.split('.')[1];
267
- if (!payload)
268
- return fallbackMs;
269
- const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
270
- return typeof claims.exp === 'number' && Number.isFinite(claims.exp)
271
- ? claims.exp * 1000
272
- : fallbackMs;
273
- }
274
- catch {
275
- return fallbackMs;
276
- }
277
- }
278
- function boundedReceiptWriteErrorCode(reason) {
279
- return reason.match(/\b(?:EACCES|EEXIST|ENOSPC|ENOTDIR|EPERM|EROFS)\b/)?.[0] ?? 'UNKNOWN';
280
- }
281
- // Must match the prepared-checkout store TTL so a session and its store entry
282
- // expire together — an abandoned review can't leak the browser + state.
283
- const PREPARED_TTL_MS = 5 * 60 * 1000;
284
- const defaultStore = new InMemoryPreparedCheckoutStore({ ttlMs: PREPARED_TTL_MS });
285
- const defaultSessions = new Map();
286
- const defaultPayAttempts = new Map();
287
- const defaultLedger = new MandateLedger();
288
- export function createCliCheckoutEngine(deps = {}) {
289
- const store = deps.store ?? defaultStore;
290
- const sessions = deps.sessions ?? defaultSessions;
291
- const payAttempts = deps.payAttempts ?? defaultPayAttempts;
292
- const ttlMs = deps.ttlMs ?? PREPARED_TTL_MS;
293
- // #8470: process-bound resume handles for a budget whose intent bootstrap
294
- // did not complete after owner approval. Holds the one-use bootstrap
295
- // credential in memory only, for at most its own lifetime.
296
- const pendingActivations = new Map();
297
- function dropActivation(token) {
298
- const pending = pendingActivations.get(token);
299
- if (!pending)
300
- return;
301
- clearTimeout(pending.cleanupTimer);
302
- pendingActivations.delete(token);
303
- }
304
- function parkActivation(pending) {
305
- const token = `act_${randomBytes(18).toString('base64url')}`;
306
- const cleanupTimer = setTimeout(() => dropActivation(token), Math.max(0, pending.expiresAtMs - now().getTime()));
307
- cleanupTimer.unref?.();
308
- pendingActivations.set(token, { ...pending, cleanupTimer });
309
- return token;
310
- }
311
- function activationFailure(err, pending, existingToken) {
312
- if (!(err instanceof ServerIntentError))
313
- throw err;
314
- const classified = classifyServerIntentFailure(err);
315
- const resumeToken = classified.resumable
316
- ? (existingToken ?? parkActivation(pending))
317
- : undefined;
318
- if (!classified.resumable && existingToken)
319
- dropActivation(existingToken);
320
- throw new CardMandateActivationError(err.message, {
321
- phase: 'intent',
322
- outcome: classified.outcome,
323
- resumable: classified.resumable,
324
- status: err.facts.status,
325
- errorCode: err.facts.errorCode,
326
- requestId: err.facts.requestId,
327
- bootstrapState: err.facts.bootstrapState,
328
- ...(resumeToken ? { resumeToken } : {}),
329
- ...(resumeToken ? { resumeExpiresAt: new Date(pending.expiresAtMs).toISOString() } : {}),
330
- });
331
- }
332
- const launchBrowser = deps.launchBrowser ?? (() => launchCheckoutBrowser());
333
- const prepareCheckout = deps.prepareCheckout ?? realPrepareCheckout;
334
- const submitApprovedCheckout = deps.submitApprovedCheckout ?? realSubmitApprovedCheckout;
335
- const runHostedApproval = deps.runHostedApproval ?? realRunHostedApproval;
336
- const claimMandatePickup = deps.claimMandatePickup ?? realClaimMandatePickup;
337
- const reportVicOutcome = deps.reportVicOutcome ?? realReportVicOutcome;
338
- const writeReceipt = deps.writeReceipt ?? realWriteReceipt;
339
- const ledger = deps.ledger ?? defaultLedger;
340
- const now = deps.now ?? (() => new Date());
341
- const fetchMandateCryptogram = deps.serverFetchCryptogram ?? serverFetchCryptogram;
342
- const postServerConfirmation = deps.serverPostConfirmation ?? serverPostConfirmation;
343
- const createObservationId = deps.createObservationId ?? randomUUID;
344
- async function writeObservedReceipt(mode, receipt) {
345
- let report;
346
- try {
347
- report = await writeReceipt(RECEIPT_DIR, receipt);
348
- }
349
- catch (err) {
350
- // The real writer is fail-open already. Keep that invariant even for an
351
- // injected writer so a filesystem or test-double failure cannot replace
352
- // the checkout result after a charge may have completed.
353
- report = { written: false, reason: err instanceof Error ? err.message : String(err) };
354
- }
355
- const event = {
356
- event: 'checkout_receipt_write',
357
- status: report.written ? 'written' : 'failed',
358
- mode,
359
- checkoutOutcome: receipt.outcome,
360
- merchantHost: receipt.merchant.host,
361
- observationId: receipt.observationId ?? receipt.receiptId ?? createObservationId(),
362
- failureCode: receipt.capability?.failureCode ?? null,
363
- errorCode: report.written ? null : boundedReceiptWriteErrorCode(report.reason),
364
- panRedactions: report.written ? report.panRedactions : null,
365
- };
366
- try {
367
- const observerResult = deps.onReceiptWrite?.(event);
368
- if (observerResult)
369
- void Promise.resolve(observerResult).catch(() => undefined);
370
- }
371
- catch {
372
- // Observability is non-authoritative and must not affect payment state.
373
- }
374
- return { report, event };
375
- }
376
- const cardDrawVerdict = deps.cardDrawVerdict ?? null;
377
- const cardMandateRegister = deps.cardMandateRegister ?? null;
378
- async function closeSession(reviewId) {
379
- const session = sessions.get(reviewId);
380
- if (!session)
381
- return;
382
- clearTimeout(session.cleanupTimer);
383
- sessions.delete(reviewId);
384
- await session.browser.close().catch(() => { });
385
- }
386
- function buildTarget(input) {
387
- return {
388
- merchantName: input.merchantName ?? new URL(input.url).hostname,
389
- merchantUrl: input.url,
390
- merchantCountryCode: input.merchantCountryCode ?? 'US',
391
- transactionAmount: input.amount,
392
- transactionCurrencyCode: input.currency,
393
- };
394
- }
395
- /**
396
- * Restate the facts at the ceiling the SERVER approved, when it lowered the
397
- * one that was requested. Nothing has been spent or reserved on a mandate this
398
- * new, so its remaining headroom IS its ceiling — see `createCardMandate`,
399
- * which records `spentMinor: 0` with no reservations.
400
- *
401
- * Only ever lowers, mirroring `applyApprovedCeiling`: the server clamps
402
- * downward, so a higher number means an unexpected response and is ignored
403
- * rather than reported as headroom no human approved.
404
- */
405
- function approvedCeilingFacts(facts, approvedCeilingMinor) {
406
- if (approvedCeilingMinor === undefined || approvedCeilingMinor >= facts.ceilingMinor)
407
- return {};
408
- return { ceilingMinor: approvedCeilingMinor, remainingMinor: approvedCeilingMinor };
409
- }
410
- // Seed the one server-authoritative cumulative store keyed by the VGS intent
411
- // ID (#5942). On failure the local record is marked register-failed so
412
- // findCovering() SKIPS it — the mandate exists but is never drawn tap-free —
413
- // and the caller reports the failure honestly. Shared by mandate-start and
414
- // pickup-claim; both refuse before their ceremony/claim when no capability
415
- // can register, so registerCap is always present here.
416
- async function registerMandateOrDisable(args) {
417
- if (!cardMandateRegister)
418
- return { registerFailed: true, registerFailureReason: 'no_seam' };
419
- const reg = await cardMandateRegister
420
- .register({
421
- authBaseUrl: args.registerCap.authBaseUrl,
422
- agentKey: args.registerCap.agentKey,
423
- ...(args.registerCap.runtimeCertificate
424
- ? { runtimeCertificate: args.registerCap.runtimeCertificate }
425
- : {}),
426
- mandateId: args.mandateId,
427
- mintToken: args.mintToken,
428
- ceiling: args.ceiling,
429
- currency: args.currency,
430
- })
431
- .catch((err) => ({
432
- ok: false,
433
- reason: err instanceof Error ? err.message : String(err),
434
- }));
435
- if (reg.ok) {
436
- // ONE SPENDING LIMIT: auth just clamped the requested ceiling to the
437
- // owner's live grant cap and told us what it committed. Adopt it, so the
438
- // local record — which findCovering() selects on and `mandate list`
439
- // prints — states the budget the owner actually approved. Best-effort:
440
- // the mandate is registered and drawable either way, and auth's verdict
441
- // remains the enforcing cap, so a failed local write must not abort the
442
- // ceremony. It leaves the record overstating headroom, which is exactly
443
- // the pre-existing behaviour.
444
- //
445
- // If that write fails the returned facts still carry the approved figure —
446
- // telling the human the truth beats echoing a ceiling their grant refused,
447
- // and the ledger is left exactly as overstated as it was before this
448
- // existed. But the two then disagree, so say so out loud rather than let
449
- // `mandate list` quietly contradict what `mandate start` just printed.
450
- // Same posture as the mark-failed escalation below.
451
- if (reg.approvedCeilingMinor !== undefined) {
452
- const applied = await ledger
453
- .applyApprovedCeiling(args.mandateId, reg.approvedCeilingMinor)
454
- .then(() => true)
455
- .catch(() => false);
456
- if (!applied) {
457
- // Rendered through minorToDecimal, never raw /100 — and only when the
458
- // value is a sane integer, because THIS branch is also where an
459
- // unusable value lands (applyApprovedCeiling rejects it). A warning
460
- // must not throw on its way out. The currency is omitted deliberately:
461
- // both mandate paths refuse anything but USD long before register, so
462
- // it is known, and passing it would let a non-2-decimal code throw here.
463
- const approved = Number.isSafeInteger(reg.approvedCeilingMinor) && reg.approvedCeilingMinor > 0
464
- ? ` (${args.currency} ${minorToDecimal(reg.approvedCeilingMinor)})`
465
- : '';
466
- process.stderr.write(`warning: your owner's approved limit for this budget${approved} could NOT be saved ` +
467
- `locally — 'mandate list' will overstate the remaining balance until you re-run ` +
468
- `'mandate start'. Spending is still capped at the approved limit; a draw over it ` +
469
- `is refused. mandateId=${args.mandateId}\n`);
470
- }
471
- }
472
- return {
473
- registerFailed: false,
474
- ...(reg.approvedCeilingMinor !== undefined
475
- ? { approvedCeilingMinor: reg.approvedCeilingMinor }
476
- : {}),
477
- };
478
- }
479
- // Register failed: the server `card_mandate_spend` row was never created, so
480
- // a delegated draw against this mandate would 404 `no_mandate`. Mark it
481
- // register-failed so findCovering() SKIPS it rather than silently selecting
482
- // a mandate that can't be drawn. A v4 grant then refuses until a usable
483
- // mandate exists; only a legacy credential-file runtime can still use the
484
- // old per-purchase approval. Marking is best-effort too.
485
- const marked = await ledger
486
- .markRegisterFailed(args.mandateId, now())
487
- .then(() => true)
488
- .catch(() => false);
489
- process.stderr.write(marked
490
- ? `warning: card-mandate register failed (${reg.reason ?? 'unknown'}) — this mandate ` +
491
- `will NOT be used for draws. A v4 card grant cannot spend until a usable mandate ` +
492
- `is registered. Re-run 'mandate start' to try again.\n`
493
- : // Escalate: the mark write ALSO failed, so the mandate is persisted but
494
- // NOT disabled — findCovering could still select an undrawable mandate.
495
- // Tell the owner loudly not to rely on it and how to recover.
496
- `warning: card-mandate register failed (${reg.reason ?? 'unknown'}) AND the mandate ` +
497
- `could NOT be disabled locally — do not rely on it. Run 'mandate list' and re-run ` +
498
- `'mandate start'. mandateId=${args.mandateId}\n`);
499
- return {
500
- registerFailed: true,
501
- ...(reg.reason !== undefined ? { registerFailureReason: reg.reason } : {}),
502
- };
503
- }
504
- // Shared by mandate-start and resume: mint (or adopt) the ceiling intent,
505
- // persist the owner-only ledger entry, and register server-side.
506
- async function activateBudget(pending, createIntent) {
507
- const facts = await createCardMandate({
508
- agentJkt: pending.registerCap.agentJkt,
509
- tokenId: pending.tokenId,
510
- assuranceData: pending.assuranceData,
511
- ceilingMinor: pending.ceilingMinor,
512
- merchant: pending.merchant,
513
- currencyCode: pending.currency,
514
- expiresAt: pending.expiresAt,
515
- maxDraws: DEFAULT_MANDATE_MAX_DRAWS,
516
- crossMerchant: true,
517
- }, {
518
- createIntent,
519
- ledger,
520
- approvalBaseUrl: pending.approvalBaseUrl,
521
- now,
522
- });
523
- // Seed the one server-authoritative cumulative store keyed by the VGS
524
- // intent ID. A later draw requires its PoP verdict; the budget token never
525
- // falls back as payable authority.
526
- const registered = await registerMandateOrDisable({
527
- registerCap: pending.registerCap,
528
- mandateId: facts.mandateId,
529
- mintToken: pending.mintToken,
530
- ceiling: pending.ceiling,
531
- currency: pending.currency,
532
- });
533
- return {
534
- ...facts,
535
- ...approvedCeilingFacts(facts, registered.approvedCeilingMinor),
536
- merchantHost: new URL(pending.merchant.url).hostname,
537
- registerFailed: registered.registerFailed,
538
- ...(registered.registerFailureReason !== undefined
539
- ? { registerFailureReason: registered.registerFailureReason }
540
- : {}),
541
- };
542
- }
543
- return {
544
- // BUDGET step: one passkey approves a CEILING; a VGS intent is minted with
545
- // that ceiling as its decline threshold and the owner-only ledger records
546
- // the cumulative budget. No browser checkout is prepared — this is purely
547
- // the passkey ceremony + intent, so later pay() draws need no fresh tap.
548
- async startCardMandate(input) {
549
- const ceilingMinor = decimalToMinor(input.ceiling);
550
- if (ceilingMinor === null || ceilingMinor <= 0) {
551
- throw new Error(`invalid mandate ceiling ${JSON.stringify(input.ceiling)}`);
552
- }
553
- if (input.currency.toUpperCase() !== 'USD') {
554
- throw new Error('card spend budgets currently support USD only');
555
- }
556
- if (input.perTransaction !== undefined) {
557
- const perTxMinor = decimalToMinor(input.perTransaction);
558
- if (perTxMinor === null || perTxMinor <= 0 || perTxMinor > ceilingMinor) {
559
- throw new Error(`invalid mandate perTransaction ${JSON.stringify(input.perTransaction)} — ` +
560
- 'must be a positive amount at or under the ceiling');
561
- }
562
- }
563
- // A budget token can bootstrap one VGS intent and its register handshake,
564
- // but it is never payable draw authority. Refuse before the passkey
565
- // ceremony unless this runtime can both prove the separately provisioned
566
- // card capability and register the resulting intent server-side.
567
- const registerCap = cardMandateRegister?.loadCapability(input.agentRef) ?? null;
568
- if (!cardMandateRegister || !registerCap) {
569
- throw new Error('startCardMandate requires separately provisioned card authority in this runtime; ' +
570
- 'identity pairing alone does not grant a card mandate');
571
- }
572
- // There is one budget product: eligible retail merchants under the
573
- // provider's required Retail/5999 network category, with total,
574
- // per-purchase, count, and time bounds stated on the approval page. The
575
- // sentinel is provider metadata, never a user-entered merchant route.
576
- const merchant = {
577
- name: 'retail spend budget',
578
- url: 'https://retail-budget.visa/budget',
579
- countryCode: 'US',
580
- };
581
- // Legacy credential file OR the activated card:vic grant's token — see
582
- // resolveCardInstrument. A v2-paired runtime only ever has the latter.
583
- const credential = await resolveCardInstrument(input);
584
- // The passkey ceremony is scoped to the CEILING + merchant (not one
585
- // charge) — that scope is the unproven part of the spike.
586
- const ceilingTarget = {
587
- merchantName: merchant.name,
588
- merchantUrl: merchant.url,
589
- merchantCountryCode: merchant.countryCode,
590
- transactionAmount: input.ceiling,
591
- transactionCurrencyCode: input.currency,
592
- };
593
- // BUDGET mode: the ceiling target's amount IS the approved ceiling, so the
594
- // server mints a budget mint token bound to that ceiling — later draws pull
595
- // sub-ceiling amounts against it tap-free.
596
- const assurance = await runHostedApproval({
597
- baseUrl: input.approvalBaseUrl,
598
- tokenId: credential.tokenId,
599
- target: ceilingTarget,
600
- consumerEmail: input.contact.email,
601
- budget: true,
602
- agentJkt: registerCap.agentJkt,
603
- onApprovalUrl: input.onApprovalUrl ?? deps.onApprovalUrl,
604
- maxDraws: DEFAULT_MANDATE_MAX_DRAWS,
605
- ...(input.perTransaction !== undefined ? { perTransaction: input.perTransaction } : {}),
606
- ...(input.intent !== undefined ? { intent: input.intent } : {}),
607
- });
608
- const mintToken = assurance.mintToken;
609
- if (!mintToken) {
610
- throw new Error('the approval server issued no mint token — server-side minting requires the ' +
611
- 'verify-web deployment to run real/turnkey auth (not the dev stub). Retry once it does.');
612
- }
613
- if (!Number.isSafeInteger(assurance.validUntil) ||
614
- assurance.validUntil <= Math.floor(now().getTime() / 1000)) {
615
- throw new Error('the approval server issued no valid budget expiry');
616
- }
617
- const expiresAt = new Date(assurance.validUntil * 1000).toISOString();
618
- const pending = {
619
- mintToken,
620
- assuranceData: assurance.assuranceData,
621
- ceiling: input.ceiling,
622
- ceilingMinor,
623
- currency: input.currency,
624
- merchant,
625
- expiresAt,
626
- tokenId: credential.tokenId,
627
- approvalBaseUrl: input.approvalBaseUrl,
628
- registerCap,
629
- expiresAtMs: mintTokenExpiryMs(mintToken, now().getTime() + 10 * 60 * 1000),
630
- };
631
- return activateBudget(pending, (i) => serverCreateIntent(input.approvalBaseUrl, mintToken, i)).catch((err) => activationFailure(err, pending));
632
- },
633
- // RESUME leg (#8470): the owner already approved, but the intent bootstrap
634
- // did not complete. Read the server's durable state under the SAME one-use
635
- // credential: a created intent is registered as-is, nothing-dispatched is
636
- // dispatched once, pending/ambiguous stays uncertain. No approval page, no
637
- // passkey, and never a sibling intent.
638
- async resumeCardMandate(input) {
639
- const pending = pendingActivations.get(input.resumeToken);
640
- if (!pending || pending.expiresAtMs <= now().getTime()) {
641
- if (pending)
642
- dropActivation(input.resumeToken);
643
- throw new CardMandateActivationError('no resumable budget activation for this operation — its approval credential expired; start a fresh approval', {
644
- phase: 'intent',
645
- outcome: 'not_created',
646
- resumable: false,
647
- status: null,
648
- errorCode: 'activation_resume_expired',
649
- requestId: null,
650
- bootstrapState: 'unknown',
651
- });
652
- }
653
- let read;
654
- try {
655
- read = await serverReadIntentBootstrap(pending.approvalBaseUrl, pending.mintToken);
656
- }
657
- catch (err) {
658
- return activationFailure(err, pending, input.resumeToken);
659
- }
660
- if (read.state === 'created' && read.intentId) {
661
- const intentId = read.intentId;
662
- const facts = await activateBudget(pending, async () => ({
663
- intentId,
664
- status: read.intentStatus,
665
- }));
666
- dropActivation(input.resumeToken);
667
- return facts;
668
- }
669
- if (read.state === 'none') {
670
- return activateBudget(pending, (i) => serverCreateIntent(pending.approvalBaseUrl, pending.mintToken, i))
671
- .then((facts) => {
672
- dropActivation(input.resumeToken);
673
- return facts;
674
- })
675
- .catch((err) => activationFailure(err, pending, input.resumeToken));
676
- }
677
- throw new CardMandateActivationError(read.state === 'ambiguous'
678
- ? 'the card network never confirmed this budget intent and its outcome cannot be verified; this approval cannot be reused'
679
- : read.state === 'pending'
680
- ? 'this budget intent is still being created; check again shortly'
681
- : 'the budget activation state could not be read; check again shortly', {
682
- phase: 'intent',
683
- outcome: 'uncertain',
684
- // Stays parked and queryable: a further resume only re-reads state
685
- // and can never redispatch under this token.
686
- resumable: true,
687
- status: null,
688
- errorCode: read.state === 'ambiguous'
689
- ? 'budget_intent_creation_ambiguous'
690
- : read.state === 'pending'
691
- ? 'budget_intent_creation_pending'
692
- : 'budget_intent_state_unavailable',
693
- requestId: read.requestId,
694
- bootstrapState: read.state,
695
- resumeToken: input.resumeToken,
696
- resumeExpiresAt: new Date(pending.expiresAtMs).toISOString(),
697
- });
698
- },
699
- // PICKUP leg: the owner already approved the ceiling in the account panel
700
- // and handed this runtime a single-use pickup code. Claim it, verify it was
701
- // minted for exactly this runtime's request key + card token, then run the
702
- // same intent → register → ledger sequence as startCardMandate. No approval
703
- // page, no passkey, no contact profile — the human side already happened.
704
- async claimCardMandate(input) {
705
- const registerCap = cardMandateRegister?.loadCapability(input.agentRef) ?? null;
706
- if (!cardMandateRegister || !registerCap) {
707
- throw new Error('claimCardMandate requires separately provisioned card authority in this runtime; ' +
708
- 'identity pairing alone does not grant a card mandate');
709
- }
710
- const credential = await resolveCardInstrument(input);
711
- const claim = await claimMandatePickup({
712
- baseUrl: input.approvalBaseUrl,
713
- pickupCode: input.pickupCode,
714
- agentJkt: registerCap.agentJkt,
715
- ...(input.expectedHandoffId ? { expectedHandoffId: input.expectedHandoffId } : {}),
716
- tokenId: credential.tokenId,
717
- });
718
- const ceilingMinor = decimalToMinor(claim.ceiling);
719
- if (ceilingMinor === null || ceilingMinor <= 0) {
720
- throw new Error(`the handoff carried an invalid ceiling ${JSON.stringify(claim.ceiling)}`);
721
- }
722
- // Same single-product bound as mandate-start (the panel initiate route
723
- // enforces it too; a divergent store entry must not widen it here).
724
- if (claim.currency.toUpperCase() !== 'USD') {
725
- throw new Error('card spend budgets currently support USD only');
726
- }
727
- const expiresAt = new Date(claim.validUntil * 1000).toISOString();
728
- const facts = await createCardMandate({
729
- agentJkt: registerCap.agentJkt,
730
- tokenId: credential.tokenId,
731
- assuranceData: claim.assuranceData,
732
- ceilingMinor,
733
- merchant: claim.merchant,
734
- currencyCode: claim.currency,
735
- expiresAt,
736
- maxDraws: claim.maxDraws,
737
- crossMerchant: true,
738
- }, {
739
- createIntent: (i) => serverCreateIntent(input.approvalBaseUrl, claim.mintToken, i),
740
- ledger,
741
- approvalBaseUrl: input.approvalBaseUrl,
742
- now,
743
- });
744
- const registered = await registerMandateOrDisable({
745
- registerCap,
746
- mandateId: facts.mandateId,
747
- mintToken: claim.mintToken,
748
- ceiling: claim.ceiling,
749
- currency: claim.currency,
750
- });
751
- return {
752
- ...facts,
753
- ...approvedCeilingFacts(facts, registered.approvedCeilingMinor),
754
- merchantHost: new URL(claim.merchant.url).hostname,
755
- registerFailed: registered.registerFailed,
756
- ...(registered.registerFailureReason !== undefined
757
- ? { registerFailureReason: registered.registerFailureReason }
758
- : {}),
759
- };
760
- },
761
- async review(input) {
762
- const amountMinor = decimalToMinor(input.amount);
763
- if (amountMinor === null)
764
- throw new Error(`invalid amount ${JSON.stringify(input.amount)}`);
765
- const host = new URL(input.url).hostname;
766
- const browser = await launchBrowser();
767
- try {
768
- const prep = await prepareCheckout({
769
- url: input.url,
770
- checkoutRoute: input.checkoutRoute,
771
- mandate: {
772
- maxAmountMinor: amountMinor,
773
- currency: input.currency,
774
- merchantHost: host,
775
- expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
776
- },
777
- amountMinor,
778
- currency: input.currency,
779
- browser,
780
- contact: input.contact,
781
- ...(input.trustedMerchantIdentity
782
- ? { trustedMerchantIdentity: input.trustedMerchantIdentity }
783
- : {}),
784
- }, store);
785
- if (prep.status !== 'ready') {
786
- // A terminal review is still a capability observation. Persist the
787
- // same compact, owner-only artifact as a pay attempt so the local
788
- // ledger records unsupported merchant surfaces and the safe card
789
- // display identity instead of retaining successes only.
790
- const observationId = createObservationId();
791
- const receiptWrite = await writeObservedReceipt('dry-run', buildReceipt({
792
- mode: 'dry-run',
793
- reviewId: null,
794
- observationId,
795
- merchant: {
796
- name: input.merchantName ?? host,
797
- host,
798
- url: input.url,
799
- },
800
- transaction: {
801
- amount: input.amount,
802
- amountMinor,
803
- currency: input.currency,
804
- },
805
- result: prep.result,
806
- vicConfirmation: null,
807
- agentName: input.agentName,
808
- cardLast4: input.cardLast4,
809
- recordedAt: now(),
810
- }));
811
- throw new CheckoutReviewRefusedError(prep.result, receiptWrite.event);
812
- }
813
- const r = prep.checkout.review;
814
- // Expire the companion session on the SAME schedule as the store entry
815
- // (unref'd so a pending timer never keeps the process alive).
816
- const cleanupTimer = setTimeout(() => void closeSession(r.id), ttlMs);
817
- cleanupTimer.unref?.();
818
- const target = buildTarget(input);
819
- if (r.merchantOrigin) {
820
- // The credential and mandate bind to the exact origin the browser
821
- // actually reviewed, not the Shopify service continuation that led
822
- // there. Keep the latter separately for review/pay replay binding.
823
- target.merchantUrl = r.merchantOrigin;
824
- target.merchantName = input.merchantName ?? r.merchantHost;
825
- }
826
- sessions.set(r.id, {
827
- browser,
828
- requestUrl: new URL(input.url).toString(),
829
- checkoutRoute: input.checkoutRoute,
830
- target,
831
- amountMinor,
832
- currency: input.currency,
833
- contact: input.contact,
834
- ...(input.agentJkt ? { agentJkt: input.agentJkt } : {}),
835
- ...(input.intentNarrative ? { intentNarrative: input.intentNarrative } : {}),
836
- cleanupTimer,
837
- });
838
- return {
839
- reviewId: r.id,
840
- merchantHost: r.merchantHost,
841
- amountMinor: r.amountMinor,
842
- currency: r.currency,
843
- submitTargetFingerprint: JSON.stringify(r.submitTargetFingerprint ?? null),
844
- detectedRoles: [...r.detectedRoles],
845
- };
846
- }
847
- catch (err) {
848
- await browser.close().catch(() => { });
849
- throw err;
850
- }
851
- },
852
- /**
853
- * Give up a prepared review without paying it (#7100).
854
- *
855
- * The retained browser is what lets a same-process MCP `review` → `pay`
856
- * draw against the checkout it already inspected. A TERMINAL review-only
857
- * run has no such second call — that process tells the operator to re-RUN
858
- * with `--submit`, and the new process cannot reach this one's in-memory
859
- * session. So the browser sat open for the full TTL, and because a live
860
- * browser connection keeps the event loop alive, a command that had already
861
- * succeeded looked hung until Ctrl-C.
862
- *
863
- * Idempotent and non-throwing: an unknown or already-released id is a
864
- * no-op, so it is safe on an error path that may not have prepared anything
865
- * and safe to call twice.
866
- */
867
- async releaseReview(reviewId) {
868
- await closeSession(reviewId);
869
- payAttempts.delete(reviewId);
870
- },
871
- async pay(input) {
872
- const noCredentialFacts = {
873
- credentialIssued: false,
874
- credentialDisclosed: false,
875
- };
876
- const fingerprint = payAttemptFingerprint(input);
877
- const priorAttempt = payAttempts.get(input.reviewId);
878
- if (priorAttempt) {
879
- return priorAttempt.fingerprint === fingerprint
880
- ? priorAttempt.promise
881
- : failedPay(`review ${input.reviewId} is already executing with different payment facts — wait for that exact attempt and inspect its receipt`);
882
- }
883
- const session = sessions.get(input.reviewId);
884
- // One signal for the whole pay lifecycle. `cardTokenId` is populated only
885
- // by the resolved v4 card-grant authority; legacy credential-file calls
886
- // omit it. Keeping the classification here prevents copy and fallback
887
- // behavior from drifting onto different grant detectors.
888
- const isV4CardGrant = typeof input.cardTokenId === 'string' && input.cardTokenId.trim() !== '';
889
- if (!session) {
890
- return {
891
- outcome: 'failed',
892
- confirmationRef: null,
893
- receiptPath: null,
894
- detail: `no prepared review ${input.reviewId} — call review first (a review does not survive a restart)`,
895
- vicConfirmation: null,
896
- source: null,
897
- remainingMinor: null,
898
- ...noCredentialFacts,
899
- };
900
- }
901
- // The reviewId selects the prepared browser session, but the pay call also
902
- // repeats the target facts. Refuse if any repeated fact disagrees so the
903
- // caller cannot submit one reviewed checkout while labeling the result or
904
- // telemetry as another. Never echo the full URLs: payment links can carry
905
- // claimable secrets in their path/query.
906
- let payUrl;
907
- let reviewedUrl;
908
- try {
909
- payUrl = new URL(input.url).toString();
910
- reviewedUrl = session.requestUrl;
911
- }
912
- catch {
913
- await closeSession(input.reviewId);
914
- return {
915
- outcome: 'failed',
916
- confirmationRef: null,
917
- receiptPath: null,
918
- detail: 'pay merchant URL is invalid or does not match the reviewed checkout — start a fresh review',
919
- vicConfirmation: null,
920
- source: null,
921
- remainingMinor: null,
922
- ...noCredentialFacts,
923
- };
924
- }
925
- if (payUrl !== reviewedUrl) {
926
- await closeSession(input.reviewId);
927
- return {
928
- outcome: 'failed',
929
- confirmationRef: null,
930
- receiptPath: null,
931
- detail: 'pay merchant URL does not match the reviewed checkout — start a fresh review',
932
- vicConfirmation: null,
933
- source: null,
934
- remainingMinor: null,
935
- ...noCredentialFacts,
936
- };
937
- }
938
- if (input.currency.toUpperCase() !== session.currency.toUpperCase()) {
939
- await closeSession(input.reviewId);
940
- return {
941
- outcome: 'failed',
942
- confirmationRef: null,
943
- receiptPath: null,
944
- detail: `pay currency ${JSON.stringify(input.currency)} does not match the reviewed currency (${session.currency}) — start a fresh review`,
945
- vicConfirmation: null,
946
- source: null,
947
- remainingMinor: null,
948
- ...noCredentialFacts,
949
- };
950
- }
951
- if (input.agentJkt !== session.agentJkt) {
952
- await closeSession(input.reviewId);
953
- return {
954
- outcome: 'failed',
955
- confirmationRef: null,
956
- receiptPath: null,
957
- detail: 'pay agent authority does not match the reviewed request key — start a fresh review',
958
- vicConfirmation: null,
959
- source: null,
960
- remainingMinor: null,
961
- ...noCredentialFacts,
962
- };
963
- }
964
- if (input.checkoutRoute !== session.checkoutRoute) {
965
- await closeSession(input.reviewId);
966
- return {
967
- outcome: 'failed',
968
- confirmationRef: null,
969
- receiptPath: null,
970
- detail: 'pay checkout route does not match the reviewed guest-card route — start a fresh review',
971
- vicConfirmation: null,
972
- source: null,
973
- remainingMinor: null,
974
- ...noCredentialFacts,
975
- };
976
- }
977
- // Amount-bind the confirmation: the pay-call amount must match the
978
- // reviewed amount. The reviewId already locks the immutable mandate, but
979
- // re-checking here makes the confirmation explicitly amount-bound so a
980
- // caller cannot pay a different figure than the human reviewed.
981
- const payAmountMinor = decimalToMinor(input.amount);
982
- if (payAmountMinor !== session.amountMinor) {
983
- await closeSession(input.reviewId);
984
- return {
985
- outcome: 'failed',
986
- confirmationRef: null,
987
- receiptPath: null,
988
- detail: `pay amount ${JSON.stringify(input.amount)} does not match the reviewed amount (${session.amountMinor} minor units) — start a fresh review`,
989
- vicConfirmation: null,
990
- source: null,
991
- remainingMinor: null,
992
- ...noCredentialFacts,
993
- };
994
- }
995
- // Reject an expired prepared checkout BEFORE running the hosted passkey
996
- // approval — the store may have expired/closed the entry while the review
997
- // waited. Peeking here avoids minting a credential we can't submit.
998
- if (!store.peek(input.reviewId)) {
999
- await closeSession(input.reviewId);
1000
- return {
1001
- outcome: 'failed',
1002
- confirmationRef: null,
1003
- receiptPath: null,
1004
- detail: `prepared review ${input.reviewId} expired — start a fresh review`,
1005
- vicConfirmation: null,
1006
- source: null,
1007
- remainingMinor: null,
1008
- ...noCredentialFacts,
1009
- };
1010
- }
1011
- let resolveAttempt;
1012
- let rejectAttempt;
1013
- const sharedResult = new Promise((resolve, reject) => {
1014
- resolveAttempt = resolve;
1015
- rejectAttempt = reject;
1016
- });
1017
- // The first caller still receives the direct execution result below; this
1018
- // retained promise exists for overlapping and bounded late duplicates.
1019
- // Mark its rejection handled even when there is no duplicate consumer.
1020
- void sharedResult.catch(() => undefined);
1021
- payAttempts.set(input.reviewId, { fingerprint, promise: sharedResult });
1022
- let completedAttempt;
1023
- const finishAttempt = (result) => {
1024
- completedAttempt = result;
1025
- resolveAttempt(result);
1026
- return result;
1027
- };
1028
- clearTimeout(session.cleanupTimer);
1029
- try {
1030
- // NO instrument read here. A tap-free mandate draw spends `covering
1031
- // .tokenId` (frozen into the mandate at start) and needs neither the
1032
- // legacy credential file nor a grant token, so a grant-only device with
1033
- // an as-yet-unrefreshed token can still draw on a mandate it already
1034
- // holds.
1035
- const host = new URL(session.target.merchantUrl).hostname;
1036
- // Does an ACTIVE card mandate already cover this exact purchase? If so,
1037
- // draw against it TAP-FREE (no hosted passkey). Else refuse with the
1038
- // mandate remedy (#7348). Mandates no longer persist the bootstrap
1039
- // token: it is consumed by intent creation + register and has no draw
1040
- // power.
1041
- const covering = await ledger.findCovering({
1042
- merchantHost: host,
1043
- currencyCode: session.currency,
1044
- amountMinor: session.amountMinor,
1045
- agentJkt: session.agentJkt,
1046
- now: now(),
1047
- });
1048
- let source;
1049
- let confirmBase;
1050
- // Mandate draws set this only after the local reserve succeeds and auth
1051
- // returns the exact per-draw verdict used to mint the credential.
1052
- let confirmationAuthority = null;
1053
- let drawnRemaining = null;
1054
- let instrument;
1055
- if (covering) {
1056
- // --- Tap-free mandate draw ------------------------------------------
1057
- source = 'mandate';
1058
- confirmBase = covering.approvalBaseUrl;
1059
- const mandateId = covering.mandateId;
1060
- // A mandate cryptogram is authorized only by a PoP-signed verdict.
1061
- // The budget mint token is bootstrap-only and cannot bypass the
1062
- // server reserve/commit ledger when local draw authority is absent.
1063
- //
1064
- // #5928 CRITICAL-2: the CLI does NOT settle the reservation. verify-web
1065
- // (which knows whether the cryptogram was payable) commits it on a
1066
- // payable mint and releases it on a decline, server-authoritatively, so
1067
- // the drawer can never release after a payable mint to dodge the ceiling.
1068
- const verdictSeam = cardDrawVerdict;
1069
- const capability = verdictSeam?.loadCapability(covering.agentJkt) ?? null;
1070
- if (!capability || !verdictSeam) {
1071
- return finishAttempt({
1072
- outcome: 'failed',
1073
- confirmationRef: null,
1074
- receiptPath: null,
1075
- detail: 'this mandate cannot draw because its delegated card authority is unavailable; ' +
1076
- 're-pair this runtime to restore the binding, or start a new mandate for this agent',
1077
- vicConfirmation: null,
1078
- source,
1079
- remainingMinor: null,
1080
- ...noCredentialFacts,
1081
- });
1082
- }
1083
- if (covering.agentJkt && capability.agentJkt !== covering.agentJkt) {
1084
- return finishAttempt({
1085
- outcome: 'failed',
1086
- confirmationRef: null,
1087
- receiptPath: null,
1088
- detail: 'this budget belongs to a different request key than the selected card capability; ' +
1089
- 'restore that exact runtime key, or start a new mandate from the current runtime',
1090
- vicConfirmation: null,
1091
- source,
1092
- remainingMinor: null,
1093
- ...noCredentialFacts,
1094
- });
1095
- }
1096
- // VgsLiveInstrument mints against an EXISTING intent (the mandate) with
1097
- // no fresh assurance — exactly the draw semantics. The fetchCredential
1098
- // seam routes through drawFromMandate so the ledger accounting (reserve
1099
- // -> commit on payable, release on reject) wraps the cryptogram pull.
1100
- const reference = {
1101
- tokenId: covering.tokenId,
1102
- intentId: mandateId,
1103
- merchantName: session.target.merchantName,
1104
- merchantUrl: session.target.merchantUrl,
1105
- merchantCountryCode: session.target.merchantCountryCode,
1106
- transactionAmount: session.target.transactionAmount,
1107
- transactionCurrencyCode: session.currency,
1108
- };
1109
- const fetchCredential = async ({ transaction }) => {
1110
- let draw;
1111
- try {
1112
- draw = await drawFromMandate({
1113
- mandateId,
1114
- amountMinor: session.amountMinor,
1115
- transaction: { ...transaction, transactionCurrencyCode: session.currency },
1116
- }, {
1117
- // Ordering matters: reserve the local ledger FIRST, then ask
1118
- // auth to reserve the server-authoritative budget immediately
1119
- // before the payable mint. A local reserve race/expiry/file
1120
- // failure therefore makes zero auth/verdict calls and cannot
1121
- // strand a server reservation until its sweep.
1122
- fetchCryptogram: async (i) => {
1123
- const { verdict } = await verdictSeam.fetchVerdict({
1124
- authBaseUrl: capability.authBaseUrl,
1125
- agentKey: capability.agentKey,
1126
- ...(capability.runtimeCertificate
1127
- ? { runtimeCertificate: capability.runtimeCertificate }
1128
- : {}),
1129
- mandateId,
1130
- // One draw per review; the reviewId is auth's idempotency key.
1131
- drawId: input.reviewId,
1132
- draw: {
1133
- tokenId: covering.tokenId,
1134
- amount: session.target.transactionAmount,
1135
- currency: session.currency,
1136
- merchantName: session.target.merchantName,
1137
- merchantUrl: session.target.merchantUrl,
1138
- merchantCountryCode: session.target.merchantCountryCode,
1139
- },
1140
- ...(session.intentNarrative
1141
- ? { intentNarrative: session.intentNarrative }
1142
- : {}),
1143
- });
1144
- confirmationAuthority = verdict;
1145
- return fetchMandateCryptogram(confirmBase, verdict, i);
1146
- },
1147
- ledger,
1148
- now,
1149
- });
1150
- }
1151
- catch (err) {
1152
- // Disable the mandate ONLY for a post-reservation NETWORK decline
1153
- // (MandateDrawDeclinedError). That case leaves the mandate
1154
- // active+covering, so a naive retry would re-select it and fail
1155
- // identically — trapping the caller; marking it unhonored makes the
1156
- // next pay_merchant skip it and surface the no-covering-mandate refusal
1157
- // (#7348) instead of re-failing identically. We do
1158
- // NOT attempt an unsafe same-call browser fallback mid-submit.
1159
- //
1160
- // A PRE-network failure — reserve() failing closed on a concurrent
1161
- // over-budget race or expiry, or commit() throwing on file I/O —
1162
- // is NOT a network decline: the mandate is HEALTHY, so we must
1163
- // rethrow WITHOUT disabling it (michaelyang1 M1). drawFromMandate
1164
- // has already released any reservation, so the budget is intact.
1165
- // Only a DEFINITIVE (hard) decline disables the mandate. A TRANSIENT
1166
- // failure — a gateway 5xx / "not completed, try again" / network
1167
- // reset/timeout — must NOT permanently kill the budget: the mandate
1168
- // may be perfectly healthy (it can have committed a draw seconds
1169
- // earlier) and the rails may recover on retry. Over-disabling on a
1170
- // transient 502 threw away good budgets and forced a fresh passkey
1171
- // every time.
1172
- // Classify on the underlying gateway CAUSE, not the MandateDrawDeclinedError
1173
- // wrapper — the wrapper's own advisory text mentions "try again"/"network",
1174
- // which would otherwise self-classify every decline as transient.
1175
- if (err instanceof MandateDrawDeclinedError) {
1176
- const verdictFailure = classifyCardDrawVerdictFailure(err);
1177
- if (verdictFailure) {
1178
- // #7491: a GRANT-capacity refusal is pre-credential and says
1179
- // nothing about this mandate, so it must NOT disable it. The
1180
- // mandate is healthy and becomes drawable again the moment the
1181
- // owner supplies a grant with room; disabling it here threw
1182
- // away a just-approved budget and pointed the caller at a new
1183
- // mandate that would die on the same wall.
1184
- if (!verdictFailure.transient && !verdictFailure.grantCapacity) {
1185
- await ledger.markUnhonored(mandateId, now()).catch(() => { });
1186
- }
1187
- if (verdictFailure.grantCapacity) {
1188
- 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 });
1189
- }
1190
- throw new Error(verdictFailure.transient
1191
- ? `the card-mandate draw could not be authorized right now (${verdictFailure.reasons.join(', ') || 'temporary error'}) — retry shortly`
1192
- : `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 });
1193
- }
1194
- if (!isTransientDrawFailure(err.cause ?? err)) {
1195
- await ledger.markUnhonored(mandateId, now());
1196
- }
1197
- }
1198
- // #5928 CRITICAL-2: the server-side reservation is released by
1199
- // verify-web (it saw the mint fail), not here — the CLI never settles.
1200
- throw err;
1201
- }
1202
- drawnRemaining = draw.remainingMinor;
1203
- // #5928 CRITICAL-2: the reservation is committed by verify-web on the
1204
- // payable mint (server-authoritative); the CLI does not settle.
1205
- return draw.payment;
1206
- };
1207
- instrument = new VgsLiveInstrument(reference, session.contact.fullName ?? '', fetchCredential);
1208
- }
1209
- else {
1210
- // #7348 single-purchase retirement: card spend is mandate-only for
1211
- // EVERY runtime. The legacy 1:1 single-purchase flow here minted a
1212
- // payable credential outside the grant/mandate ledger — invisible to
1213
- // spending controls. The server now refuses non-budget approvals at
1214
- // registration and non-budget mint tokens at verification; this
1215
- // client refusal exists to give the honest remedy up front instead
1216
- // of a mid-flow server error.
1217
- return finishAttempt({
1218
- outcome: 'failed',
1219
- confirmationRef: null,
1220
- receiptPath: null,
1221
- detail: isV4CardGrant
1222
- ? 'this v4 card grant has no active mandate covering the purchase; run ' +
1223
- '`visa mandate start --ceiling <usd> --per-transaction <usd>` for the selected ' +
1224
- 'agent, approve and claim the mandate, then retry this checkout. No payment ' +
1225
- 'credential was requested and no charge was made.'
1226
- : 'one-off card approvals are retired (#7348): card spending always runs through ' +
1227
- 'an owner-approved mandate now. Attach card authority to this agent ' +
1228
- '(`visa agent grant-card <agent-id> --ceiling <usd> --per-transaction <usd> --wait`), ' +
1229
- 'then start and claim a mandate (`visa mandate start <usd> --per-transaction <usd>`), ' +
1230
- 'and retry the checkout. No payment credential was requested and no charge was made.',
1231
- vicConfirmation: null,
1232
- source: null,
1233
- remainingMinor: null,
1234
- ...noCredentialFacts,
1235
- });
1236
- }
1237
- const mode = input.submit ? 'submit' : 'dry-run';
1238
- const result = await submitApprovedCheckout(input.reviewId, {
1239
- approval: { approved: true, reviewId: input.reviewId },
1240
- instrument,
1241
- contact: session.contact,
1242
- mode,
1243
- ...(deps.resolveEmailOtp ? { resolveEmailOtp: deps.resolveEmailOtp } : {}),
1244
- }, store);
1245
- // Report the observed submit outcome to VIC for the consumed intent
1246
- // (APPROVED/DECLINED). Only definitive submit answers post. Mirrors
1247
- // run-live-fill.ts.
1248
- let vicConfirmation = null;
1249
- let processorIntentId = null;
1250
- if (mode === 'submit') {
1251
- const confirmationTarget = instrument.confirmationTarget();
1252
- processorIntentId = confirmationTarget?.intentId ?? null;
1253
- vicConfirmation = await reportVicOutcome({
1254
- target: confirmationTarget,
1255
- outcome: result.outcome,
1256
- transaction: {
1257
- transactionAmount: session.target.transactionAmount,
1258
- transactionCurrencyCode: session.currency,
1259
- },
1260
- post: (confInput) => {
1261
- if (!confirmationAuthority) {
1262
- throw new Error('confirmation authority missing for the consumed VIC intent');
1263
- }
1264
- return postServerConfirmation(confirmBase, confirmationAuthority, confInput);
1265
- },
1266
- });
1267
- }
1268
- let receiptPath = null;
1269
- const receiptWrite = await writeObservedReceipt(mode, buildReceipt({
1270
- mode,
1271
- reviewId: input.reviewId,
1272
- // Derive BOTH name and host from the reviewed session target (not
1273
- // pay()'s input.url), so the receipt always reflects the checkout
1274
- // the human actually reviewed + that reviewId bound for submit.
1275
- merchant: {
1276
- name: session.target.merchantName,
1277
- host: new URL(session.target.merchantUrl).hostname,
1278
- url: session.target.merchantUrl,
1279
- },
1280
- transaction: {
1281
- amount: session.target.transactionAmount,
1282
- amountMinor: session.amountMinor,
1283
- currency: session.currency,
1284
- },
1285
- result,
1286
- vicConfirmation,
1287
- agentName: input.agentName,
1288
- cardLast4: input.cardLast4,
1289
- }));
1290
- if (receiptWrite.report.written)
1291
- receiptPath = receiptWrite.report.path;
1292
- return finishAttempt({
1293
- outcome: result.outcome,
1294
- confirmationRef: result.confirmationRef ?? null,
1295
- receiptPath,
1296
- detail: result.detail ?? null,
1297
- vicConfirmation,
1298
- source,
1299
- remainingMinor: drawnRemaining,
1300
- processorIntentId,
1301
- credentialIssued: result.credentialLifecycle !== 'not-requested',
1302
- credentialDisclosed: result.credentialLifecycle === 'partially-exposed' ||
1303
- result.credentialLifecycle === 'fully-filled',
1304
- receiptWrite: receiptWrite.event,
1305
- });
1306
- }
1307
- catch (error) {
1308
- rejectAttempt(error);
1309
- throw error;
1310
- }
1311
- finally {
1312
- await closeSession(input.reviewId);
1313
- if (!completedAttempt ||
1314
- completedAttempt.outcome === 'failed' ||
1315
- completedAttempt.outcome === 'declined') {
1316
- if (payAttempts.get(input.reviewId)?.promise === sharedResult) {
1317
- payAttempts.delete(input.reviewId);
1318
- }
1319
- }
1320
- else {
1321
- const expiry = setTimeout(() => {
1322
- if (payAttempts.get(input.reviewId)?.promise === sharedResult) {
1323
- payAttempts.delete(input.reviewId);
1324
- }
1325
- }, ttlMs);
1326
- expiry.unref?.();
1327
- }
1328
- }
1329
- },
1330
- };
1331
- }