@visa/cli 4.1.0-rc.24 → 4.1.0-rc.26
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.
- package/README.md +132 -242
- package/dist/checkout-engine/cli-engine.d.ts +142 -0
- package/dist/checkout-engine/cli-engine.js +377 -35
- package/dist/checkout-engine/detect.d.ts +1 -1
- package/dist/checkout-engine/detect.js +20 -0
- package/dist/checkout-engine/evidence.d.ts +3 -0
- package/dist/checkout-engine/evidence.js +51 -6
- package/dist/checkout-engine/executor.d.ts +3 -1
- package/dist/checkout-engine/executor.js +75 -2
- package/dist/checkout-engine/hosted-approval.d.ts +64 -7
- package/dist/checkout-engine/hosted-approval.js +194 -54
- package/dist/checkout-engine/index.d.ts +4 -1
- package/dist/checkout-engine/index.js +3 -0
- package/dist/checkout-engine/instrument.d.ts +1 -0
- package/dist/checkout-engine/instrument.js +4 -0
- package/dist/checkout-engine/live-fill-approval.d.ts +0 -9
- package/dist/checkout-engine/live-fill-approval.js +0 -17
- package/dist/checkout-engine/mandate/card-mandate.d.ts +117 -0
- package/dist/checkout-engine/mandate/card-mandate.js +221 -0
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +135 -0
- package/dist/checkout-engine/mandate/mandate-ledger.js +318 -0
- package/dist/checkout-engine/outcome.d.ts +2 -2
- package/dist/checkout-engine/outcome.js +36 -1
- package/dist/checkout-engine/owner-only-file.d.ts +9 -0
- package/dist/checkout-engine/owner-only-file.js +20 -1
- package/dist/checkout-engine/run-live-fill.js +151 -101
- package/dist/checkout-engine/types.d.ts +13 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +34 -1
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +35 -7
- package/dist/checkout-engine/vgs-live-instrument.d.ts +27 -0
- package/dist/checkout-engine/vgs-live-instrument.js +37 -0
- package/dist/cli.js +268 -385
- package/dist/mcp-server/index.js +251 -161
- package/dist/skills/pair-visa-agent/RUNTIMES.md +1 -1
- package/dist/skills/pair-visa-agent/SKILL.md +124 -51
- package/install.ps1 +3 -41
- package/install.sh +3 -35
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +5 -4
- 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,
|
|
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,
|
|
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
|
|
84
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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.
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
'
|
|
272
|
+
'packages/checkout-engine/dev-harness/generate-certs.sh once');
|
|
220
273
|
}
|
|
221
|
-
const vendorSdkPath = fileURLToPath(new URL('../../../apps/
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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 (
|
|
416
|
+
if (ceremonyFailure !== null) {
|
|
366
417
|
result = await cancelPreparedCheckout(review.id, `device-binding ceremony failed: ${ceremonyFailure}`);
|
|
367
418
|
}
|
|
368
419
|
else {
|
|
369
|
-
const instrument =
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
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
|
|
72
|
+
consumerPrompt,
|
|
47
73
|
assuranceData,
|
|
48
74
|
mandates: [
|
|
49
75
|
{
|
|
50
76
|
description: `Purchase at ${t.merchantName}`,
|
|
51
|
-
declineThresholdAmount
|
|
77
|
+
declineThresholdAmount,
|
|
52
78
|
declineThresholdCurrencyCode: t.transactionCurrencyCode.toUpperCase(),
|
|
53
|
-
effectiveUntil
|
|
79
|
+
effectiveUntil,
|
|
54
80
|
merchantCategory: 'Retail',
|
|
55
81
|
merchantCategoryCode: '5999',
|
|
56
82
|
preferredMerchantName: t.merchantName,
|
|
57
|
-
quantity
|
|
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
|
-
|
|
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' ||
|