@visa/cli 4.1.0-rc.25 → 4.1.0-rc.4
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/cli.js +329 -364
- package/dist/mcp-server/index.js +148 -148
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +3 -5
- package/server.json +2 -2
- package/dist/checkout-engine/adapters/generic.d.ts +0 -19
- package/dist/checkout-engine/adapters/generic.js +0 -201
- package/dist/checkout-engine/adapters/index.d.ts +0 -7
- package/dist/checkout-engine/adapters/index.js +0 -17
- package/dist/checkout-engine/adapters/stripe-like.d.ts +0 -10
- package/dist/checkout-engine/adapters/stripe-like.js +0 -21
- package/dist/checkout-engine/browser-launch.d.ts +0 -46
- package/dist/checkout-engine/browser-launch.js +0 -81
- package/dist/checkout-engine/ceremony.d.ts +0 -64
- package/dist/checkout-engine/ceremony.js +0 -261
- package/dist/checkout-engine/cli-engine.d.ts +0 -60
- package/dist/checkout-engine/cli-engine.js +0 -242
- package/dist/checkout-engine/detect.d.ts +0 -61
- package/dist/checkout-engine/detect.js +0 -372
- package/dist/checkout-engine/evidence.d.ts +0 -22
- package/dist/checkout-engine/evidence.js +0 -59
- package/dist/checkout-engine/executor.d.ts +0 -172
- package/dist/checkout-engine/executor.js +0 -1233
- package/dist/checkout-engine/hosted-approval.d.ts +0 -78
- package/dist/checkout-engine/hosted-approval.js +0 -171
- package/dist/checkout-engine/index.d.ts +0 -3
- package/dist/checkout-engine/index.js +0 -5
- package/dist/checkout-engine/inline-target.d.ts +0 -13
- package/dist/checkout-engine/inline-target.js +0 -37
- package/dist/checkout-engine/instrument.d.ts +0 -54
- package/dist/checkout-engine/instrument.js +0 -83
- package/dist/checkout-engine/live-fill-approval.d.ts +0 -52
- package/dist/checkout-engine/live-fill-approval.js +0 -107
- package/dist/checkout-engine/mandate.d.ts +0 -25
- package/dist/checkout-engine/mandate.js +0 -100
- package/dist/checkout-engine/outcome.d.ts +0 -30
- package/dist/checkout-engine/outcome.js +0 -190
- package/dist/checkout-engine/owner-only-file.d.ts +0 -10
- package/dist/checkout-engine/owner-only-file.js +0 -22
- package/dist/checkout-engine/package.json +0 -3
- 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/receipt.d.ts +0 -81
- package/dist/checkout-engine/receipt.js +0 -109
- package/dist/checkout-engine/repo-env.d.ts +0 -11
- package/dist/checkout-engine/repo-env.js +0 -23
- package/dist/checkout-engine/run-live-fill.d.ts +0 -1
- package/dist/checkout-engine/run-live-fill.js +0 -443
- package/dist/checkout-engine/types.d.ts +0 -26
- package/dist/checkout-engine/types.js +0 -2
- package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
- package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +0 -248
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +0 -49
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +0 -150
- package/dist/checkout-engine/vgs-live-instrument.d.ts +0 -141
- package/dist/checkout-engine/vgs-live-instrument.js +0 -252
- package/dist/checkout-engine/vic-confirmation.d.ts +0 -34
- package/dist/checkout-engine/vic-confirmation.js +0 -39
- package/dist/skills/pair-visa-agent/RUNTIMES.md +0 -79
- package/dist/skills/pair-visa-agent/SKILL.md +0 -360
- package/dist/skills/pair-visa-agent/scripts/setup.mjs +0 -48
|
@@ -1,261 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,60 +0,0 @@
|
|
|
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 { writeReceipt as realWriteReceipt } from './receipt.js';
|
|
6
|
-
import { reportVicOutcome as realReportVicOutcome, type VicConfirmationReport } from './vic-confirmation.js';
|
|
7
|
-
import type { Contact } from './types.js';
|
|
8
|
-
export type CliReviewInput = {
|
|
9
|
-
url: string;
|
|
10
|
-
amount: string;
|
|
11
|
-
currency: string;
|
|
12
|
-
credentialPath: string;
|
|
13
|
-
contact: Contact;
|
|
14
|
-
approvalBaseUrl: string;
|
|
15
|
-
merchantName?: string;
|
|
16
|
-
merchantCountryCode?: string;
|
|
17
|
-
};
|
|
18
|
-
export type CliReviewFacts = {
|
|
19
|
-
reviewId: string;
|
|
20
|
-
merchantHost: string;
|
|
21
|
-
amountMinor: number;
|
|
22
|
-
currency: string;
|
|
23
|
-
submitTargetFingerprint: string;
|
|
24
|
-
detectedRoles: string[];
|
|
25
|
-
};
|
|
26
|
-
export type CliPayInput = CliReviewInput & {
|
|
27
|
-
reviewId: string;
|
|
28
|
-
submit: boolean;
|
|
29
|
-
};
|
|
30
|
-
export type CliReceiptFacts = {
|
|
31
|
-
outcome: string;
|
|
32
|
-
confirmationRef: string | null;
|
|
33
|
-
receiptPath: string | null;
|
|
34
|
-
detail: string | null;
|
|
35
|
-
vicConfirmation: VicConfirmationReport | null;
|
|
36
|
-
};
|
|
37
|
-
type Session = {
|
|
38
|
-
browser: Browser;
|
|
39
|
-
target: VgsCheckoutTarget;
|
|
40
|
-
amountMinor: number;
|
|
41
|
-
currency: string;
|
|
42
|
-
contact: Contact;
|
|
43
|
-
cleanupTimer: ReturnType<typeof setTimeout>;
|
|
44
|
-
};
|
|
45
|
-
export type CliEngineDeps = {
|
|
46
|
-
launchBrowser?: () => Promise<Browser>;
|
|
47
|
-
prepareCheckout?: typeof realPrepareCheckout;
|
|
48
|
-
submitApprovedCheckout?: typeof realSubmitApprovedCheckout;
|
|
49
|
-
runHostedApproval?: typeof realRunHostedApproval;
|
|
50
|
-
reportVicOutcome?: typeof realReportVicOutcome;
|
|
51
|
-
writeReceipt?: typeof realWriteReceipt;
|
|
52
|
-
store?: PreparedCheckoutSessionStore;
|
|
53
|
-
sessions?: Map<string, Session>;
|
|
54
|
-
ttlMs?: number;
|
|
55
|
-
};
|
|
56
|
-
export declare function createCliCheckoutEngine(deps?: CliEngineDeps): {
|
|
57
|
-
review(input: CliReviewInput): Promise<CliReviewFacts>;
|
|
58
|
-
pay(input: CliPayInput): Promise<CliReceiptFacts>;
|
|
59
|
-
};
|
|
60
|
-
export {};
|
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
// createCliCheckoutEngine — the review()/pay() adapter consumed by @visa/cli's
|
|
2
|
-
// pay_merchant tool. It COMPOSES prepareCheckout, runHostedApproval, the
|
|
3
|
-
// server-side mint (serverCreateIntent/serverFetchCryptogram — the credential is
|
|
4
|
-
// minted by the verify-web deployment, not this machine), submitApprovedCheckout,
|
|
5
|
-
// reportVicOutcome, and receipts into a two-call API.
|
|
6
|
-
//
|
|
7
|
-
// A live browser + prepared-checkout session is held in-process between review
|
|
8
|
-
// and pay, keyed by reviewId, so the submitted checkout is the exact one the
|
|
9
|
-
// human approved. Passkey approval uses the hosted /approve page only.
|
|
10
|
-
//
|
|
11
|
-
// Every browser/network primitive is injectable (CliEngineDeps) so the session/
|
|
12
|
-
// timer/store lifecycle is unit-testable without launching Chromium.
|
|
13
|
-
import { homedir } from 'node:os';
|
|
14
|
-
import { join } from 'node:path';
|
|
15
|
-
import { readFile } from 'node:fs/promises';
|
|
16
|
-
import { launchCheckoutBrowser } from './browser-launch.js';
|
|
17
|
-
import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
|
|
18
|
-
import { runHostedApproval as realRunHostedApproval } from './hosted-approval.js';
|
|
19
|
-
import { VgsAssuranceInstrument, decimalToMinor, } from './vgs-live-instrument.js';
|
|
20
|
-
import { serverCreateIntent, serverFetchCryptogram, serverPostConfirmation, } from './vgs-gateway/server-mint-client.js';
|
|
21
|
-
import { buildReceipt, writeReceipt as realWriteReceipt } from './receipt.js';
|
|
22
|
-
import { reportVicOutcome as realReportVicOutcome, } from './vic-confirmation.js';
|
|
23
|
-
const RECEIPT_DIR = join(homedir(), '.visa-mcp', 'checkout-receipts');
|
|
24
|
-
// Must match the prepared-checkout store TTL so a session and its store entry
|
|
25
|
-
// expire together — an abandoned review can't leak the browser + state.
|
|
26
|
-
const PREPARED_TTL_MS = 5 * 60 * 1000;
|
|
27
|
-
const defaultStore = new InMemoryPreparedCheckoutStore({ ttlMs: PREPARED_TTL_MS });
|
|
28
|
-
const defaultSessions = new Map();
|
|
29
|
-
export function createCliCheckoutEngine(deps = {}) {
|
|
30
|
-
const store = deps.store ?? defaultStore;
|
|
31
|
-
const sessions = deps.sessions ?? defaultSessions;
|
|
32
|
-
const ttlMs = deps.ttlMs ?? PREPARED_TTL_MS;
|
|
33
|
-
const launchBrowser = deps.launchBrowser ?? (() => launchCheckoutBrowser());
|
|
34
|
-
const prepareCheckout = deps.prepareCheckout ?? realPrepareCheckout;
|
|
35
|
-
const submitApprovedCheckout = deps.submitApprovedCheckout ?? realSubmitApprovedCheckout;
|
|
36
|
-
const runHostedApproval = deps.runHostedApproval ?? realRunHostedApproval;
|
|
37
|
-
const reportVicOutcome = deps.reportVicOutcome ?? realReportVicOutcome;
|
|
38
|
-
const writeReceipt = deps.writeReceipt ?? realWriteReceipt;
|
|
39
|
-
async function closeSession(reviewId) {
|
|
40
|
-
const session = sessions.get(reviewId);
|
|
41
|
-
if (!session)
|
|
42
|
-
return;
|
|
43
|
-
clearTimeout(session.cleanupTimer);
|
|
44
|
-
sessions.delete(reviewId);
|
|
45
|
-
await session.browser.close().catch(() => { });
|
|
46
|
-
}
|
|
47
|
-
function buildTarget(input) {
|
|
48
|
-
return {
|
|
49
|
-
merchantName: input.merchantName ?? new URL(input.url).hostname,
|
|
50
|
-
merchantUrl: input.url,
|
|
51
|
-
merchantCountryCode: input.merchantCountryCode ?? 'US',
|
|
52
|
-
transactionAmount: input.amount,
|
|
53
|
-
transactionCurrencyCode: input.currency,
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
return {
|
|
57
|
-
async review(input) {
|
|
58
|
-
const amountMinor = decimalToMinor(input.amount);
|
|
59
|
-
if (amountMinor === null)
|
|
60
|
-
throw new Error(`invalid amount ${JSON.stringify(input.amount)}`);
|
|
61
|
-
const host = new URL(input.url).hostname;
|
|
62
|
-
const browser = await launchBrowser();
|
|
63
|
-
try {
|
|
64
|
-
const prep = await prepareCheckout({
|
|
65
|
-
url: input.url,
|
|
66
|
-
mandate: {
|
|
67
|
-
maxAmountMinor: amountMinor,
|
|
68
|
-
currency: input.currency,
|
|
69
|
-
merchantHost: host,
|
|
70
|
-
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
|
71
|
-
},
|
|
72
|
-
amountMinor,
|
|
73
|
-
currency: input.currency,
|
|
74
|
-
browser,
|
|
75
|
-
}, store);
|
|
76
|
-
if (prep.status !== 'ready') {
|
|
77
|
-
await browser.close();
|
|
78
|
-
throw new Error(`review refused: ${prep.result.outcome} — ${prep.result.detail ?? ''}`);
|
|
79
|
-
}
|
|
80
|
-
const r = prep.checkout.review;
|
|
81
|
-
// Expire the companion session on the SAME schedule as the store entry
|
|
82
|
-
// (unref'd so a pending timer never keeps the process alive).
|
|
83
|
-
const cleanupTimer = setTimeout(() => void closeSession(r.id), ttlMs);
|
|
84
|
-
cleanupTimer.unref?.();
|
|
85
|
-
sessions.set(r.id, {
|
|
86
|
-
browser,
|
|
87
|
-
target: buildTarget(input),
|
|
88
|
-
amountMinor,
|
|
89
|
-
currency: input.currency,
|
|
90
|
-
contact: input.contact,
|
|
91
|
-
cleanupTimer,
|
|
92
|
-
});
|
|
93
|
-
return {
|
|
94
|
-
reviewId: r.id,
|
|
95
|
-
merchantHost: r.merchantHost,
|
|
96
|
-
amountMinor: r.amountMinor,
|
|
97
|
-
currency: r.currency,
|
|
98
|
-
submitTargetFingerprint: JSON.stringify(r.submitTargetFingerprint ?? null),
|
|
99
|
-
detectedRoles: [...r.detectedRoles],
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
catch (err) {
|
|
103
|
-
await browser.close().catch(() => { });
|
|
104
|
-
throw err;
|
|
105
|
-
}
|
|
106
|
-
},
|
|
107
|
-
async pay(input) {
|
|
108
|
-
const session = sessions.get(input.reviewId);
|
|
109
|
-
if (!session) {
|
|
110
|
-
return {
|
|
111
|
-
outcome: 'failed',
|
|
112
|
-
confirmationRef: null,
|
|
113
|
-
receiptPath: null,
|
|
114
|
-
detail: `no prepared review ${input.reviewId} — call review first (a review does not survive a restart)`,
|
|
115
|
-
vicConfirmation: null,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
// Amount-bind the confirmation: the pay-call amount must match the
|
|
119
|
-
// reviewed amount. The reviewId already locks the immutable mandate, but
|
|
120
|
-
// re-checking here makes the confirmation explicitly amount-bound so a
|
|
121
|
-
// caller cannot pay a different figure than the human reviewed.
|
|
122
|
-
const payAmountMinor = decimalToMinor(input.amount);
|
|
123
|
-
if (payAmountMinor !== session.amountMinor) {
|
|
124
|
-
await closeSession(input.reviewId);
|
|
125
|
-
return {
|
|
126
|
-
outcome: 'failed',
|
|
127
|
-
confirmationRef: null,
|
|
128
|
-
receiptPath: null,
|
|
129
|
-
detail: `pay amount ${JSON.stringify(input.amount)} does not match the reviewed amount (${session.amountMinor} minor units) — start a fresh review`,
|
|
130
|
-
vicConfirmation: null,
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
// Reject an expired prepared checkout BEFORE running the hosted passkey
|
|
134
|
-
// approval — the store may have expired/closed the entry while the review
|
|
135
|
-
// waited. Peeking here avoids minting a credential we can't submit.
|
|
136
|
-
if (!store.peek(input.reviewId)) {
|
|
137
|
-
await closeSession(input.reviewId);
|
|
138
|
-
return {
|
|
139
|
-
outcome: 'failed',
|
|
140
|
-
confirmationRef: null,
|
|
141
|
-
receiptPath: null,
|
|
142
|
-
detail: `prepared review ${input.reviewId} expired — start a fresh review`,
|
|
143
|
-
vicConfirmation: null,
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
clearTimeout(session.cleanupTimer);
|
|
147
|
-
try {
|
|
148
|
-
const credential = JSON.parse(await readFile(input.credentialPath, 'utf8'));
|
|
149
|
-
const assurance = await runHostedApproval({
|
|
150
|
-
baseUrl: input.approvalBaseUrl,
|
|
151
|
-
tokenId: credential.tokenId,
|
|
152
|
-
target: session.target,
|
|
153
|
-
consumerEmail: session.contact.email,
|
|
154
|
-
});
|
|
155
|
-
// Server-side mint (Phase 1): the approval claim releases a scoped mint
|
|
156
|
-
// token; the credential is minted by the verify-web deployment (which
|
|
157
|
-
// holds the VGS secret), never on this machine. No token means the
|
|
158
|
-
// deployment ran the dev-auth stub — refuse loudly rather than reach for
|
|
159
|
-
// a client-held secret (there is none anymore).
|
|
160
|
-
const approvalBase = input.approvalBaseUrl;
|
|
161
|
-
const mintToken = assurance.mintToken;
|
|
162
|
-
if (!mintToken) {
|
|
163
|
-
return {
|
|
164
|
-
outcome: 'failed',
|
|
165
|
-
confirmationRef: null,
|
|
166
|
-
receiptPath: null,
|
|
167
|
-
detail: 'the approval server issued no mint token — server-side minting requires the ' +
|
|
168
|
-
'verify-web deployment to run real/turnkey auth (not the dev stub). Retry once it does.',
|
|
169
|
-
vicConfirmation: null,
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
const instrument = new VgsAssuranceInstrument(credential, assurance, session.target, session.contact.fullName ?? '', async (mintInput) => {
|
|
173
|
-
const { intentId, status } = await serverCreateIntent(approvalBase, mintToken, mintInput);
|
|
174
|
-
try {
|
|
175
|
-
const payment = await serverFetchCryptogram(approvalBase, mintToken, {
|
|
176
|
-
tokenId: mintInput.tokenId,
|
|
177
|
-
intentId,
|
|
178
|
-
transaction: mintInput.transaction,
|
|
179
|
-
});
|
|
180
|
-
return { payment, intentId };
|
|
181
|
-
}
|
|
182
|
-
catch (err) {
|
|
183
|
-
throw new Error(`${err.message} (intent status at creation: ${status ?? 'unknown'})`);
|
|
184
|
-
}
|
|
185
|
-
});
|
|
186
|
-
const mode = input.submit ? 'submit' : 'dry-run';
|
|
187
|
-
const result = await submitApprovedCheckout(input.reviewId, {
|
|
188
|
-
approval: { approved: true, reviewId: input.reviewId },
|
|
189
|
-
instrument,
|
|
190
|
-
contact: session.contact,
|
|
191
|
-
mode,
|
|
192
|
-
}, store);
|
|
193
|
-
// Report the observed submit outcome to VIC for the consumed intent
|
|
194
|
-
// (APPROVED/DECLINED). Only definitive submit answers post. Mirrors
|
|
195
|
-
// run-live-fill.ts.
|
|
196
|
-
let vicConfirmation = null;
|
|
197
|
-
if (mode === 'submit') {
|
|
198
|
-
vicConfirmation = await reportVicOutcome({
|
|
199
|
-
target: instrument.confirmationTarget(),
|
|
200
|
-
outcome: result.outcome,
|
|
201
|
-
transaction: {
|
|
202
|
-
transactionAmount: session.target.transactionAmount,
|
|
203
|
-
transactionCurrencyCode: session.currency,
|
|
204
|
-
},
|
|
205
|
-
post: (confInput) => serverPostConfirmation(approvalBase, mintToken, confInput),
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
let receiptPath = null;
|
|
209
|
-
const report = await writeReceipt(RECEIPT_DIR, buildReceipt({
|
|
210
|
-
mode,
|
|
211
|
-
reviewId: input.reviewId,
|
|
212
|
-
// Derive BOTH name and host from the reviewed session target (not
|
|
213
|
-
// pay()'s input.url), so the receipt always reflects the checkout
|
|
214
|
-
// the human actually reviewed + that reviewId bound for submit.
|
|
215
|
-
merchant: {
|
|
216
|
-
name: session.target.merchantName,
|
|
217
|
-
host: new URL(session.target.merchantUrl).hostname,
|
|
218
|
-
},
|
|
219
|
-
transaction: {
|
|
220
|
-
amount: session.target.transactionAmount,
|
|
221
|
-
amountMinor: session.amountMinor,
|
|
222
|
-
currency: session.currency,
|
|
223
|
-
},
|
|
224
|
-
result,
|
|
225
|
-
vicConfirmation,
|
|
226
|
-
}));
|
|
227
|
-
if (report.written)
|
|
228
|
-
receiptPath = report.path;
|
|
229
|
-
return {
|
|
230
|
-
outcome: result.outcome,
|
|
231
|
-
confirmationRef: result.confirmationRef ?? null,
|
|
232
|
-
receiptPath,
|
|
233
|
-
detail: result.detail ?? null,
|
|
234
|
-
vicConfirmation,
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
finally {
|
|
238
|
-
await closeSession(input.reviewId);
|
|
239
|
-
}
|
|
240
|
-
},
|
|
241
|
-
};
|
|
242
|
-
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import type { Page } from 'playwright-core';
|
|
2
|
-
export type FieldSource = 'autocomplete' | 'attr-heuristic' | 'label-text' | 'iframe-psp' | 'type-inference';
|
|
3
|
-
export type FieldRole = 'number' | 'expMonth' | 'expYear' | 'expCombined' | 'cvc' | 'name' | 'nameFirst' | 'nameLast' | 'email' | 'addressLine1' | 'addressLine2' | 'city' | 'state' | 'postalCode' | 'country';
|
|
4
|
-
export declare const ALL_ROLES: FieldRole[];
|
|
5
|
-
export type SelectOption = {
|
|
6
|
-
value: string;
|
|
7
|
-
text: string;
|
|
8
|
-
};
|
|
9
|
-
export type CandidateMeta = {
|
|
10
|
-
idx: number;
|
|
11
|
-
tag: 'input' | 'select' | 'textarea';
|
|
12
|
-
type: string;
|
|
13
|
-
name: string;
|
|
14
|
-
id: string;
|
|
15
|
-
placeholder: string;
|
|
16
|
-
ariaLabel: string;
|
|
17
|
-
autocomplete: string;
|
|
18
|
-
maxlength: string;
|
|
19
|
-
inputmode: string;
|
|
20
|
-
pattern: string;
|
|
21
|
-
labelText: string;
|
|
22
|
-
options: SelectOption[];
|
|
23
|
-
visible: boolean;
|
|
24
|
-
};
|
|
25
|
-
export type RoleMatch = {
|
|
26
|
-
role: FieldRole;
|
|
27
|
-
confidence: number;
|
|
28
|
-
source: FieldSource;
|
|
29
|
-
};
|
|
30
|
-
export type FieldEntry = {
|
|
31
|
-
locator: string;
|
|
32
|
-
confidence: number;
|
|
33
|
-
source: FieldSource;
|
|
34
|
-
frame?: string;
|
|
35
|
-
tag: 'input' | 'select' | 'textarea';
|
|
36
|
-
inputType: string;
|
|
37
|
-
visible: boolean;
|
|
38
|
-
options?: SelectOption[];
|
|
39
|
-
maxlength?: number;
|
|
40
|
-
};
|
|
41
|
-
export type FieldMap = Partial<Record<FieldRole, FieldEntry>>;
|
|
42
|
-
export type FieldCandidate = {
|
|
43
|
-
role: FieldRole | null;
|
|
44
|
-
confidence: number;
|
|
45
|
-
source: FieldSource | null;
|
|
46
|
-
matches: RoleMatch[];
|
|
47
|
-
frame?: string;
|
|
48
|
-
meta: CandidateMeta;
|
|
49
|
-
};
|
|
50
|
-
export type PspHit = {
|
|
51
|
-
psp: string;
|
|
52
|
-
requiresAdapter: string | null;
|
|
53
|
-
frameSelector: string;
|
|
54
|
-
};
|
|
55
|
-
export type DetectResult = {
|
|
56
|
-
fields: FieldMap;
|
|
57
|
-
candidates: FieldCandidate[];
|
|
58
|
-
psps: PspHit[];
|
|
59
|
-
};
|
|
60
|
-
export declare function classifyCandidate(m: CandidateMeta): RoleMatch[];
|
|
61
|
-
export declare function detectFields(page: Page): Promise<DetectResult>;
|