@visa/cli 4.1.0-rc.8 → 4.1.0-rc.81
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 +178 -231
- package/dist/checkout-engine/adapters/generic.d.ts +23 -0
- package/dist/checkout-engine/adapters/generic.js +216 -0
- package/dist/checkout-engine/adapters/index.d.ts +8 -0
- package/dist/checkout-engine/adapters/index.js +21 -0
- package/dist/checkout-engine/adapters/shopify.d.ts +31 -0
- package/dist/checkout-engine/adapters/shopify.js +423 -0
- package/dist/checkout-engine/adapters/stripe-like.d.ts +10 -0
- package/dist/checkout-engine/adapters/stripe-like.js +21 -0
- package/dist/checkout-engine/amount.d.ts +15 -0
- package/dist/checkout-engine/amount.js +72 -0
- package/dist/checkout-engine/browser-launch.d.ts +46 -0
- package/dist/checkout-engine/browser-launch.js +81 -0
- package/dist/checkout-engine/ceremony.d.ts +64 -0
- package/dist/checkout-engine/ceremony.js +261 -0
- package/dist/checkout-engine/cli-engine.d.ts +214 -0
- package/dist/checkout-engine/cli-engine.js +701 -0
- package/dist/checkout-engine/detect.d.ts +61 -0
- package/dist/checkout-engine/detect.js +398 -0
- package/dist/checkout-engine/evidence.d.ts +25 -0
- package/dist/checkout-engine/evidence.js +104 -0
- package/dist/checkout-engine/executor.d.ts +176 -0
- package/dist/checkout-engine/executor.js +1322 -0
- package/dist/checkout-engine/hosted-approval.d.ts +142 -0
- package/dist/checkout-engine/hosted-approval.js +339 -0
- package/dist/checkout-engine/index.d.ts +6 -0
- package/dist/checkout-engine/index.js +8 -0
- package/dist/checkout-engine/inline-target.d.ts +13 -0
- package/dist/checkout-engine/inline-target.js +37 -0
- package/dist/checkout-engine/instrument.d.ts +61 -0
- package/dist/checkout-engine/instrument.js +87 -0
- package/dist/checkout-engine/live-fill-approval.d.ts +43 -0
- package/dist/checkout-engine/live-fill-approval.js +90 -0
- package/dist/checkout-engine/mandate/card-mandate.d.ts +121 -0
- package/dist/checkout-engine/mandate/card-mandate.js +227 -0
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +142 -0
- package/dist/checkout-engine/mandate/mandate-ledger.js +338 -0
- package/dist/checkout-engine/mandate.d.ts +25 -0
- package/dist/checkout-engine/mandate.js +100 -0
- package/dist/checkout-engine/outcome.d.ts +30 -0
- package/dist/checkout-engine/outcome.js +225 -0
- package/dist/checkout-engine/owner-only-file.d.ts +19 -0
- package/dist/checkout-engine/owner-only-file.js +41 -0
- package/dist/checkout-engine/package.json +3 -0
- package/dist/checkout-engine/receipt.d.ts +81 -0
- package/dist/checkout-engine/receipt.js +109 -0
- package/dist/checkout-engine/repo-env.d.ts +11 -0
- package/dist/checkout-engine/repo-env.js +23 -0
- package/dist/checkout-engine/trace-handles.d.ts +8 -0
- package/dist/checkout-engine/trace-handles.js +12 -0
- package/dist/checkout-engine/types.d.ts +44 -0
- package/dist/checkout-engine/types.js +2 -0
- package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +74 -0
- package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +248 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +82 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +180 -0
- package/dist/checkout-engine/vgs-live-instrument.d.ts +170 -0
- package/dist/checkout-engine/vgs-live-instrument.js +293 -0
- package/dist/checkout-engine/vic-confirmation.d.ts +34 -0
- package/dist/checkout-engine/vic-confirmation.js +39 -0
- package/dist/cli.js +442 -433
- package/dist/mcp-server/index.js +360 -170
- package/dist/skills/pair-visa-agent/RUNTIMES.md +92 -0
- package/dist/skills/pair-visa-agent/SKILL.md +447 -0
- package/dist/skills/pair-visa-agent/scripts/setup.mjs +48 -0
- package/install.ps1 +3 -41
- package/install.sh +3 -35
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +16 -12
- package/server.json +3 -3
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { PurchaseAssurance, VgsCheckoutTarget } from './vgs-live-instrument.js';
|
|
2
|
+
/** ISO-4217 numeric codes for the consent screen — mirrors the dev-harness map. */
|
|
3
|
+
export declare const CURRENCY_NUMERIC: Record<string, string>;
|
|
4
|
+
export declare function currencyNumeric(code: string): string | null;
|
|
5
|
+
/**
|
|
6
|
+
* Build the runner-side PurchaseAssurance from a completed ceremony. Scope
|
|
7
|
+
* fields come verbatim from the checkout target, so the instrument's
|
|
8
|
+
* scope-alignment validation (#5709) is satisfied by construction; `now` is
|
|
9
|
+
* injectable for tests.
|
|
10
|
+
*/
|
|
11
|
+
export declare function assuranceFromCeremony(target: VgsCheckoutTarget, assuranceData: unknown, now?: Date): PurchaseAssurance;
|
|
12
|
+
export type CeremonyPageConfig = {
|
|
13
|
+
tokenId: string;
|
|
14
|
+
environment: 'live' | 'sandbox';
|
|
15
|
+
consumerEmail: string;
|
|
16
|
+
merchantName: string;
|
|
17
|
+
/** Decimal major-unit amount, e.g. "1.00" — drives the consent screen. */
|
|
18
|
+
amount: string;
|
|
19
|
+
/** Display currency, e.g. "USD". */
|
|
20
|
+
currency: string;
|
|
21
|
+
/** ISO-4217 numeric code for the SDK, e.g. "840". */
|
|
22
|
+
currencyNumericCode: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The single-purpose approval page. Pure string builder so tests can assert
|
|
26
|
+
* the config embed is intact and escape-safe. The config is embedded as JSON
|
|
27
|
+
* with `<` escaped, so a hostile merchant name cannot break out of the script
|
|
28
|
+
* element.
|
|
29
|
+
*/
|
|
30
|
+
export declare function ceremonyPageHtml(cfg: CeremonyPageConfig): string;
|
|
31
|
+
export type RunCeremonyOptions = {
|
|
32
|
+
page: CeremonyPageConfig;
|
|
33
|
+
target: VgsCheckoutTarget;
|
|
34
|
+
/** Contents of the vendored VgsAgenticAuth module, served to the page. */
|
|
35
|
+
vendorSdkJs: string;
|
|
36
|
+
/** Short-lived VGS bearer for the ceremony SDK (client-credentials mint). */
|
|
37
|
+
mintAccessToken: () => Promise<string>;
|
|
38
|
+
/**
|
|
39
|
+
* TLS material for the loopback server. The Visa device-binding iframe
|
|
40
|
+
* requires an HTTPS parent page, so real callers must pass this; `null`
|
|
41
|
+
* serves plain HTTP and exists ONLY so tests can exercise the server
|
|
42
|
+
* without certificates.
|
|
43
|
+
*/
|
|
44
|
+
tls: {
|
|
45
|
+
key: string | Buffer;
|
|
46
|
+
cert: string | Buffer;
|
|
47
|
+
} | null;
|
|
48
|
+
/** Default 4400 — the origin the operator's browser already trusts. */
|
|
49
|
+
port?: number;
|
|
50
|
+
/** Overall wait for the human to complete the ceremony. Default 4 minutes. */
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
/** Open the approval page in the operator's browser. Default: macOS `open`. */
|
|
53
|
+
openUrl?: (url: string) => void;
|
|
54
|
+
/** Status output. Default: stderr. */
|
|
55
|
+
log?: (line: string) => void;
|
|
56
|
+
};
|
|
57
|
+
export declare const CEREMONY_DEFAULT_PORT = 4400;
|
|
58
|
+
export declare const CEREMONY_DEFAULT_TIMEOUT_MS: number;
|
|
59
|
+
/**
|
|
60
|
+
* Serve the approval page on loopback, open it in the operator's browser, and
|
|
61
|
+
* resolve with the fresh PurchaseAssurance once the passkey completes. Rejects
|
|
62
|
+
* on timeout, port conflict, or server failure; the server is always closed.
|
|
63
|
+
*/
|
|
64
|
+
export declare function runInteractiveCeremony(opts: RunCeremonyOptions): Promise<PurchaseAssurance>;
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import { exec } from 'node:child_process';
|
|
4
|
+
// In-run device-binding ceremony — replaces the manual dev-harness tab and the
|
|
5
|
+
// exported assurance file. After the operator types the approval phrase, the
|
|
6
|
+
// runner serves a single-purpose loopback page, opens it in the operator's
|
|
7
|
+
// normal browser (where their passkey, popup allowance, and cert exception
|
|
8
|
+
// already live), and receives the ceremony's assuranceData back in-process.
|
|
9
|
+
// The assurance is therefore seconds old at intent mint (the only
|
|
10
|
+
// proven-reliable configuration — see the 2026-07-17 PENDING-intent runs) and
|
|
11
|
+
// never touches disk.
|
|
12
|
+
//
|
|
13
|
+
// Security posture (same as the dev-harness, contained tighter):
|
|
14
|
+
// - The server binds 127.0.0.1 ONLY. /api/token hands the page a short-lived
|
|
15
|
+
// VGS bearer, so the server must never be reachable from another machine.
|
|
16
|
+
// - assuranceData exists in process memory only; nothing is persisted.
|
|
17
|
+
// - The page carries no card data and no service-account secret.
|
|
18
|
+
/** ISO-4217 numeric codes for the consent screen — mirrors the dev-harness map. */
|
|
19
|
+
export const CURRENCY_NUMERIC = {
|
|
20
|
+
USD: '840',
|
|
21
|
+
CAD: '124',
|
|
22
|
+
EUR: '978',
|
|
23
|
+
GBP: '826',
|
|
24
|
+
AUD: '036',
|
|
25
|
+
};
|
|
26
|
+
export function currencyNumeric(code) {
|
|
27
|
+
return CURRENCY_NUMERIC[code.toUpperCase()] ?? null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build the runner-side PurchaseAssurance from a completed ceremony. Scope
|
|
31
|
+
* fields come verbatim from the checkout target, so the instrument's
|
|
32
|
+
* scope-alignment validation (#5709) is satisfied by construction; `now` is
|
|
33
|
+
* injectable for tests.
|
|
34
|
+
*/
|
|
35
|
+
export function assuranceFromCeremony(target, assuranceData, now = new Date()) {
|
|
36
|
+
return {
|
|
37
|
+
assuranceData,
|
|
38
|
+
mintedAt: now.toISOString(),
|
|
39
|
+
merchantHost: new URL(target.merchantUrl).hostname,
|
|
40
|
+
transactionAmount: target.transactionAmount,
|
|
41
|
+
transactionCurrencyCode: target.transactionCurrencyCode,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The single-purpose approval page. Pure string builder so tests can assert
|
|
46
|
+
* the config embed is intact and escape-safe. The config is embedded as JSON
|
|
47
|
+
* with `<` escaped, so a hostile merchant name cannot break out of the script
|
|
48
|
+
* element.
|
|
49
|
+
*/
|
|
50
|
+
export function ceremonyPageHtml(cfg) {
|
|
51
|
+
const esc = (s) => s.replace(/[&<>"']/g, (ch) => {
|
|
52
|
+
const map = {
|
|
53
|
+
'&': '&',
|
|
54
|
+
'<': '<',
|
|
55
|
+
'>': '>',
|
|
56
|
+
'"': '"',
|
|
57
|
+
"'": ''',
|
|
58
|
+
};
|
|
59
|
+
return map[ch];
|
|
60
|
+
});
|
|
61
|
+
const json = JSON.stringify(cfg).replace(/</g, '\\u003c');
|
|
62
|
+
return `<!doctype html>
|
|
63
|
+
<html>
|
|
64
|
+
<head>
|
|
65
|
+
<meta charset="utf-8" />
|
|
66
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
67
|
+
<title>Approve purchase — Visa</title>
|
|
68
|
+
<style>
|
|
69
|
+
body { font: 15px/1.5 Menlo, 'SF Mono', monospace; max-width: 640px; margin: 40px auto; padding: 0 16px; color: #1a1a1a; background: #fff; }
|
|
70
|
+
h1 { font-size: 16px; }
|
|
71
|
+
.facts { border: 1px solid #e5e5e5; border-radius: 12px; padding: 14px; margin: 14px 0; background: #fafafa; }
|
|
72
|
+
.facts b { font-size: 18px; }
|
|
73
|
+
button { font: inherit; padding: 10px 16px; margin: 6px 6px 6px 0; border: 1px solid #111; background: #111; color: #fff; border-radius: 8px; cursor: pointer; }
|
|
74
|
+
button:disabled { opacity: .4; cursor: default; }
|
|
75
|
+
button.ghost { background: #fff; color: #111; }
|
|
76
|
+
input { font: inherit; padding: 8px; border: 1px solid #d4d4d4; border-radius: 8px; }
|
|
77
|
+
#iframeBox { border: 2px dashed #d4d4d4; border-radius: 12px; min-height: 90px; padding: 8px; margin: 12px 0; }
|
|
78
|
+
#log { color: #666; font-size: 12.5px; white-space: pre-wrap; }
|
|
79
|
+
#otpBox { display: none; margin: 10px 0; }
|
|
80
|
+
.ok { color: #16a34a } .err { color: #be123c }
|
|
81
|
+
</style>
|
|
82
|
+
</head>
|
|
83
|
+
<body>
|
|
84
|
+
<h1>Approve this purchase with your passkey</h1>
|
|
85
|
+
<div class="facts">${esc(cfg.merchantName)}<br /><b>${esc(cfg.currency)} ${esc(cfg.amount)}</b></div>
|
|
86
|
+
<button id="go">Start approval</button>
|
|
87
|
+
<button id="tap" disabled>Approve with passkey</button>
|
|
88
|
+
<div id="otpBox"><span id="otpMethods"></span> <input id="otpCode" placeholder="code" style="width:120px" /> <button id="otpSubmit" class="ghost">Submit code</button></div>
|
|
89
|
+
<div id="iframeBox"></div>
|
|
90
|
+
<div id="log">The agent is waiting in the terminal. Complete the approval here, then return to it.</div>
|
|
91
|
+
<script type="application/json" id="cfg">${json}</script>
|
|
92
|
+
<script type="module">
|
|
93
|
+
import { VgsAgenticAuth } from './vgs-agentic-auth.js'
|
|
94
|
+
const cfg = JSON.parse(document.getElementById('cfg').textContent)
|
|
95
|
+
const log = (m, c = '') => { const el = document.getElementById('log'); el.className = c; el.textContent = m }
|
|
96
|
+
let session = null
|
|
97
|
+
document.getElementById('go').onclick = async () => {
|
|
98
|
+
const go = document.getElementById('go')
|
|
99
|
+
go.disabled = true
|
|
100
|
+
try {
|
|
101
|
+
log('starting the Visa session…')
|
|
102
|
+
const { access_token, error } = await (await fetch('/api/token')).json()
|
|
103
|
+
if (error) throw new Error(error)
|
|
104
|
+
const flow = new VgsAgenticAuth({
|
|
105
|
+
tokenId: cfg.tokenId, environment: cfg.environment, consumerEmail: cfg.consumerEmail,
|
|
106
|
+
accessToken: access_token, timeout: 180000,
|
|
107
|
+
authenticationAmount: cfg.amount, currencyCode: cfg.currencyNumericCode, merchantName: cfg.merchantName,
|
|
108
|
+
})
|
|
109
|
+
const box = document.getElementById('iframeBox'); box.innerHTML = ''
|
|
110
|
+
session = await flow.startSession(box)
|
|
111
|
+
if (session.needsOtp) {
|
|
112
|
+
const b = document.getElementById('otpBox'), d = document.getElementById('otpMethods')
|
|
113
|
+
b.style.display = 'block'; d.innerHTML = ''
|
|
114
|
+
log('your bank wants a one-time code first — pick a delivery method.')
|
|
115
|
+
for (const m of session.otpMethods || []) {
|
|
116
|
+
const btn = document.createElement('button'); btn.className = 'ghost'
|
|
117
|
+
btn.textContent = 'Send via ' + (m.method || m.identifier)
|
|
118
|
+
btn.onclick = async () => { try { await session.requestOtp(m); log('code sent — type it and submit.') } catch (e) { log('sending the code failed: ' + e.message, 'err') } }
|
|
119
|
+
d.appendChild(btn)
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
log('ready — click "Approve with passkey".', 'ok')
|
|
123
|
+
document.getElementById('tap').disabled = false
|
|
124
|
+
}
|
|
125
|
+
} catch (e) {
|
|
126
|
+
log('could not start: ' + (e && e.message || e) + ' — click "Start approval" to retry.', 'err')
|
|
127
|
+
try { session && session.destroy && session.destroy() } catch {}
|
|
128
|
+
session = null
|
|
129
|
+
go.disabled = false
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
document.getElementById('otpSubmit').onclick = async () => {
|
|
133
|
+
try {
|
|
134
|
+
await session.submitOtp(document.getElementById('otpCode').value.trim())
|
|
135
|
+
document.getElementById('otpBox').style.display = 'none'
|
|
136
|
+
log('code accepted — click "Approve with passkey".', 'ok')
|
|
137
|
+
document.getElementById('tap').disabled = false
|
|
138
|
+
} catch (e) { log('code rejected: ' + (e && e.message || e), 'err') }
|
|
139
|
+
}
|
|
140
|
+
document.getElementById('tap').onclick = async () => {
|
|
141
|
+
const tap = document.getElementById('tap')
|
|
142
|
+
tap.disabled = true
|
|
143
|
+
try {
|
|
144
|
+
log('waiting for your passkey…')
|
|
145
|
+
const assuranceData = await session.authenticate()
|
|
146
|
+
try { session.destroy && session.destroy() } catch {}
|
|
147
|
+
const r = await (await fetch('/api/assurance', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ assuranceData }) })).json()
|
|
148
|
+
if (r.error) throw new Error(r.error)
|
|
149
|
+
document.getElementById('go').disabled = true
|
|
150
|
+
log('approved — return to the terminal. You can close this tab.', 'ok')
|
|
151
|
+
} catch (e) {
|
|
152
|
+
log('passkey failed: ' + (e && e.message || e) + ' — click "Start approval" to retry.', 'err')
|
|
153
|
+
try { session && session.destroy && session.destroy() } catch {}
|
|
154
|
+
session = null
|
|
155
|
+
document.getElementById('go').disabled = false
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
</script>
|
|
159
|
+
</body>
|
|
160
|
+
</html>
|
|
161
|
+
`;
|
|
162
|
+
}
|
|
163
|
+
export const CEREMONY_DEFAULT_PORT = 4400;
|
|
164
|
+
export const CEREMONY_DEFAULT_TIMEOUT_MS = 4 * 60 * 1000;
|
|
165
|
+
/**
|
|
166
|
+
* Serve the approval page on loopback, open it in the operator's browser, and
|
|
167
|
+
* resolve with the fresh PurchaseAssurance once the passkey completes. Rejects
|
|
168
|
+
* on timeout, port conflict, or server failure; the server is always closed.
|
|
169
|
+
*/
|
|
170
|
+
export function runInteractiveCeremony(opts) {
|
|
171
|
+
const port = opts.port ?? CEREMONY_DEFAULT_PORT;
|
|
172
|
+
const timeoutMs = opts.timeoutMs ?? CEREMONY_DEFAULT_TIMEOUT_MS;
|
|
173
|
+
const log = opts.log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
174
|
+
const openUrl = opts.openUrl ??
|
|
175
|
+
((url) => {
|
|
176
|
+
// macOS `open`; on failure the operator still has the printed URL.
|
|
177
|
+
exec(`open ${JSON.stringify(url)}`, () => { });
|
|
178
|
+
});
|
|
179
|
+
const html = ceremonyPageHtml(opts.page);
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
let settled = false;
|
|
182
|
+
const handler = async (req, res) => {
|
|
183
|
+
const send = (code, body, type = 'application/json') => {
|
|
184
|
+
res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
|
|
185
|
+
res.end(body);
|
|
186
|
+
};
|
|
187
|
+
try {
|
|
188
|
+
const path = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
189
|
+
if (path === '/')
|
|
190
|
+
return send(200, html, 'text/html');
|
|
191
|
+
if (path === '/vgs-agentic-auth.js')
|
|
192
|
+
return send(200, opts.vendorSdkJs, 'text/javascript');
|
|
193
|
+
if (path === '/api/token') {
|
|
194
|
+
const access_token = await opts.mintAccessToken();
|
|
195
|
+
return send(200, JSON.stringify({ access_token }));
|
|
196
|
+
}
|
|
197
|
+
if (path === '/api/assurance' && req.method === 'POST') {
|
|
198
|
+
const chunks = [];
|
|
199
|
+
for await (const chunk of req)
|
|
200
|
+
chunks.push(chunk);
|
|
201
|
+
let body = {};
|
|
202
|
+
try {
|
|
203
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return send(400, JSON.stringify({ error: 'invalid JSON' }));
|
|
207
|
+
}
|
|
208
|
+
if (body.assuranceData == null) {
|
|
209
|
+
return send(400, JSON.stringify({ error: 'assuranceData required' }));
|
|
210
|
+
}
|
|
211
|
+
// Flush { ok: true } BEFORE teardown: finish() drops every
|
|
212
|
+
// connection, and destroying the in-flight socket first would show
|
|
213
|
+
// the operator a failed approval while the runner proceeds — the
|
|
214
|
+
// end() callback fires once the response has been handed to the OS.
|
|
215
|
+
const assurance = assuranceFromCeremony(opts.target, body.assuranceData);
|
|
216
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
217
|
+
res.end(JSON.stringify({ ok: true }), () => finish(null, assurance));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
return send(404, JSON.stringify({ error: 'not found' }));
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
return send(500, JSON.stringify({ error: err.message }));
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const server = opts.tls ? https.createServer(opts.tls, handler) : http.createServer(handler);
|
|
227
|
+
const timer = setTimeout(() => {
|
|
228
|
+
finish(new Error(`no passkey approval within ${Math.round(timeoutMs / 1000)}s — ` +
|
|
229
|
+
'the checkout was not touched; rerun when ready'));
|
|
230
|
+
}, timeoutMs);
|
|
231
|
+
timer.unref();
|
|
232
|
+
function finish(err, assurance) {
|
|
233
|
+
if (settled)
|
|
234
|
+
return;
|
|
235
|
+
settled = true;
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
server.closeAllConnections?.();
|
|
238
|
+
server.close(() => {
|
|
239
|
+
if (err)
|
|
240
|
+
reject(err);
|
|
241
|
+
else
|
|
242
|
+
resolve(assurance);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
server.on('error', (err) => {
|
|
246
|
+
finish(err.code === 'EADDRINUSE'
|
|
247
|
+
? new Error(`port ${port} is already in use (is the dev harness still running?) — ` +
|
|
248
|
+
'stop it and rerun')
|
|
249
|
+
: err);
|
|
250
|
+
});
|
|
251
|
+
// Loopback ONLY: /api/token hands the page a live VGS bearer.
|
|
252
|
+
server.listen(port, '127.0.0.1', () => {
|
|
253
|
+
const address = server.address();
|
|
254
|
+
const boundPort = typeof address === 'object' && address ? address.port : port;
|
|
255
|
+
const scheme = opts.tls ? 'https' : 'http';
|
|
256
|
+
const pageUrl = `${scheme}://localhost:${boundPort}`;
|
|
257
|
+
log(`Visa approval page: ${pageUrl} — complete the passkey there.`);
|
|
258
|
+
openUrl(pageUrl);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { type Browser } from 'playwright-core';
|
|
2
|
+
import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, type PreparedCheckoutSessionStore } from './executor.js';
|
|
3
|
+
import { runHostedApproval as realRunHostedApproval } from './hosted-approval.js';
|
|
4
|
+
import { type VgsCheckoutTarget } from './vgs-live-instrument.js';
|
|
5
|
+
import { serverFetchCryptogram, serverPostConfirmation } from './vgs-gateway/server-mint-client.js';
|
|
6
|
+
import { type CardMandateFacts } from './mandate/card-mandate.js';
|
|
7
|
+
import { MandateLedger } from './mandate/mandate-ledger.js';
|
|
8
|
+
import { writeReceipt as realWriteReceipt } from './receipt.js';
|
|
9
|
+
import { reportVicOutcome as realReportVicOutcome, type VicConfirmationReport } from './vic-confirmation.js';
|
|
10
|
+
import type { Contact, OtpResolver } from './types.js';
|
|
11
|
+
/**
|
|
12
|
+
* A card-mandate draw failed transiently (retryable) rather than definitively.
|
|
13
|
+
* Gateway 5xx, "server cryptogram not completed / try again", and network
|
|
14
|
+
* reset/timeout errors are transient: the mandate stays healthy and must NOT be
|
|
15
|
+
* disabled. Walks the error's cause chain so a wrapped MandateDrawDeclinedError
|
|
16
|
+
* is classified by its underlying gateway error. Exported for tests.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isTransientDrawFailure(err: unknown): boolean;
|
|
19
|
+
export type CliReviewInput = {
|
|
20
|
+
url: string;
|
|
21
|
+
amount: string;
|
|
22
|
+
currency: string;
|
|
23
|
+
credentialPath: string;
|
|
24
|
+
/** See {@link CardInstrumentSource}. */
|
|
25
|
+
cardTokenId?: string;
|
|
26
|
+
contact: Contact;
|
|
27
|
+
approvalBaseUrl: string;
|
|
28
|
+
merchantName?: string;
|
|
29
|
+
merchantCountryCode?: string;
|
|
30
|
+
};
|
|
31
|
+
export type CliReviewFacts = {
|
|
32
|
+
reviewId: string;
|
|
33
|
+
merchantHost: string;
|
|
34
|
+
amountMinor: number;
|
|
35
|
+
currency: string;
|
|
36
|
+
submitTargetFingerprint: string;
|
|
37
|
+
detectedRoles: string[];
|
|
38
|
+
};
|
|
39
|
+
export type CliPayInput = CliReviewInput & {
|
|
40
|
+
reviewId: string;
|
|
41
|
+
submit: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Headless handoff for this payment attempt. The MCP layer uses it to return
|
|
44
|
+
* the hosted passkey URL to a messaging surface while the engine continues
|
|
45
|
+
* waiting in-process.
|
|
46
|
+
*/
|
|
47
|
+
onApprovalUrl?: (url: string) => void;
|
|
48
|
+
};
|
|
49
|
+
export type CliReceiptFacts = {
|
|
50
|
+
outcome: string;
|
|
51
|
+
confirmationRef: string | null;
|
|
52
|
+
receiptPath: string | null;
|
|
53
|
+
detail: string | null;
|
|
54
|
+
vicConfirmation: VicConfirmationReport | null;
|
|
55
|
+
/**
|
|
56
|
+
* Which credential path actually ran: `mandate` = tap-free draw against an
|
|
57
|
+
* existing card mandate; `fresh-tap` = today's 1:1 hosted-passkey mint; `null`
|
|
58
|
+
* = neither ran (a pre-flight refusal, e.g. no prepared review). Transparency,
|
|
59
|
+
* never magic — the caller can always see whether a passkey was skipped.
|
|
60
|
+
*/
|
|
61
|
+
source: 'mandate' | 'fresh-tap' | null;
|
|
62
|
+
/** Remaining mandate budget (minor units) after a mandate draw; else null. */
|
|
63
|
+
remainingMinor: number | null;
|
|
64
|
+
};
|
|
65
|
+
export type CliStartMandateInput = {
|
|
66
|
+
/** Optional local card-capability selector (legacy name or exact request-key JKT). */
|
|
67
|
+
agentRef?: string;
|
|
68
|
+
ceiling: string;
|
|
69
|
+
currency: string;
|
|
70
|
+
credentialPath: string;
|
|
71
|
+
/** See {@link CardInstrumentSource}. */
|
|
72
|
+
cardTokenId?: string;
|
|
73
|
+
contact: Contact;
|
|
74
|
+
approvalBaseUrl: string;
|
|
75
|
+
/**
|
|
76
|
+
* Per-purchase cap (decimal string, > 0 and <= ceiling). Registered with the
|
|
77
|
+
* approval context so the operator reads it as a worst-case term, and carried
|
|
78
|
+
* onto the budget mint token so it is enforced at draw time.
|
|
79
|
+
*/
|
|
80
|
+
perTransaction?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Agent-supplied one-liner shown on the approval page in a labeled
|
|
83
|
+
* "written by the agent" block — provenance for the human, never trusted.
|
|
84
|
+
*/
|
|
85
|
+
intent?: string;
|
|
86
|
+
};
|
|
87
|
+
export type CliMandateFacts = CardMandateFacts & {
|
|
88
|
+
merchantHost: string;
|
|
89
|
+
/**
|
|
90
|
+
* True when the mandate minted its ceiling intent but the #5942 register
|
|
91
|
+
* handshake failed, so `findCovering` will SKIP it and no tap-free draw is
|
|
92
|
+
* possible. The mandate exists but is not usable — the caller must surface
|
|
93
|
+
* this (not report a plain success). Absent/false means registration succeeded;
|
|
94
|
+
* mandate-start now refuses before approval when no capability can register.
|
|
95
|
+
*/
|
|
96
|
+
registerFailed?: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* The server's refusal reason when {@link registerFailed} is true, verbatim.
|
|
99
|
+
* Carried out so the CLI can distinguish causes that need DIFFERENT operator
|
|
100
|
+
* actions — notably `token_mismatch`, which means the on-device grant record's
|
|
101
|
+
* cached token no longer matches the owner's live agentic token (they
|
|
102
|
+
* re-enrolled a card after the grant) and is fixed by re-running `grant-card`,
|
|
103
|
+
* not by retrying `mandate start`. Without the reason every failure reads as
|
|
104
|
+
* "the auth server was unreachable", which sends the operator in a loop.
|
|
105
|
+
*/
|
|
106
|
+
registerFailureReason?: string;
|
|
107
|
+
};
|
|
108
|
+
type Session = {
|
|
109
|
+
browser: Browser;
|
|
110
|
+
target: VgsCheckoutTarget;
|
|
111
|
+
amountMinor: number;
|
|
112
|
+
currency: string;
|
|
113
|
+
contact: Contact;
|
|
114
|
+
cleanupTimer: ReturnType<typeof setTimeout>;
|
|
115
|
+
};
|
|
116
|
+
export interface CardDrawVerdictDraw {
|
|
117
|
+
tokenId: string;
|
|
118
|
+
amount: string;
|
|
119
|
+
currency: string;
|
|
120
|
+
merchantName: string;
|
|
121
|
+
merchantUrl: string;
|
|
122
|
+
merchantCountryCode: string;
|
|
123
|
+
}
|
|
124
|
+
export interface CardDrawVerdictCapability {
|
|
125
|
+
/** Opaque to the engine — passed straight back to {@link CardDrawVerdictSeam.fetchVerdict}. */
|
|
126
|
+
agentKey: unknown;
|
|
127
|
+
agentJkt: string;
|
|
128
|
+
/** Auth origin that minted the binding and hosts the /v4/card/draw* routes. */
|
|
129
|
+
authBaseUrl: string;
|
|
130
|
+
}
|
|
131
|
+
export interface CardDrawVerdictSeam {
|
|
132
|
+
loadCapability: (agentRef?: string) => CardDrawVerdictCapability | null;
|
|
133
|
+
fetchVerdict: (input: {
|
|
134
|
+
authBaseUrl: string;
|
|
135
|
+
agentKey: unknown;
|
|
136
|
+
mandateId: string;
|
|
137
|
+
drawId: string;
|
|
138
|
+
draw: CardDrawVerdictDraw;
|
|
139
|
+
}) => Promise<{
|
|
140
|
+
verdict: string;
|
|
141
|
+
remainingCents: number;
|
|
142
|
+
}>;
|
|
143
|
+
}
|
|
144
|
+
export interface CardMandateRegisterSeam {
|
|
145
|
+
loadCapability: (agentRef?: string) => {
|
|
146
|
+
agentKey: unknown;
|
|
147
|
+
agentJkt: string;
|
|
148
|
+
authBaseUrl: string;
|
|
149
|
+
} | null;
|
|
150
|
+
register: (input: {
|
|
151
|
+
authBaseUrl: string;
|
|
152
|
+
agentKey: unknown;
|
|
153
|
+
mandateId: string;
|
|
154
|
+
mintToken: string;
|
|
155
|
+
ceiling: string;
|
|
156
|
+
currency: string;
|
|
157
|
+
}) => Promise<{
|
|
158
|
+
ok: boolean;
|
|
159
|
+
reason?: string;
|
|
160
|
+
}>;
|
|
161
|
+
}
|
|
162
|
+
export type CliEngineDeps = {
|
|
163
|
+
launchBrowser?: () => Promise<Browser>;
|
|
164
|
+
prepareCheckout?: typeof realPrepareCheckout;
|
|
165
|
+
submitApprovedCheckout?: typeof realSubmitApprovedCheckout;
|
|
166
|
+
runHostedApproval?: typeof realRunHostedApproval;
|
|
167
|
+
/**
|
|
168
|
+
* Relay the hosted-approval URL to the caller as DATA the moment it is known,
|
|
169
|
+
* before the (up-to-timeout) poll wait. A headless agent surface wires this to
|
|
170
|
+
* hand the URL to its operator; the raw CLI leaves it unset (the URL prints to
|
|
171
|
+
* stderr and best-effort opens a browser).
|
|
172
|
+
*/
|
|
173
|
+
onApprovalUrl?: (url: string) => void;
|
|
174
|
+
reportVicOutcome?: typeof realReportVicOutcome;
|
|
175
|
+
writeReceipt?: typeof realWriteReceipt;
|
|
176
|
+
store?: PreparedCheckoutSessionStore;
|
|
177
|
+
sessions?: Map<string, Session>;
|
|
178
|
+
ttlMs?: number;
|
|
179
|
+
/** Owner-only card-mandate ledger — defaults to the ~/.visa-mcp singleton. */
|
|
180
|
+
ledger?: MandateLedger;
|
|
181
|
+
/** Injectable clock for mandate expiry decisions (tests pin it). */
|
|
182
|
+
now?: () => Date;
|
|
183
|
+
/**
|
|
184
|
+
* Cryptogram transport for the TAP-FREE mandate draw — defaults to the real
|
|
185
|
+
* server-side mint route. Injectable so a test can drive the draw-reject ->
|
|
186
|
+
* markUnhonored path with no network.
|
|
187
|
+
*/
|
|
188
|
+
serverFetchCryptogram?: typeof serverFetchCryptogram;
|
|
189
|
+
/** Injectable confirmation transport; defaults to verify-web. */
|
|
190
|
+
serverPostConfirmation?: typeof serverPostConfirmation;
|
|
191
|
+
/**
|
|
192
|
+
* #5923 delegated card-draw verdict seam (see {@link CardDrawVerdictSeam}).
|
|
193
|
+
* Injected by the CLI when the runtime holds separately provisioned card
|
|
194
|
+
* authority. When absent, a covering-mandate draw fails before cryptogram mint.
|
|
195
|
+
*/
|
|
196
|
+
cardDrawVerdict?: CardDrawVerdictSeam;
|
|
197
|
+
/**
|
|
198
|
+
* #5942 delegated card-mandate register seam (see {@link CardMandateRegisterSeam}).
|
|
199
|
+
* Required by mandate-start. When absent, the ceremony is refused before
|
|
200
|
+
* passkey approval because a budget token cannot act as draw authority.
|
|
201
|
+
*/
|
|
202
|
+
cardMandateRegister?: CardMandateRegisterSeam;
|
|
203
|
+
/**
|
|
204
|
+
* Canonical mailbox OTP reader supplied by the runtime. The checkout engine
|
|
205
|
+
* owns no mailbox credential and never imports an email provider package.
|
|
206
|
+
*/
|
|
207
|
+
resolveEmailOtp?: OtpResolver;
|
|
208
|
+
};
|
|
209
|
+
export declare function createCliCheckoutEngine(deps?: CliEngineDeps): {
|
|
210
|
+
startCardMandate(input: CliStartMandateInput): Promise<CliMandateFacts>;
|
|
211
|
+
review(input: CliReviewInput): Promise<CliReviewFacts>;
|
|
212
|
+
pay(input: CliPayInput): Promise<CliReceiptFacts>;
|
|
213
|
+
};
|
|
214
|
+
export {};
|