@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
@@ -9,12 +9,70 @@ import { assertApprovalBaseUrl, resolveApprovalBaseUrl, runHostedApproval, } fro
9
9
  import { inlineTargetFromFlags } from './inline-target.js';
10
10
  import { createIntent, fetchCryptogram, postConfirmation } from './vgs-gateway/fetch-credential.mjs';
11
11
  import { cancelPreparedCheckout, prepareCheckout, submitApprovedCheckout, } from './executor.js';
12
- import { approvalPhrase, approvalQuestion, assertLegacyReferenceAllowed, assertSubmitAllowed, isRunSuccess, parseCheckoutMode, submitClickedWithoutConfirmation, } from './live-fill-approval.js';
12
+ import { approvalPhrase, approvalQuestion, assertSubmitAllowed, isRunSuccess, parseCheckoutMode, submitClickedWithoutConfirmation, } from './live-fill-approval.js';
13
13
  import { assertOwnerOnlyFile, readOwnerOnlyJson } from './owner-only-file.js';
14
14
  import { buildReceipt, writeReceipt } from './receipt.js';
15
15
  import { loadRepoEnvDefaults } from './repo-env.js';
16
16
  import { reportVicOutcome } from './vic-confirmation.js';
17
- import { decimalToMinor, VgsAssuranceInstrument, VgsLiveInstrument, } from './vgs-live-instrument.js';
17
+ import { decimalToMinor, VgsAssuranceInstrument, } from './vgs-live-instrument.js';
18
+ import { execFile } from 'node:child_process';
19
+ /** VISA_V4_HOME/identity/mailbox.json — mirrors @visa/wallet's v4Home default. */
20
+ function v4Identity(file) {
21
+ const home = process.env.VISA_V4_HOME || join(homedir(), '.visa-v4');
22
+ return join(home, 'identity', file);
23
+ }
24
+ /**
25
+ * The agent's provisioned inbox address from mailbox.json, or null when no
26
+ * inbox exists. Non-sensitive: address + opaque inbox id only. The sealed
27
+ * scoped key is NEVER read here — OTP reads go through the out-of-process
28
+ * resolver command (below), keeping the keychain gate outside this package.
29
+ */
30
+ function loadAgentInboxEmail() {
31
+ try {
32
+ const raw = readFileSync(v4Identity('mailbox.json'), 'utf8');
33
+ const rec = JSON.parse(raw);
34
+ return typeof rec.email === 'string' && rec.email.includes('@') ? rec.email : null;
35
+ }
36
+ catch {
37
+ return null; // no mailbox provisioned, or unreadable — keep the contact-file email
38
+ }
39
+ }
40
+ /**
41
+ * Build the injected email-OTP resolver, or undefined when none is configured.
42
+ * @visa/checkout-engine depends on playwright-core ONLY, so it must not import
43
+ * @visa/wallet-tools/@visa/agent-mail to read OTPs. Instead the operator wires
44
+ * an OUT-OF-PROCESS command (VISA_V4_OTP_RESOLVER_CMD) that shells to the
45
+ * keychain-gated `wallet_mail_await_otp` tool: the scoped-key read and the
46
+ * from-domain guard live entirely behind that command. The command receives a
47
+ * JSON request on argv and must print JSON `{code,fromDomain}` (or `null`) to
48
+ * stdout. Absent ⇒ no resolver ⇒ the executor hands email OTP to a human.
49
+ */
50
+ function buildOtpResolver() {
51
+ const cmd = process.env.VISA_V4_OTP_RESOLVER_CMD?.trim();
52
+ if (!cmd)
53
+ return undefined;
54
+ return (req) => new Promise((resolve) => {
55
+ execFile(cmd, [JSON.stringify(req)], { timeout: 150_000, maxBuffer: 1024 * 1024 }, (err, stdout) => {
56
+ if (err) {
57
+ process.stdout.write(`email OTP resolver command failed: ${err.message}\n`);
58
+ return resolve(null); // fail CLEAN — the executor falls back to a human
59
+ }
60
+ try {
61
+ const parsed = JSON.parse(stdout.trim() || 'null');
62
+ if (parsed &&
63
+ typeof parsed.code === 'string' &&
64
+ typeof parsed.fromDomain === 'string') {
65
+ process.stdout.write(`email OTP auto-resolved (from ${parsed.fromDomain})\n`);
66
+ return resolve({ code: parsed.code, fromDomain: parsed.fromDomain });
67
+ }
68
+ }
69
+ catch {
70
+ /* fall through to null */
71
+ }
72
+ resolve(null);
73
+ });
74
+ });
75
+ }
18
76
  function usage() {
19
77
  throw new Error('usage: pnpm fill:live --checkout-file <path> --contact-file <path> ' +
20
78
  '[--purchase-assurance-file <path>] [--approval-base-url <url>] ' +
@@ -27,9 +85,6 @@ function usage() {
27
85
  'the repo-root .env is loaded as env DEFAULTS (already-set variables win), e.g. for ' +
28
86
  'CHECKOUT_AGENT_MODE (--mode fallback); hosted approval on the deployed verify site ' +
29
87
  "is the built-in default (CHECKOUT_APPROVAL_BASE_URL='' forces the loopback ceremony)\n" +
30
- 'legacy: pnpm fill:live --reference-file <path> --contact-file <path> ' +
31
- '(requires CHECKOUT_AGENT_ALLOW_LEGACY_REFERENCE=1 — bypasses purchase-assurance ' +
32
- 'validation, #5709)\n' +
33
88
  'Without --purchase-assurance-file the runner performs the device-binding ' +
34
89
  'ceremony itself after the typed approval. With --approval-base-url (or ' +
35
90
  'CHECKOUT_APPROVAL_BASE_URL) the passkey tap happens on the deployed verify ' +
@@ -67,7 +122,6 @@ function args() {
67
122
  // changes nothing.
68
123
  loadRepoEnvDefaults();
69
124
  const input = args();
70
- const referenceFile = input.get('--reference-file');
71
125
  const checkoutFile = input.get('--checkout-file');
72
126
  // Inline any-merchant mode: --merchant-url + --amount build the target
73
127
  // directly, so no checkout JSON has to exist. Throws its own scoped errors;
@@ -80,13 +134,8 @@ const agentCredentialFile = input.get('--agent-credential-file') ?? join(homedir
80
134
  // (#5709). Without the file, the runner performs the ceremony itself after
81
135
  // the typed approval (interactive ceremony mode).
82
136
  const purchaseAssuranceFile = input.get('--purchase-assurance-file');
83
- // Exactly ONE target source: legacy reference file, checkout file, or the
84
- // inline flags.
85
- const targetSources = [
86
- typeof referenceFile === 'string',
87
- typeof checkoutFile === 'string',
88
- inlineTarget !== null,
89
- ].filter(Boolean).length;
137
+ // Exactly one target source: a checkout file or the inline flags.
138
+ const targetSources = [typeof checkoutFile === 'string', inlineTarget !== null].filter(Boolean).length;
90
139
  if (targetSources !== 1 || typeof agentCredentialFile !== 'string') {
91
140
  usage();
92
141
  }
@@ -102,20 +151,25 @@ const challengeHoldMs = Number.isFinite(challengeHoldMinutes) && challengeHoldMi
102
151
  // Refuse an unarmed submit before anything else runs — no file is read, no
103
152
  // browser launches, no credential can possibly be minted.
104
153
  assertSubmitAllowed(mode);
105
- // The legacy path bypasses purchase-assurance validation (#5709) — default-off,
106
- // same fail-fast posture as the submit gate.
107
- assertLegacyReferenceAllowed(typeof referenceFile === 'string');
108
- const reference = typeof referenceFile === 'string'
109
- ? await readOwnerOnlyJson(referenceFile, 'reference file')
110
- : null;
111
- const target = reference
112
- ? reference
113
- : (inlineTarget ??
114
- (await readOwnerOnlyJson(checkoutFile, 'checkout file')));
154
+ const target = inlineTarget ??
155
+ (await readOwnerOnlyJson(checkoutFile, 'checkout file'));
115
156
  const contact = await readOwnerOnlyJson(contactFile, 'contact file');
116
- if (reference && (!reference.tokenId || !reference.intentId)) {
117
- throw new Error('legacy reference requires tokenId and intentId');
157
+ // Route merchant verification mail to the AGENT's provisioned inbox, not the
158
+ // human's address: the merchant emails OTP/verification codes to whatever
159
+ // contact.email it is given, and wallet_mail_await_otp can only read the
160
+ // agent inbox. mailbox.json is non-sensitive provisioning state (address +
161
+ // opaque inbox id — the SCOPED KEY is sealed separately and never read here),
162
+ // so the runner reads it directly. Only override when a mailbox exists;
163
+ // otherwise keep the contact-file email.
164
+ const agentInboxEmail = loadAgentInboxEmail();
165
+ if (agentInboxEmail) {
166
+ contact.email = agentInboxEmail;
167
+ process.stdout.write(`Using agent inbox for merchant verification mail: ${agentInboxEmail}\n`);
118
168
  }
169
+ // Optional out-of-process email-OTP resolver (see buildOtpResolver). Undefined
170
+ // unless VISA_V4_OTP_RESOLVER_CMD is set — then merchant email codes are
171
+ // auto-resolved single-use; otherwise email OTP falls to a human.
172
+ const otpResolver = buildOtpResolver();
119
173
  if (typeof target.merchantName !== 'string' || !target.merchantName.trim()) {
120
174
  throw new Error('checkout target requires merchantName');
121
175
  }
@@ -125,8 +179,7 @@ if (typeof target.merchantCountryCode !== 'string' ||
125
179
  }
126
180
  // Normalize once so the instrument's strict uppercase re-check (which runs
127
181
  // after browser launch + operator confirmation) can't fail on a lowercase code
128
- // this loader already accepted. `target` aliases `reference` on the legacy
129
- // path, so both instruments see the normalized code.
182
+ // this loader already accepted.
130
183
  target.merchantCountryCode = target.merchantCountryCode.toUpperCase();
131
184
  if (!contact.fullName && !(contact.firstName && contact.lastName)) {
132
185
  throw new Error('contact file needs fullName or firstName + lastName');
@@ -168,11 +221,9 @@ target.transactionCurrencyCode = currency;
168
221
  // instead of after the operator has completed the manual checkout review and
169
222
  // typed the FILL phrase (review nit). Contents stay unread until after
170
223
  // approval — the guard reads nothing.
171
- if (!reference) {
172
- await assertOwnerOnlyFile(agentCredentialFile, 'CLI agent credential', 40 * 1024);
173
- if (typeof purchaseAssuranceFile === 'string') {
174
- await assertOwnerOnlyFile(purchaseAssuranceFile, 'purchase assurance', 40 * 1024);
175
- }
224
+ await assertOwnerOnlyFile(agentCredentialFile, 'CLI agent credential', 40 * 1024);
225
+ if (typeof purchaseAssuranceFile === 'string') {
226
+ await assertOwnerOnlyFile(purchaseAssuranceFile, 'purchase assurance', 40 * 1024);
176
227
  }
177
228
  // Interactive ceremony mode (no --purchase-assurance-file): the runner performs
178
229
  // the device-binding ceremony itself after the typed approval. With an
@@ -180,7 +231,7 @@ if (!reference) {
180
231
  // loopback page, no local TLS/SDK/VGS-credential prerequisites); otherwise it
181
232
  // runs on the loopback page. Fail fast on the active mode's prerequisites
182
233
  // here, before any browser launches.
183
- const ceremonyMode = !reference && typeof purchaseAssuranceFile !== 'string';
234
+ const ceremonyMode = typeof purchaseAssuranceFile !== 'string';
184
235
  // Hosted approval on the deployed verify site is the DEFAULT; set
185
236
  // CHECKOUT_APPROVAL_BASE_URL='' (empty) to force the loopback ceremony.
186
237
  const approvalBaseUrl = resolveApprovalBaseUrl(input.get('--approval-base-url'));
@@ -207,7 +258,9 @@ if (ceremonyMode) {
207
258
  // parent page (dev-harness certs) and the vendored SDK. The hosted page
208
259
  // brings its own origin and SDK — no local prerequisites.
209
260
  if (!hostedApproval) {
210
- const harnessDir = fileURLToPath(new URL('../../../apps/v4-verify-web/dev-harness/', import.meta.url));
261
+ // GA graduation: the harness moved into this package (was
262
+ // apps/v4-verify-web/dev-harness); the vendored SDK ships with apps/web.
263
+ const harnessDir = fileURLToPath(new URL('../dev-harness/', import.meta.url));
211
264
  try {
212
265
  ceremonyTls = {
213
266
  key: readFileSync(join(harnessDir, 'certs', 'key.pem')),
@@ -216,9 +269,9 @@ if (ceremonyMode) {
216
269
  }
217
270
  catch {
218
271
  throw new Error('interactive ceremony needs the dev-harness HTTPS certs — run ' +
219
- 'apps/v4-verify-web/dev-harness/generate-certs.sh once');
272
+ 'packages/checkout-engine/dev-harness/generate-certs.sh once');
220
273
  }
221
- const vendorSdkPath = fileURLToPath(new URL('../../../apps/v4-verify-web/public/vendor/vgs-agentic-auth.js', import.meta.url));
274
+ const vendorSdkPath = fileURLToPath(new URL('../../../apps/web/public/vendor/vgs-agentic-auth.js', import.meta.url));
222
275
  try {
223
276
  ceremonyVendorSdkJs = readFileSync(vendorSdkPath, 'utf8');
224
277
  }
@@ -317,83 +370,80 @@ try {
317
370
  let ceremonyFailure = null;
318
371
  let credential = null;
319
372
  let purchase = null;
320
- if (!reference) {
321
- credential = await readOwnerOnlyJson(agentCredentialFile, 'CLI agent credential', 40 * 1024);
322
- if (typeof purchaseAssuranceFile === 'string') {
323
- // Fresh, purchase-scoped assurance the instrument validates its
324
- // declared merchant/amount/currency scope + freshness against the
325
- // checkout target before any intent is minted (#5709).
326
- purchase = await readOwnerOnlyJson(purchaseAssuranceFile, 'purchase assurance', 40 * 1024);
327
- }
328
- else if (typeof credential.tokenId !== 'string' || !credential.tokenId.trim()) {
329
- ceremonyFailure = 'CLI agent credential requires tokenId';
330
- }
331
- else {
332
- process.stdout.write('approval received — complete the passkey on the Visa approval page…\n');
333
- try {
334
- purchase = hostedApproval
335
- ? await runHostedApproval({
336
- baseUrl: approvalBaseUrl,
373
+ credential = await readOwnerOnlyJson(agentCredentialFile, 'CLI agent credential', 40 * 1024);
374
+ if (typeof purchaseAssuranceFile === 'string') {
375
+ // Fresh, purchase-scoped assurance the instrument validates its
376
+ // declared merchant/amount/currency scope + freshness against the
377
+ // checkout target before any intent is minted (#5709).
378
+ purchase = await readOwnerOnlyJson(purchaseAssuranceFile, 'purchase assurance', 40 * 1024);
379
+ }
380
+ else if (typeof credential.tokenId !== 'string' || !credential.tokenId.trim()) {
381
+ ceremonyFailure = 'CLI agent credential requires tokenId';
382
+ }
383
+ else {
384
+ process.stdout.write('approval received — complete the passkey on the Visa approval page…\n');
385
+ try {
386
+ purchase = hostedApproval
387
+ ? await runHostedApproval({
388
+ baseUrl: approvalBaseUrl,
389
+ tokenId: credential.tokenId,
390
+ target,
391
+ ...(ceremonyConsumerEmail ? { consumerEmail: ceremonyConsumerEmail } : {}),
392
+ log: (line) => process.stdout.write(`${line}\n`),
393
+ })
394
+ : await runInteractiveCeremony({
395
+ page: {
337
396
  tokenId: credential.tokenId,
338
- target,
339
- ...(ceremonyConsumerEmail ? { consumerEmail: ceremonyConsumerEmail } : {}),
340
- log: (line) => process.stdout.write(`${line}\n`),
341
- })
342
- : await runInteractiveCeremony({
343
- page: {
344
- tokenId: credential.tokenId,
345
- environment: process.env.VGS_ENVIRONMENT === 'sandbox' ? 'sandbox' : 'live',
346
- consumerEmail: ceremonyConsumerEmail,
347
- merchantName: target.merchantName,
348
- amount: target.transactionAmount,
349
- currency,
350
- currencyNumericCode: currencyNumeric(currency),
351
- },
352
- target,
353
- vendorSdkJs: ceremonyVendorSdkJs,
354
- tls: ceremonyTls,
355
- mintAccessToken: mintCeremonyAccessToken,
356
- log: (line) => process.stdout.write(`${line}\n`),
357
- });
358
- process.stdout.write('passkey approved — minting the credential…\n');
359
- }
360
- catch (err) {
361
- ceremonyFailure = err.message;
362
- }
397
+ environment: process.env.VGS_ENVIRONMENT === 'sandbox' ? 'sandbox' : 'live',
398
+ consumerEmail: ceremonyConsumerEmail,
399
+ merchantName: target.merchantName,
400
+ amount: target.transactionAmount,
401
+ currency,
402
+ currencyNumericCode: currencyNumeric(currency),
403
+ },
404
+ target,
405
+ vendorSdkJs: ceremonyVendorSdkJs,
406
+ tls: ceremonyTls,
407
+ mintAccessToken: mintCeremonyAccessToken,
408
+ log: (line) => process.stdout.write(`${line}\n`),
409
+ });
410
+ process.stdout.write('passkey approved — minting the credential…\n');
411
+ }
412
+ catch (err) {
413
+ ceremonyFailure = err.message;
363
414
  }
364
415
  }
365
- if (!reference && ceremonyFailure !== null) {
416
+ if (ceremonyFailure !== null) {
366
417
  result = await cancelPreparedCheckout(review.id, `device-binding ceremony failed: ${ceremonyFailure}`);
367
418
  }
368
419
  else {
369
- const instrument = reference
370
- ? new VgsLiveInstrument(reference, cardholderName, fetchCryptogram)
371
- : new VgsAssuranceInstrument(credential, purchase, target, cardholderName,
372
- // The minter surfaces the fresh intentId (unlike getCredential,
373
- // which discards it) so the VIC confirmation below can target it.
374
- async (input) => {
375
- const { intentId, status } = await createIntent(input);
376
- try {
377
- const payment = await fetchCryptogram({
378
- tokenId: input.tokenId,
379
- intentId,
380
- transaction: input.transaction,
381
- });
382
- return { payment, intentId };
383
- }
384
- catch (err) {
385
- // HTTP 201 at intent creation ≠ an authorized intent — carry
386
- // the creation-time status so a not-completed cryptogram is
387
- // diagnosable in place (#5709).
388
- throw new Error(`${err.message} (intent status at creation: ${status ?? 'unknown'})`);
389
- }
390
- });
420
+ const instrument = new VgsAssuranceInstrument(credential, purchase, target, cardholderName,
421
+ // The minter surfaces the fresh intentId (unlike getCredential,
422
+ // which discards it) so the VIC confirmation below can target it.
423
+ async (input) => {
424
+ const { intentId, status } = await createIntent(input);
425
+ try {
426
+ const payment = await fetchCryptogram({
427
+ tokenId: input.tokenId,
428
+ intentId,
429
+ transaction: input.transaction,
430
+ });
431
+ return { payment, intentId };
432
+ }
433
+ catch (err) {
434
+ // HTTP 201 at intent creation ≠ an authorized intent — carry
435
+ // the creation-time status so a not-completed cryptogram is
436
+ // diagnosable in place (#5709).
437
+ throw new Error(`${err.message} (intent status at creation: ${status ?? 'unknown'})`);
438
+ }
439
+ });
391
440
  result = await submitApprovedCheckout(review.id, {
392
441
  approval: { approved: true, reviewId: review.id },
393
442
  instrument,
394
443
  contact,
395
444
  mode,
396
445
  challengeHoldMs,
446
+ ...(otpResolver ? { resolveEmailOtp: otpResolver } : {}),
397
447
  onChallengeHold: (signal) => process.stdout.write(signal === 'body:link-wallet'
398
448
  ? `Stripe Link wallet login appeared despite network suppression — this means Link engaged through an endpoint isStripeLinkConsumerRequest does not yet match. Do NOT enter the code (it would pay with a Link-saved card, not the minted credential): close the modal to continue as guest, then pull the Link request URL from the evidence log (note "linkSuppressed"/network trace) and extend the route predicate to cover it. Holding up to ${challengeHoldMinutes} min…\n`
399
449
  : `issuer challenge detected (${signal ?? 'challenge'}) — complete the bank verification in the browser window (enter the code your bank sent). Holding up to ${challengeHoldMinutes} min…\n`),
@@ -24,3 +24,16 @@ export type FillResult = {
24
24
  ok: boolean;
25
25
  filled: FilledField[];
26
26
  };
27
+ export type OtpRequest = {
28
+ /** ISO watermark captured BEFORE the click that triggers the OTP email. */
29
+ after: string;
30
+ /** The merchant hostname; the sender's registrable domain must match it. */
31
+ merchantHost: string;
32
+ };
33
+ export type OtpResolution = {
34
+ /** The single-use code. The executor fills it exactly once; never retried. */
35
+ code: string;
36
+ /** The sender's registrable domain — a trust signal, safe to log (no PII). */
37
+ fromDomain: string;
38
+ };
39
+ export type OtpResolver = (req: OtpRequest) => Promise<OtpResolution | null>;
@@ -6,15 +6,48 @@ export type ServerMintDeps = {
6
6
  sleep?: (ms: number) => Promise<void>;
7
7
  env?: NodeJS.ProcessEnv;
8
8
  };
9
+ /**
10
+ * The merchant ORIGIN (scheme + host) — not the full checkout URL. A merchant's
11
+ * identity is its origin; a real checkout URL can carry hundreds of chars of
12
+ * campaign/tracking query params. Confirmed live: an oversized `merchantUrl`
13
+ * (a ~280-char Wikimedia donation URL) makes the cryptogram mint fail downstream
14
+ * with a vague `502 "card network could not complete"`, while the same merchant
15
+ * with a trimmed URL mints fine. Sending the origin is both correct (that IS the
16
+ * merchant) and safely bounded. Falls back to the raw value if it does not parse
17
+ * — the mint must never throw here.
18
+ */
19
+ export declare function merchantOrigin(url: string): string;
20
+ /**
21
+ * Optional mandate override for serverCreateIntent. Absent → the historical
22
+ * 1:1 fresh-tap shape is preserved byte-for-byte (cap = ceil(amount)+10 min 25,
23
+ * quantity 1, 30-day window). Present → the card-mandate (budget) layer sets a
24
+ * CEILING decline threshold and a multi-draw quantity so one passkey approves a
25
+ * spend ceiling and later draws pull cryptograms under it without a fresh tap.
26
+ * Whether the network honors a ceiling-scoped assurance across multiple
27
+ * sub-amount draws is UNPROVEN — see packages/checkout-engine/src/mandate/.
28
+ */
29
+ export type ServerIntentMandateOverride = {
30
+ /** Wire decline-threshold amount (major-unit decimal string), e.g. "500.00". */
31
+ declineThresholdAmount?: string;
32
+ /** Number of draws the intent may fulfil (VGS mandate `quantity`). */
33
+ quantity?: number;
34
+ /** ISO 8601 mandate validity end. */
35
+ effectiveUntil?: string;
36
+ /** Human-readable consumer prompt describing the ceiling grant. */
37
+ consumerPrompt?: string;
38
+ };
9
39
  /**
10
40
  * Create a fresh intent via POST {base}/api/vgs/intent. The server holds the VGS
11
41
  * credential and calls the gateway; we send the same mandate shape the local
12
- * path built (cap = ceil(amount)+10, min 25; merchant category Retail/5999).
42
+ * path built (cap = ceil(amount)+10, min 25; merchant category Retail/5999)
43
+ * unless `input.mandate` overrides the threshold/quantity/window for the
44
+ * card-mandate (budget) layer.
13
45
  */
14
46
  export declare function serverCreateIntent(base: string, mintToken: string, input: {
15
47
  tokenId: string;
16
48
  assuranceData: unknown;
17
49
  transaction: VgsCheckoutTarget;
50
+ mandate?: ServerIntentMandateOverride;
18
51
  }, deps?: ServerMintDeps): Promise<{
19
52
  intentId: string;
20
53
  status: string | null;
@@ -21,6 +21,24 @@ function stripTrailingSlashes(value) {
21
21
  out = out.slice(0, -1);
22
22
  return out;
23
23
  }
24
+ /**
25
+ * The merchant ORIGIN (scheme + host) — not the full checkout URL. A merchant's
26
+ * identity is its origin; a real checkout URL can carry hundreds of chars of
27
+ * campaign/tracking query params. Confirmed live: an oversized `merchantUrl`
28
+ * (a ~280-char Wikimedia donation URL) makes the cryptogram mint fail downstream
29
+ * with a vague `502 "card network could not complete"`, while the same merchant
30
+ * with a trimmed URL mints fine. Sending the origin is both correct (that IS the
31
+ * merchant) and safely bounded. Falls back to the raw value if it does not parse
32
+ * — the mint must never throw here.
33
+ */
34
+ export function merchantOrigin(url) {
35
+ try {
36
+ return new URL(url).origin;
37
+ }
38
+ catch {
39
+ return url;
40
+ }
41
+ }
24
42
  /** Read a stable, non-secret error message from a route's JSON body. */
25
43
  async function routeError(res) {
26
44
  const doc = (await res.json().catch(() => null));
@@ -32,29 +50,37 @@ function bearer(mintToken) {
32
50
  /**
33
51
  * Create a fresh intent via POST {base}/api/vgs/intent. The server holds the VGS
34
52
  * credential and calls the gateway; we send the same mandate shape the local
35
- * path built (cap = ceil(amount)+10, min 25; merchant category Retail/5999).
53
+ * path built (cap = ceil(amount)+10, min 25; merchant category Retail/5999)
54
+ * unless `input.mandate` overrides the threshold/quantity/window for the
55
+ * card-mandate (budget) layer.
36
56
  */
37
57
  export async function serverCreateIntent(base, mintToken, input, deps = {}) {
38
58
  const fetchImpl = deps.fetchImpl ?? fetch;
39
59
  const { tokenId, assuranceData, transaction: t } = input;
40
- const cap = Math.max(Math.ceil(Number(t.transactionAmount) || 0) + 10, 25);
60
+ const override = input.mandate ?? {};
61
+ const defaultCap = Math.max(Math.ceil(Number(t.transactionAmount) || 0) + 10, 25);
62
+ const declineThresholdAmount = override.declineThresholdAmount ?? String(defaultCap);
63
+ const quantity = override.quantity ?? 1;
64
+ const effectiveUntil = override.effectiveUntil ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
65
+ const consumerPrompt = override.consumerPrompt ??
66
+ `Buy an item from ${t.merchantName} for ${t.transactionCurrencyCode.toUpperCase()} ${t.transactionAmount}`;
41
67
  const res = await fetchImpl(`${stripTrailingSlashes(base)}/api/vgs/intent`, {
42
68
  method: 'POST',
43
69
  headers: bearer(mintToken),
44
70
  body: JSON.stringify({
45
71
  tokenId,
46
- consumerPrompt: `Buy an item from ${t.merchantName} for ${t.transactionCurrencyCode.toUpperCase()} ${t.transactionAmount}`,
72
+ consumerPrompt,
47
73
  assuranceData,
48
74
  mandates: [
49
75
  {
50
76
  description: `Purchase at ${t.merchantName}`,
51
- declineThresholdAmount: String(cap),
77
+ declineThresholdAmount,
52
78
  declineThresholdCurrencyCode: t.transactionCurrencyCode.toUpperCase(),
53
- effectiveUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
79
+ effectiveUntil,
54
80
  merchantCategory: 'Retail',
55
81
  merchantCategoryCode: '5999',
56
82
  preferredMerchantName: t.merchantName,
57
- quantity: 1,
83
+ quantity,
58
84
  },
59
85
  ],
60
86
  }),
@@ -89,7 +115,9 @@ export async function serverFetchCryptogram(base, mintToken, input, deps = {}) {
89
115
  intentId,
90
116
  transaction: {
91
117
  merchantName: t.merchantName,
92
- merchantUrl: t.merchantUrl,
118
+ // Send the merchant ORIGIN, not the full (possibly huge) checkout URL — an
119
+ // oversized merchantUrl makes the downstream cryptogram mint 502 (proven live).
120
+ merchantUrl: merchantOrigin(t.merchantUrl),
93
121
  merchantCountryCode: t.merchantCountryCode,
94
122
  transactionAmount: t.transactionAmount,
95
123
  transactionCurrencyCode: t.transactionCurrencyCode.toUpperCase(),
@@ -83,6 +83,33 @@ export type VicConfirmationTarget = {
83
83
  intentId: string;
84
84
  };
85
85
  export declare function decimalToMinor(value: unknown): number | null;
86
+ /**
87
+ * Currencies whose minor unit is 1/100 of the major unit (2 decimal places).
88
+ * minorToDecimal's /100 + pad-to-2 rendering is ONLY correct for these. The
89
+ * card mandate path accepts any 3-letter ISO code, so a 0- or 3-decimal
90
+ * currency (JPY, BHD, …) would otherwise be emitted with a wrong wire amount.
91
+ * We only support USD/USDC today; extend deliberately (and fix the exponent)
92
+ * before adding any non-2-decimal currency.
93
+ *
94
+ * NOTE (scanner FP pre-empt): this is a CLIENT-SIDE currency-exponent lookup for
95
+ * rendering a wire amount in @visa/checkout-engine — it is NOT the cross-store
96
+ * `PAYMENT_CURRENCIES` enum governed by apps/auth/src/shared-validators.ts, and
97
+ * this package cannot import from apps/auth (no dependency edge). It intentionally
98
+ * does not mirror that enum's membership; do not "reconcile" the two.
99
+ */
100
+ export declare const TWO_DECIMAL_CURRENCIES: Set<string>;
101
+ /**
102
+ * Inverse of decimalToMinor: render integer minor units as a major-unit decimal
103
+ * string ("D.DD") for the VGS wire (e.g. a ceiling of 50000 minor → "500.00").
104
+ * Integer-only; never floating-point money. Throws on a non-integer/negative so
105
+ * a bad accounting value cannot silently reach the network.
106
+ *
107
+ * The /100 + pad-to-2 rendering assumes a 2-decimal (exponent-2) currency. Pass
108
+ * `currencyCode` on any path that accepts arbitrary currencies (the mandate
109
+ * path) and it REFUSES a non-2-decimal currency rather than emit a wrong wire
110
+ * amount. Omit it only where the currency is already known to be USD/USDC.
111
+ */
112
+ export declare function minorToDecimal(minor: number, currencyCode?: string): string;
86
113
  /**
87
114
  * Refuse to mint an intent unless the assurance is fresh and its declared
88
115
  * scope matches the checkout target exactly. `now` is injectable so the
@@ -19,6 +19,43 @@ export function decimalToMinor(value) {
19
19
  const minor = Number(whole) * 100 + Number(fraction.padEnd(2, '0'));
20
20
  return Number.isSafeInteger(minor) ? minor : null;
21
21
  }
22
+ /**
23
+ * Currencies whose minor unit is 1/100 of the major unit (2 decimal places).
24
+ * minorToDecimal's /100 + pad-to-2 rendering is ONLY correct for these. The
25
+ * card mandate path accepts any 3-letter ISO code, so a 0- or 3-decimal
26
+ * currency (JPY, BHD, …) would otherwise be emitted with a wrong wire amount.
27
+ * We only support USD/USDC today; extend deliberately (and fix the exponent)
28
+ * before adding any non-2-decimal currency.
29
+ *
30
+ * NOTE (scanner FP pre-empt): this is a CLIENT-SIDE currency-exponent lookup for
31
+ * rendering a wire amount in @visa/checkout-engine — it is NOT the cross-store
32
+ * `PAYMENT_CURRENCIES` enum governed by apps/auth/src/shared-validators.ts, and
33
+ * this package cannot import from apps/auth (no dependency edge). It intentionally
34
+ * does not mirror that enum's membership; do not "reconcile" the two.
35
+ */
36
+ export const TWO_DECIMAL_CURRENCIES = new Set(['USD', 'USDC']);
37
+ /**
38
+ * Inverse of decimalToMinor: render integer minor units as a major-unit decimal
39
+ * string ("D.DD") for the VGS wire (e.g. a ceiling of 50000 minor → "500.00").
40
+ * Integer-only; never floating-point money. Throws on a non-integer/negative so
41
+ * a bad accounting value cannot silently reach the network.
42
+ *
43
+ * The /100 + pad-to-2 rendering assumes a 2-decimal (exponent-2) currency. Pass
44
+ * `currencyCode` on any path that accepts arbitrary currencies (the mandate
45
+ * path) and it REFUSES a non-2-decimal currency rather than emit a wrong wire
46
+ * amount. Omit it only where the currency is already known to be USD/USDC.
47
+ */
48
+ export function minorToDecimal(minor, currencyCode) {
49
+ if (!Number.isSafeInteger(minor) || minor < 0) {
50
+ throw new Error('minor units must be a non-negative safe integer');
51
+ }
52
+ if (currencyCode !== undefined && !TWO_DECIMAL_CURRENCIES.has(currencyCode.toUpperCase())) {
53
+ throw new Error(`minorToDecimal only supports 2-decimal currencies (${[...TWO_DECIMAL_CURRENCIES].join('/')}); refusing to emit a wire amount for ${currencyCode}`);
54
+ }
55
+ const whole = Math.floor(minor / 100);
56
+ const fraction = minor % 100;
57
+ return `${whole}.${String(fraction).padStart(2, '0')}`;
58
+ }
22
59
  function validateTarget(reference, ctx) {
23
60
  if (typeof reference.merchantName !== 'string' ||
24
61
  typeof reference.merchantCountryCode !== 'string' ||