@visa/cli 4.1.0-rc.44 → 4.1.0-rc.46
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/dist/checkout-engine/mandate/card-mandate.js +1 -1
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +8 -2
- package/dist/checkout-engine/mandate/mandate-ledger.js +9 -3
- package/dist/cli.js +251 -250
- package/dist/mcp-server/index.js +187 -186
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +1 -2
- package/server.json +2 -2
- package/dist/checkout-engine/pay-args.d.ts +0 -14
- package/dist/checkout-engine/pay-args.js +0 -44
- package/dist/checkout-engine/pay.d.ts +0 -1
- package/dist/checkout-engine/pay.js +0 -13
- package/dist/checkout-engine/run-live-fill.d.ts +0 -1
- package/dist/checkout-engine/run-live-fill.js +0 -495
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@visa/cli",
|
|
3
|
-
"version": "4.1.0-rc.
|
|
3
|
+
"version": "4.1.0-rc.46",
|
|
4
4
|
"description": "Visa CLI runtime for stable agent identity and separately authorized payment capabilities",
|
|
5
5
|
"bin": {
|
|
6
6
|
"visa-cli": "./bin/visa-cli.js",
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
"test:smoke": "VISA_AUTH_URL=https://auth.visacli.sh jest --config jest.smoke.config.js",
|
|
23
23
|
"test:integration": "jest --config jest.integration.config.js",
|
|
24
24
|
"test:e2e": "jest --config jest.e2e.config.js",
|
|
25
|
-
"test:catalog-e2e": "jest --config jest.catalog-e2e.config.js",
|
|
26
25
|
"test:all": "npm run test:unit && npm run test:integration && npm run test:e2e",
|
|
27
26
|
"prepublishOnly": "npm run build && npm test",
|
|
28
27
|
"lint": "eslint src/**/*.ts",
|
package/server.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json",
|
|
3
3
|
"name": "io.github.visa-crypto-labs/visa-cli",
|
|
4
|
-
"version": "4.1.0-rc.
|
|
4
|
+
"version": "4.1.0-rc.46",
|
|
5
5
|
"title": "Visa CLI",
|
|
6
6
|
"description": "Pair a human-approved agent identity, configure payment capabilities separately, and discover and pay x402 services from your AI coding assistant.",
|
|
7
7
|
"websiteUrl": "https://github.com/Visa-Crypto-Labs/Visa-mono/tree/main/packages/cli#readme",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
{
|
|
10
10
|
"registryType": "npm",
|
|
11
11
|
"identifier": "@visa/cli",
|
|
12
|
-
"version": "4.1.0-rc.
|
|
12
|
+
"version": "4.1.0-rc.46",
|
|
13
13
|
"transport": {
|
|
14
14
|
"type": "stdio"
|
|
15
15
|
},
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Argv rewriter for the `pnpm pay` positional entry point, extracted pure so
|
|
3
|
-
* it is unit-testable (the pay.ts script executes on import). Maps
|
|
4
|
-
* `<checkout-url> <amount> [flags]` onto run-live-fill's inline flags.
|
|
5
|
-
*
|
|
6
|
-
* Strict by design — this drives a real-money run:
|
|
7
|
-
* - `--merchant-url`/`--amount` are REJECTED as passthrough flags: the
|
|
8
|
-
* runner's arg map lets a later flag win, so a stray duplicate would
|
|
9
|
-
* silently retarget the purchase away from the typed positionals.
|
|
10
|
-
* - Every flag must carry a value (same grammar as run-live-fill's args()),
|
|
11
|
-
* so a trailing bare flag fails HERE with pay's usage, not downstream with
|
|
12
|
-
* fill:live's.
|
|
13
|
-
*/
|
|
14
|
-
export declare function rewritePayArgs(argv: string[]): string[];
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Argv rewriter for the `pnpm pay` positional entry point, extracted pure so
|
|
3
|
-
* it is unit-testable (the pay.ts script executes on import). Maps
|
|
4
|
-
* `<checkout-url> <amount> [flags]` onto run-live-fill's inline flags.
|
|
5
|
-
*
|
|
6
|
-
* Strict by design — this drives a real-money run:
|
|
7
|
-
* - `--merchant-url`/`--amount` are REJECTED as passthrough flags: the
|
|
8
|
-
* runner's arg map lets a later flag win, so a stray duplicate would
|
|
9
|
-
* silently retarget the purchase away from the typed positionals.
|
|
10
|
-
* - Every flag must carry a value (same grammar as run-live-fill's args()),
|
|
11
|
-
* so a trailing bare flag fails HERE with pay's usage, not downstream with
|
|
12
|
-
* fill:live's.
|
|
13
|
-
*/
|
|
14
|
-
const USAGE = 'usage: pnpm pay -- <checkout-url> <amount> [--merchant-name <name>] ' +
|
|
15
|
-
'[--country <CC>] [--currency <ISO>] [--mode dry-run|submit] [--contact-file <path>] ' +
|
|
16
|
-
'[--agent-credential-file <path>] [--approval-base-url <url>]';
|
|
17
|
-
const RESERVED_FLAGS = ['--merchant-url', '--amount'];
|
|
18
|
-
export function rewritePayArgs(argv) {
|
|
19
|
-
// pnpm forwards the literal `--` separator; drop it like run-live-fill does.
|
|
20
|
-
const tokens = argv.filter((a) => a !== '--');
|
|
21
|
-
const positionals = [];
|
|
22
|
-
const flags = [];
|
|
23
|
-
for (let i = 0; i < tokens.length; i++) {
|
|
24
|
-
const token = tokens[i];
|
|
25
|
-
if (token.startsWith('--')) {
|
|
26
|
-
if (RESERVED_FLAGS.includes(token)) {
|
|
27
|
-
throw new Error(`${token} is derived from the positionals — pass the url and amount ` +
|
|
28
|
-
`positionally instead of as flags\n${USAGE}`);
|
|
29
|
-
}
|
|
30
|
-
const value = tokens[i + 1];
|
|
31
|
-
if (value === undefined || value.startsWith('--')) {
|
|
32
|
-
throw new Error(`flag ${token} needs a value\n${USAGE}`);
|
|
33
|
-
}
|
|
34
|
-
flags.push(token, value);
|
|
35
|
-
i++;
|
|
36
|
-
}
|
|
37
|
-
else {
|
|
38
|
-
positionals.push(token);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
if (positionals.length !== 2)
|
|
42
|
-
throw new Error(USAGE);
|
|
43
|
-
return ['--merchant-url', positionals[0], '--amount', positionals[1], ...flags];
|
|
44
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Positional sugar over run-live-fill's inline any-merchant mode:
|
|
3
|
-
*
|
|
4
|
-
* pnpm pay -- <checkout-url> <amount> [--mode submit] [--merchant-name "X"] [...]
|
|
5
|
-
*
|
|
6
|
-
* Rewrites the two positionals into --merchant-url/--amount (pay-args.ts,
|
|
7
|
-
* strict) and re-enters the runner, so every guardrail (typed PAY approval,
|
|
8
|
-
* CHECKOUT_AGENT_ALLOW_SUBMIT, hosted/loopback ceremony, receipts) is exactly
|
|
9
|
-
* the fill:live path — this file must never grow logic of its own.
|
|
10
|
-
*/
|
|
11
|
-
import { rewritePayArgs } from './pay-args.js';
|
|
12
|
-
process.argv = [process.argv[0], process.argv[1], ...rewritePayArgs(process.argv.slice(2))];
|
|
13
|
-
await import('./run-live-fill.js');
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,495 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { homedir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { createInterface } from 'node:readline/promises';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { launchCheckoutBrowser } from './browser-launch.js';
|
|
7
|
-
import { CURRENCY_NUMERIC, currencyNumeric, runInteractiveCeremony } from './ceremony.js';
|
|
8
|
-
import { assertApprovalBaseUrl, resolveApprovalBaseUrl, runHostedApproval, } from './hosted-approval.js';
|
|
9
|
-
import { inlineTargetFromFlags } from './inline-target.js';
|
|
10
|
-
import { createIntent, fetchCryptogram, postConfirmation } from './vgs-gateway/fetch-credential.mjs';
|
|
11
|
-
import { cancelPreparedCheckout, prepareCheckout, submitApprovedCheckout, } from './executor.js';
|
|
12
|
-
import { approvalPhrase, approvalQuestion, assertSubmitAllowed, isRunSuccess, parseCheckoutMode, submitClickedWithoutConfirmation, } from './live-fill-approval.js';
|
|
13
|
-
import { assertOwnerOnlyFile, readOwnerOnlyJson } from './owner-only-file.js';
|
|
14
|
-
import { buildReceipt, writeReceipt } from './receipt.js';
|
|
15
|
-
import { loadRepoEnvDefaults } from './repo-env.js';
|
|
16
|
-
import { reportVicOutcome } from './vic-confirmation.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 exportable
|
|
27
|
-
* scoped bearer credential is NEVER read here — OTP reads go through the
|
|
28
|
-
* out-of-process resolver command (below), keeping the owner-only file gate
|
|
29
|
-
* outside this package.
|
|
30
|
-
*/
|
|
31
|
-
function loadAgentInboxEmail() {
|
|
32
|
-
try {
|
|
33
|
-
const raw = readFileSync(v4Identity('mailbox.json'), 'utf8');
|
|
34
|
-
const rec = JSON.parse(raw);
|
|
35
|
-
return typeof rec.email === 'string' && rec.email.includes('@') ? rec.email : null;
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
return null; // no mailbox provisioned, or unreadable — keep the contact-file email
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* Build the injected email-OTP resolver, or undefined when none is configured.
|
|
43
|
-
* @visa/checkout-engine depends on playwright-core ONLY, so it must not import
|
|
44
|
-
* @visa/wallet-tools/@visa/agent-mail to read OTPs. Instead the operator wires
|
|
45
|
-
* an OUT-OF-PROCESS command (VISA_V4_OTP_RESOLVER_CMD) that shells to the
|
|
46
|
-
* owner-only-credential-gated `wallet_mail_await_otp` tool: the scoped-key read and the
|
|
47
|
-
* from-domain guard live entirely behind that command. The command receives a
|
|
48
|
-
* JSON request on argv and must print JSON `{code,fromDomain}` (or `null`) to
|
|
49
|
-
* stdout. Absent ⇒ no resolver ⇒ the executor hands email OTP to a human.
|
|
50
|
-
*/
|
|
51
|
-
function buildOtpResolver() {
|
|
52
|
-
const cmd = process.env.VISA_V4_OTP_RESOLVER_CMD?.trim();
|
|
53
|
-
if (!cmd)
|
|
54
|
-
return undefined;
|
|
55
|
-
return (req) => new Promise((resolve) => {
|
|
56
|
-
execFile(cmd, [JSON.stringify(req)], { timeout: 150_000, maxBuffer: 1024 * 1024 }, (err, stdout) => {
|
|
57
|
-
if (err) {
|
|
58
|
-
process.stdout.write(`email OTP resolver command failed: ${err.message}\n`);
|
|
59
|
-
return resolve(null); // fail CLEAN — the executor falls back to a human
|
|
60
|
-
}
|
|
61
|
-
try {
|
|
62
|
-
const parsed = JSON.parse(stdout.trim() || 'null');
|
|
63
|
-
if (parsed &&
|
|
64
|
-
typeof parsed.code === 'string' &&
|
|
65
|
-
typeof parsed.fromDomain === 'string') {
|
|
66
|
-
process.stdout.write(`email OTP auto-resolved (from ${parsed.fromDomain})\n`);
|
|
67
|
-
return resolve({ code: parsed.code, fromDomain: parsed.fromDomain });
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
catch {
|
|
71
|
-
/* fall through to null */
|
|
72
|
-
}
|
|
73
|
-
resolve(null);
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
function usage() {
|
|
78
|
-
throw new Error('usage: pnpm fill:live --checkout-file <path> --contact-file <path> ' +
|
|
79
|
-
'[--purchase-assurance-file <path>] [--approval-base-url <url>] ' +
|
|
80
|
-
'[--agent-credential-file <path>] [--mode dry-run|submit] [--receipt-dir <path>]\n' +
|
|
81
|
-
'inline (any merchant, no checkout file): pnpm fill:live --merchant-url <url> ' +
|
|
82
|
-
'--amount <decimal> [--merchant-name <name>] [--country <CC>] [--currency <ISO>] — ' +
|
|
83
|
-
'merchant name defaults to the URL hostname, country to US, currency to USD; ' +
|
|
84
|
-
'positional sugar: pnpm pay -- <url> <amount> [flags]\n' +
|
|
85
|
-
'--contact-file defaults to ~/.visa-mcp/contact.json\n' +
|
|
86
|
-
'the repo-root .env is loaded as env DEFAULTS (already-set variables win), e.g. for ' +
|
|
87
|
-
'CHECKOUT_AGENT_MODE (--mode fallback); hosted approval on the deployed verify site ' +
|
|
88
|
-
"is the built-in default (CHECKOUT_APPROVAL_BASE_URL='' forces the loopback ceremony)\n" +
|
|
89
|
-
'Without --purchase-assurance-file the runner performs the device-binding ' +
|
|
90
|
-
'ceremony itself after the typed approval. With --approval-base-url (or ' +
|
|
91
|
-
'CHECKOUT_APPROVAL_BASE_URL) the passkey tap happens on the deployed verify ' +
|
|
92
|
-
"site's /approve page (no localhost anywhere); otherwise it opens a loopback " +
|
|
93
|
-
'Visa approval page. Either way the assurance arrives in-process (seconds old ' +
|
|
94
|
-
'at mint, never written to disk).\n' +
|
|
95
|
-
'--purchase-assurance-file holds the output of a fresh device-binding ceremony ' +
|
|
96
|
-
'scoped to this exact merchant, amount, and currency ({assuranceData, mintedAt, ' +
|
|
97
|
-
'merchantHost, transactionAmount, transactionCurrencyCode}); the enrollment ' +
|
|
98
|
-
'artifact is never purchase authorization (#5709)\n' +
|
|
99
|
-
'--mode submit clicks the pay control after a typed PAY approval; non-interactive ' +
|
|
100
|
-
'(scripted) submits additionally require CHECKOUT_AGENT_ALLOW_SUBMIT=1 ' +
|
|
101
|
-
'(default: dry-run, never submits)');
|
|
102
|
-
}
|
|
103
|
-
function args() {
|
|
104
|
-
const parsed = new Map();
|
|
105
|
-
for (let i = 2; i < process.argv.length; i++) {
|
|
106
|
-
const key = process.argv[i];
|
|
107
|
-
// pnpm forwards the literal `--` separator from `pnpm fill:live -- …`;
|
|
108
|
-
// every documented invocation uses it, so skip the bare token.
|
|
109
|
-
if (key === '--')
|
|
110
|
-
continue;
|
|
111
|
-
if (!key.startsWith('--'))
|
|
112
|
-
usage();
|
|
113
|
-
const value = process.argv[++i];
|
|
114
|
-
if (!value || value.startsWith('--'))
|
|
115
|
-
usage();
|
|
116
|
-
parsed.set(key, value);
|
|
117
|
-
}
|
|
118
|
-
return parsed;
|
|
119
|
-
}
|
|
120
|
-
// Repo-root .env as DEFAULTS, before any env read below: stable per-machine
|
|
121
|
-
// config (VGS creds, CHECKOUT_AGENT_MODE, the scripted-run submit arm) lives
|
|
122
|
-
// there once; explicitly-set environment always wins, and a missing file
|
|
123
|
-
// changes nothing.
|
|
124
|
-
loadRepoEnvDefaults();
|
|
125
|
-
const input = args();
|
|
126
|
-
const checkoutFile = input.get('--checkout-file');
|
|
127
|
-
// Inline any-merchant mode: --merchant-url + --amount build the target
|
|
128
|
-
// directly, so no checkout JSON has to exist. Throws its own scoped errors;
|
|
129
|
-
// the shared validation block below still governs the resulting shape.
|
|
130
|
-
const inlineTarget = inlineTargetFromFlags(input);
|
|
131
|
-
const contactFile = input.get('--contact-file') ?? join(homedir(), '.visa-mcp', 'contact.json');
|
|
132
|
-
const agentCredentialFile = input.get('--agent-credential-file') ?? join(homedir(), '.visa-mcp', 'agent-credential.json');
|
|
133
|
-
// Optional in assurance (checkout-file) mode: purchase authorization is a
|
|
134
|
-
// FRESH ceremony scoped to this purchase, never the enrollment artifact
|
|
135
|
-
// (#5709). Without the file, the runner performs the ceremony itself after
|
|
136
|
-
// the typed approval (interactive ceremony mode).
|
|
137
|
-
const purchaseAssuranceFile = input.get('--purchase-assurance-file');
|
|
138
|
-
// Exactly one target source: a checkout file or the inline flags.
|
|
139
|
-
const targetSources = [typeof checkoutFile === 'string', inlineTarget !== null].filter(Boolean).length;
|
|
140
|
-
if (targetSources !== 1 || typeof agentCredentialFile !== 'string') {
|
|
141
|
-
usage();
|
|
142
|
-
}
|
|
143
|
-
const receiptDir = input.get('--receipt-dir') ?? join(homedir(), '.visa-mcp', 'checkout-receipts');
|
|
144
|
-
const mode = parseCheckoutMode(input.get('--mode'));
|
|
145
|
-
// Opt-in human-in-the-loop for issuer challenges: CHECKOUT_AGENT_CHALLENGE_HOLD=<minutes>
|
|
146
|
-
// keeps a detected 3DS challenge on screen so the operator can complete it
|
|
147
|
-
// (e.g. type an SMS one-time code) instead of ending the run.
|
|
148
|
-
const challengeHoldMinutes = Number(process.env.CHECKOUT_AGENT_CHALLENGE_HOLD ?? '');
|
|
149
|
-
const challengeHoldMs = Number.isFinite(challengeHoldMinutes) && challengeHoldMinutes > 0
|
|
150
|
-
? challengeHoldMinutes * 60_000
|
|
151
|
-
: undefined;
|
|
152
|
-
// Refuse an unarmed submit before anything else runs — no file is read, no
|
|
153
|
-
// browser launches, no credential can possibly be minted.
|
|
154
|
-
assertSubmitAllowed(mode);
|
|
155
|
-
const target = inlineTarget ??
|
|
156
|
-
(await readOwnerOnlyJson(checkoutFile, 'checkout file'));
|
|
157
|
-
const contact = await readOwnerOnlyJson(contactFile, 'contact file');
|
|
158
|
-
// Route merchant verification mail to the AGENT's provisioned inbox, not the
|
|
159
|
-
// human's address: the merchant emails OTP/verification codes to whatever
|
|
160
|
-
// contact.email it is given, and wallet_mail_await_otp can only read the
|
|
161
|
-
// agent inbox. mailbox.json is non-sensitive provisioning state (address +
|
|
162
|
-
// opaque inbox id — the scoped bearer credential is stored separately and never read here),
|
|
163
|
-
// so the runner reads it directly. Only override when a mailbox exists;
|
|
164
|
-
// otherwise keep the contact-file email.
|
|
165
|
-
const agentInboxEmail = loadAgentInboxEmail();
|
|
166
|
-
if (agentInboxEmail) {
|
|
167
|
-
contact.email = agentInboxEmail;
|
|
168
|
-
process.stdout.write(`Using agent inbox for merchant verification mail: ${agentInboxEmail}\n`);
|
|
169
|
-
}
|
|
170
|
-
// Optional out-of-process email-OTP resolver (see buildOtpResolver). Undefined
|
|
171
|
-
// unless VISA_V4_OTP_RESOLVER_CMD is set — then merchant email codes are
|
|
172
|
-
// auto-resolved single-use; otherwise email OTP falls to a human.
|
|
173
|
-
const otpResolver = buildOtpResolver();
|
|
174
|
-
if (typeof target.merchantName !== 'string' || !target.merchantName.trim()) {
|
|
175
|
-
throw new Error('checkout target requires merchantName');
|
|
176
|
-
}
|
|
177
|
-
if (typeof target.merchantCountryCode !== 'string' ||
|
|
178
|
-
!/^[A-Z]{2}$/.test(target.merchantCountryCode.toUpperCase())) {
|
|
179
|
-
throw new Error('checkout target merchantCountryCode must be a 2-letter ISO code');
|
|
180
|
-
}
|
|
181
|
-
// Normalize once so the instrument's strict uppercase re-check (which runs
|
|
182
|
-
// after browser launch + operator confirmation) can't fail on a lowercase code
|
|
183
|
-
// this loader already accepted.
|
|
184
|
-
target.merchantCountryCode = target.merchantCountryCode.toUpperCase();
|
|
185
|
-
if (!contact.fullName && !(contact.firstName && contact.lastName)) {
|
|
186
|
-
throw new Error('contact file needs fullName or firstName + lastName');
|
|
187
|
-
}
|
|
188
|
-
// || not ??: an empty-string fullName must fall through to first+last.
|
|
189
|
-
const cardholderName = contact.fullName || `${contact.firstName} ${contact.lastName}`;
|
|
190
|
-
if (typeof target.merchantUrl !== 'string') {
|
|
191
|
-
throw new Error('checkout target requires merchantUrl');
|
|
192
|
-
}
|
|
193
|
-
const url = new URL(target.merchantUrl);
|
|
194
|
-
if (url.protocol !== 'https:' && url.hostname !== '127.0.0.1' && url.hostname !== 'localhost') {
|
|
195
|
-
throw new Error('checkout URL must use HTTPS (localhost is allowed for fixtures)');
|
|
196
|
-
}
|
|
197
|
-
if (typeof target.transactionAmount !== 'string') {
|
|
198
|
-
throw new Error('checkout target requires transactionAmount');
|
|
199
|
-
}
|
|
200
|
-
const parsedAmountMinor = decimalToMinor(target.transactionAmount);
|
|
201
|
-
if (parsedAmountMinor === null || parsedAmountMinor <= 0) {
|
|
202
|
-
throw new Error('checkout target transactionAmount must be a positive decimal');
|
|
203
|
-
}
|
|
204
|
-
// Re-bound after the guard so the narrowed `number` type reaches the hoisted
|
|
205
|
-
// persistReceipt declaration below.
|
|
206
|
-
const amountMinor = parsedAmountMinor;
|
|
207
|
-
if (typeof target.transactionCurrencyCode !== 'string') {
|
|
208
|
-
throw new Error('checkout target transactionCurrencyCode must be a 3-letter ISO code');
|
|
209
|
-
}
|
|
210
|
-
const currency = target.transactionCurrencyCode.toUpperCase();
|
|
211
|
-
if (!/^[A-Z]{3}$/.test(currency)) {
|
|
212
|
-
throw new Error('checkout target transactionCurrencyCode must be a 3-letter ISO code');
|
|
213
|
-
}
|
|
214
|
-
// Normalize once on the target (same pattern as merchantCountryCode above) so
|
|
215
|
-
// every downstream consumer — the hosted-approval registration, the assurance
|
|
216
|
-
// scope, the instrument's re-check — sees the exact value this loader
|
|
217
|
-
// validated; a lowercase code in the file must not 400 at the hosted relay.
|
|
218
|
-
target.transactionCurrencyCode = currency;
|
|
219
|
-
// Preflight the agent-credential + purchase-assurance artifacts (assurance
|
|
220
|
-
// mode) BEFORE the browser launches: lstat-only — existence, regular file,
|
|
221
|
-
// 0600, size cap. A missing or mis-permissioned artifact fails fast here
|
|
222
|
-
// instead of after the operator has completed the manual checkout review and
|
|
223
|
-
// typed the FILL phrase (review nit). Contents stay unread until after
|
|
224
|
-
// approval — the guard reads nothing.
|
|
225
|
-
await assertOwnerOnlyFile(agentCredentialFile, 'CLI agent credential', 40 * 1024);
|
|
226
|
-
if (typeof purchaseAssuranceFile === 'string') {
|
|
227
|
-
await assertOwnerOnlyFile(purchaseAssuranceFile, 'purchase assurance', 40 * 1024);
|
|
228
|
-
}
|
|
229
|
-
// Interactive ceremony mode (no --purchase-assurance-file): the runner performs
|
|
230
|
-
// the device-binding ceremony itself after the typed approval. With an
|
|
231
|
-
// approval base URL the ceremony is HOSTED on the deployed verify site (no
|
|
232
|
-
// loopback page, no local TLS/SDK/VGS-credential prerequisites); otherwise it
|
|
233
|
-
// runs on the loopback page. Fail fast on the active mode's prerequisites
|
|
234
|
-
// here, before any browser launches.
|
|
235
|
-
const ceremonyMode = typeof purchaseAssuranceFile !== 'string';
|
|
236
|
-
// Hosted approval on the deployed verify site is the DEFAULT; set
|
|
237
|
-
// CHECKOUT_APPROVAL_BASE_URL='' (empty) to force the loopback ceremony.
|
|
238
|
-
const approvalBaseUrl = resolveApprovalBaseUrl(input.get('--approval-base-url'));
|
|
239
|
-
const hostedApproval = ceremonyMode && approvalBaseUrl !== '';
|
|
240
|
-
// HTTPS-except-loopback — the same policy the checkout URL gets above; the
|
|
241
|
-
// approval claim carries the verifier credential and never travels cleartext.
|
|
242
|
-
if (hostedApproval)
|
|
243
|
-
assertApprovalBaseUrl(approvalBaseUrl);
|
|
244
|
-
let ceremonyTls = null;
|
|
245
|
-
let ceremonyVendorSdkJs = '';
|
|
246
|
-
let ceremonyConsumerEmail = '';
|
|
247
|
-
if (ceremonyMode) {
|
|
248
|
-
// The hosted page collects the enrollment email itself when the contact file
|
|
249
|
-
// carries none; the loopback page cannot, so local mode still requires it.
|
|
250
|
-
if (!hostedApproval && (typeof contact.email !== 'string' || !contact.email.trim())) {
|
|
251
|
-
throw new Error('interactive ceremony needs an email in the contact file');
|
|
252
|
-
}
|
|
253
|
-
ceremonyConsumerEmail = typeof contact.email === 'string' ? contact.email : '';
|
|
254
|
-
if (!currencyNumeric(currency)) {
|
|
255
|
-
throw new Error(`interactive ceremony supports ${Object.keys(CURRENCY_NUMERIC).join('/')} — ` +
|
|
256
|
-
`pass --purchase-assurance-file for ${currency}`);
|
|
257
|
-
}
|
|
258
|
-
// Loopback mode only: the Visa device-binding iframe requires an HTTPS
|
|
259
|
-
// parent page (dev-harness certs) and the vendored SDK. The hosted page
|
|
260
|
-
// brings its own origin and SDK — no local prerequisites.
|
|
261
|
-
if (!hostedApproval) {
|
|
262
|
-
// GA graduation: the harness moved into this package (was
|
|
263
|
-
// apps/v4-verify-web/dev-harness); the vendored SDK ships with apps/web.
|
|
264
|
-
const harnessDir = fileURLToPath(new URL('../dev-harness/', import.meta.url));
|
|
265
|
-
try {
|
|
266
|
-
ceremonyTls = {
|
|
267
|
-
key: readFileSync(join(harnessDir, 'certs', 'key.pem')),
|
|
268
|
-
cert: readFileSync(join(harnessDir, 'certs', 'cert.pem')),
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
catch {
|
|
272
|
-
throw new Error('interactive ceremony needs the dev-harness HTTPS certs — run ' +
|
|
273
|
-
'packages/checkout-engine/dev-harness/generate-certs.sh once');
|
|
274
|
-
}
|
|
275
|
-
const vendorSdkPath = fileURLToPath(new URL('../../../apps/web/public/vendor/vgs-agentic-auth.js', import.meta.url));
|
|
276
|
-
try {
|
|
277
|
-
ceremonyVendorSdkJs = readFileSync(vendorSdkPath, 'utf8');
|
|
278
|
-
}
|
|
279
|
-
catch {
|
|
280
|
-
throw new Error(`missing vendored VgsAgenticAuth module at ${vendorSdkPath}`);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
// Client-credentials mint for the ceremony page's short-lived browser token —
|
|
285
|
-
// same wire call as the dev-harness /api/token, served on loopback only.
|
|
286
|
-
async function mintCeremonyAccessToken() {
|
|
287
|
-
const clientId = process.env.VGS_USERNAME;
|
|
288
|
-
const clientSecret = process.env.VGS_PASSWORD;
|
|
289
|
-
if (!clientId || !clientSecret) {
|
|
290
|
-
throw new Error('Set VGS_USERNAME and VGS_PASSWORD (the VGS service-account id/secret).');
|
|
291
|
-
}
|
|
292
|
-
const body = new URLSearchParams({
|
|
293
|
-
client_id: clientId,
|
|
294
|
-
client_secret: clientSecret,
|
|
295
|
-
grant_type: 'client_credentials',
|
|
296
|
-
});
|
|
297
|
-
const r = await fetch('https://auth.verygoodsecurity.com/auth/realms/vgs/protocol/openid-connect/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body });
|
|
298
|
-
if (!r.ok)
|
|
299
|
-
throw new Error(`VGS token grant failed (${r.status})`);
|
|
300
|
-
const doc = (await r.json());
|
|
301
|
-
if (!doc.access_token)
|
|
302
|
-
throw new Error('VGS token grant returned no access_token');
|
|
303
|
-
return doc.access_token;
|
|
304
|
-
}
|
|
305
|
-
// Persist the redacted receipt for this attempt (LIVE_VIC_GUEST_CHECKOUT.md
|
|
306
|
-
// step 8) and return the path for the printed result. Persisting never throws:
|
|
307
|
-
// a failed write degrades to a stderr warning — the run result (and any
|
|
308
|
-
// completed charge) must not be lost to a full disk or a permission error.
|
|
309
|
-
async function persistReceipt(reviewId, result, vicConfirmation) {
|
|
310
|
-
const report = await writeReceipt(receiptDir, buildReceipt({
|
|
311
|
-
mode,
|
|
312
|
-
reviewId,
|
|
313
|
-
merchant: { name: target.merchantName, host: url.hostname },
|
|
314
|
-
transaction: { amount: target.transactionAmount, amountMinor, currency },
|
|
315
|
-
result,
|
|
316
|
-
vicConfirmation,
|
|
317
|
-
}));
|
|
318
|
-
if (!report.written) {
|
|
319
|
-
process.stderr.write(`WARNING: receipt not written — ${report.reason}\n`);
|
|
320
|
-
return null;
|
|
321
|
-
}
|
|
322
|
-
if (report.panRedactions > 0) {
|
|
323
|
-
process.stderr.write(`WARNING: ${report.panRedactions} PAN-like digit run(s) were redacted from the receipt — ` +
|
|
324
|
-
'upstream evidence must never contain one; investigate before the next run.\n');
|
|
325
|
-
}
|
|
326
|
-
return report.path;
|
|
327
|
-
}
|
|
328
|
-
const browser = await launchCheckoutBrowser();
|
|
329
|
-
try {
|
|
330
|
-
const mandate = {
|
|
331
|
-
maxAmountMinor: amountMinor,
|
|
332
|
-
currency,
|
|
333
|
-
merchantHost: url.hostname,
|
|
334
|
-
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
|
335
|
-
};
|
|
336
|
-
const debugShotsDir = join(receiptDir, 'shots');
|
|
337
|
-
process.stdout.write(`debug screenshots (credential fields masked): ${debugShotsDir}\n`);
|
|
338
|
-
const preparation = await prepareCheckout({
|
|
339
|
-
url: target.merchantUrl,
|
|
340
|
-
mandate,
|
|
341
|
-
amountMinor,
|
|
342
|
-
currency,
|
|
343
|
-
browser,
|
|
344
|
-
contact,
|
|
345
|
-
debugShotsDir,
|
|
346
|
-
});
|
|
347
|
-
if (preparation.status === 'finished') {
|
|
348
|
-
// Refused before a review existed (blocked-by-mandate, adapter-required,
|
|
349
|
-
// …). Still a recorded attempt: the merchant-test matrix reconciles
|
|
350
|
-
// compatibility findings, not just submissions.
|
|
351
|
-
const receipt = await persistReceipt(null, preparation.result, null);
|
|
352
|
-
process.stdout.write(`${JSON.stringify({ ...preparation.result, receipt }, null, 2)}\n`);
|
|
353
|
-
process.exitCode = 1;
|
|
354
|
-
}
|
|
355
|
-
else {
|
|
356
|
-
const review = preparation.checkout.review;
|
|
357
|
-
// The full review contract (LIVE_VIC_GUEST_CHECKOUT.md step 1): merchant,
|
|
358
|
-
// exact total, currency, mandate cap/expiry, and the fingerprint of the
|
|
359
|
-
// exact control a submit-mode approval would click. Contains no credential.
|
|
360
|
-
process.stdout.write(`${JSON.stringify({ reviewId: review.id, merchantHost: review.merchantHost, amountMinor: review.amountMinor, currency: review.currency, mandateMaxAmountMinor: review.mandateMaxAmountMinor, mandateExpiresAt: review.mandateExpiresAt, submitTarget: review.submitTarget, submitTargetFingerprint: review.submitTargetFingerprint, detectedRoles: review.detectedRoles }, null, 2)}\n`);
|
|
361
|
-
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
362
|
-
const expected = approvalPhrase(mode, review.id);
|
|
363
|
-
const answer = await terminal.question(approvalQuestion(mode, review, expected));
|
|
364
|
-
terminal.close();
|
|
365
|
-
let result;
|
|
366
|
-
let vicConfirmation = null;
|
|
367
|
-
if (answer === expected) {
|
|
368
|
-
// Assurance-mode inputs. In interactive ceremony mode the assurance is
|
|
369
|
-
// produced NOW — after the typed approval, seconds before the mint (the
|
|
370
|
-
// freshest possible configuration; see the 2026-07-17 PENDING-intent
|
|
371
|
-
// contrast) — and exists in process memory only.
|
|
372
|
-
let ceremonyFailure = null;
|
|
373
|
-
let credential = null;
|
|
374
|
-
let purchase = null;
|
|
375
|
-
credential = await readOwnerOnlyJson(agentCredentialFile, 'CLI agent credential', 40 * 1024);
|
|
376
|
-
if (typeof purchaseAssuranceFile === 'string') {
|
|
377
|
-
// Fresh, purchase-scoped assurance — the instrument validates its
|
|
378
|
-
// declared merchant/amount/currency scope + freshness against the
|
|
379
|
-
// checkout target before any intent is minted (#5709).
|
|
380
|
-
purchase = await readOwnerOnlyJson(purchaseAssuranceFile, 'purchase assurance', 40 * 1024);
|
|
381
|
-
}
|
|
382
|
-
else if (typeof credential.tokenId !== 'string' || !credential.tokenId.trim()) {
|
|
383
|
-
ceremonyFailure = 'CLI agent credential requires tokenId';
|
|
384
|
-
}
|
|
385
|
-
else {
|
|
386
|
-
process.stdout.write('approval received — complete the passkey on the Visa approval page…\n');
|
|
387
|
-
try {
|
|
388
|
-
purchase = hostedApproval
|
|
389
|
-
? await runHostedApproval({
|
|
390
|
-
baseUrl: approvalBaseUrl,
|
|
391
|
-
tokenId: credential.tokenId,
|
|
392
|
-
target,
|
|
393
|
-
...(ceremonyConsumerEmail ? { consumerEmail: ceremonyConsumerEmail } : {}),
|
|
394
|
-
log: (line) => process.stdout.write(`${line}\n`),
|
|
395
|
-
})
|
|
396
|
-
: await runInteractiveCeremony({
|
|
397
|
-
page: {
|
|
398
|
-
tokenId: credential.tokenId,
|
|
399
|
-
environment: process.env.VGS_ENVIRONMENT === 'sandbox' ? 'sandbox' : 'live',
|
|
400
|
-
consumerEmail: ceremonyConsumerEmail,
|
|
401
|
-
merchantName: target.merchantName,
|
|
402
|
-
amount: target.transactionAmount,
|
|
403
|
-
currency,
|
|
404
|
-
currencyNumericCode: currencyNumeric(currency),
|
|
405
|
-
},
|
|
406
|
-
target,
|
|
407
|
-
vendorSdkJs: ceremonyVendorSdkJs,
|
|
408
|
-
tls: ceremonyTls,
|
|
409
|
-
mintAccessToken: mintCeremonyAccessToken,
|
|
410
|
-
log: (line) => process.stdout.write(`${line}\n`),
|
|
411
|
-
});
|
|
412
|
-
process.stdout.write('passkey approved — minting the credential…\n');
|
|
413
|
-
}
|
|
414
|
-
catch (err) {
|
|
415
|
-
ceremonyFailure = err.message;
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
if (ceremonyFailure !== null) {
|
|
419
|
-
result = await cancelPreparedCheckout(review.id, `device-binding ceremony failed: ${ceremonyFailure}`);
|
|
420
|
-
}
|
|
421
|
-
else {
|
|
422
|
-
const instrument = new VgsAssuranceInstrument(credential, purchase, target, cardholderName,
|
|
423
|
-
// The minter surfaces the fresh intentId (unlike getCredential,
|
|
424
|
-
// which discards it) so the VIC confirmation below can target it.
|
|
425
|
-
async (input) => {
|
|
426
|
-
const { intentId, status } = await createIntent(input);
|
|
427
|
-
try {
|
|
428
|
-
const payment = await fetchCryptogram({
|
|
429
|
-
tokenId: input.tokenId,
|
|
430
|
-
intentId,
|
|
431
|
-
transaction: input.transaction,
|
|
432
|
-
});
|
|
433
|
-
return { payment, intentId };
|
|
434
|
-
}
|
|
435
|
-
catch (err) {
|
|
436
|
-
// HTTP 201 at intent creation ≠ an authorized intent — carry
|
|
437
|
-
// the creation-time status so a not-completed cryptogram is
|
|
438
|
-
// diagnosable in place (#5709).
|
|
439
|
-
throw new Error(`${err.message} (intent status at creation: ${status ?? 'unknown'})`);
|
|
440
|
-
}
|
|
441
|
-
});
|
|
442
|
-
result = await submitApprovedCheckout(review.id, {
|
|
443
|
-
approval: { approved: true, reviewId: review.id },
|
|
444
|
-
instrument,
|
|
445
|
-
contact,
|
|
446
|
-
mode,
|
|
447
|
-
challengeHoldMs,
|
|
448
|
-
...(otpResolver ? { resolveEmailOtp: otpResolver } : {}),
|
|
449
|
-
onChallengeHold: (signal) => process.stdout.write(signal === 'body:link-wallet'
|
|
450
|
-
? `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`
|
|
451
|
-
: `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`),
|
|
452
|
-
});
|
|
453
|
-
if (mode === 'submit') {
|
|
454
|
-
// Report the OBSERVED outcome to VIC for the consumed intent. Only
|
|
455
|
-
// definitive answers post (confirmed → APPROVED, declined → DECLINED);
|
|
456
|
-
// unknown outcomes are resolved with the merchant first, never
|
|
457
|
-
// guessed. A ceremony failure never reaches here — no intent was
|
|
458
|
-
// consumed, so there is nothing to confirm.
|
|
459
|
-
vicConfirmation = await reportVicOutcome({
|
|
460
|
-
target: instrument.confirmationTarget(),
|
|
461
|
-
outcome: result.outcome,
|
|
462
|
-
transaction: {
|
|
463
|
-
transactionAmount: target.transactionAmount,
|
|
464
|
-
transactionCurrencyCode: currency,
|
|
465
|
-
},
|
|
466
|
-
post: postConfirmation,
|
|
467
|
-
});
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
else {
|
|
472
|
-
result = await cancelPreparedCheckout(review.id, 'operator declined the checkout review');
|
|
473
|
-
}
|
|
474
|
-
const receipt = await persistReceipt(review.id, result, vicConfirmation);
|
|
475
|
-
process.stdout.write(`${JSON.stringify({ outcome: result.outcome, confirmationRef: result.confirmationRef ?? null, vicConfirmation, receipt, credentialLifecycle: result.credentialLifecycle, credentialTiming: result.credentialTiming, detectedRoles: Object.keys(result.fields), requiresAdapter: result.requiresAdapter, detail: result.detail ?? null }, null, 2)}\n`);
|
|
476
|
-
if (submitClickedWithoutConfirmation(result)) {
|
|
477
|
-
process.stderr.write('WARNING: the pay control was clicked but no confirmation was detected — the charge ' +
|
|
478
|
-
'may still have gone through. Verify with the merchant before any retry; a blind ' +
|
|
479
|
-
'rerun risks a double charge.\n');
|
|
480
|
-
}
|
|
481
|
-
if (result.outcome === 'action-required') {
|
|
482
|
-
process.stderr.write('NOTICE: the issuer asked for human verification (3-D Secure) and the run stopped ' +
|
|
483
|
-
'there — no charge exists until the challenge is completed. Complete the purchase ' +
|
|
484
|
-
'manually if still wanted; any rerun needs a new review and a fresh credential.\n');
|
|
485
|
-
}
|
|
486
|
-
if (vicConfirmation && !vicConfirmation.posted && isRunSuccess(mode, result.outcome)) {
|
|
487
|
-
process.stderr.write(`WARNING: purchase confirmed but the VIC confirmation was not posted — ${vicConfirmation.reason}\n`);
|
|
488
|
-
}
|
|
489
|
-
if (!isRunSuccess(mode, result.outcome))
|
|
490
|
-
process.exitCode = 1;
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
finally {
|
|
494
|
-
await browser.close();
|
|
495
|
-
}
|