@visa/cli 4.1.0-rc.6 → 4.1.0-rc.61
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 +168 -235
- 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 +200 -0
- package/dist/checkout-engine/cli-engine.js +659 -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 +335 -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 +391 -427
- package/dist/mcp-server/index.js +312 -163
- package/dist/skills/pair-visa-agent/RUNTIMES.md +92 -0
- package/dist/skills/pair-visa-agent/SKILL.md +315 -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 +11 -7
- package/server.json +3 -3
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Definitive merchant/processor "no". Kept high-precision: every phrase here
|
|
2
|
+
// means the charge did NOT succeed — never "maybe".
|
|
3
|
+
const DECLINED_BODY = [
|
|
4
|
+
{ name: 'body:card-declined', re: /\bdeclined\b/ },
|
|
5
|
+
{ name: 'body:payment-failed', re: /payment (?:has )?failed/ },
|
|
6
|
+
{ name: 'body:do-not-honor', re: /do not honou?r/ },
|
|
7
|
+
{ name: 'body:insufficient-funds', re: /insufficient funds/ },
|
|
8
|
+
// Shopify's stock copy for a rejected card.
|
|
9
|
+
{ name: 'body:not-verified', re: /payment details couldn.t be verified/ },
|
|
10
|
+
{ name: 'body:not-authorized', re: /transaction (?:was )?not authori[sz]ed/ },
|
|
11
|
+
];
|
|
12
|
+
// Where checkouts land after success. Matched against the parsed PATHNAME
|
|
13
|
+
// only — never the hostname or query string — and anchored to terminal
|
|
14
|
+
// segments, so `?next=/thank-you`, a host like thankyou-boutique.example,
|
|
15
|
+
// `/no-thank-you`, or a pre-payment review step at `/checkout/confirm` can
|
|
16
|
+
// never fabricate a confirmed verdict (Sentinel finding on #5704). A missed
|
|
17
|
+
// real confirmation degrades to 'unknown' + operator verification — the safe
|
|
18
|
+
// direction; a false 'confirmed' would report a charge that never happened.
|
|
19
|
+
// `thank-you` covers Shopify's legacy (/checkouts/<token>/thank_you) and
|
|
20
|
+
// extensibility (/checkouts/cn/<token>/thank-you) endings.
|
|
21
|
+
const CONFIRMED_URL = [
|
|
22
|
+
{ name: 'url:thank-you', re: /\/thank[-_]?you\/?$/ },
|
|
23
|
+
{ name: 'url:order-confirmation', re: /\/order-confirmation\b/ },
|
|
24
|
+
{ name: 'url:orders-page', re: /\/orders\/[a-z0-9]/ },
|
|
25
|
+
{ name: 'url:success', re: /\/success\/?$/ },
|
|
26
|
+
{ name: 'url:confirmation', re: /\/confirmation\/?$/ },
|
|
27
|
+
];
|
|
28
|
+
const CONFIRMED_BODY = [
|
|
29
|
+
{ name: 'body:order-confirmed', re: /order (?:is )?(?:confirmed|placed)/ },
|
|
30
|
+
{ name: 'body:thank-you-order', re: /thank(?:s| you) for your (?:order|purchase|donation)/ },
|
|
31
|
+
// Stripe payment-link success copy: "A payment to X will appear on your
|
|
32
|
+
// statement." Live run c2eda9f6 confirmed on-screen but classified unknown.
|
|
33
|
+
{ name: 'body:on-your-statement', re: /payment to .{1,80} will appear on your statement/ },
|
|
34
|
+
{ name: 'body:payment-ok', re: /payment (?:successful|approved)/ },
|
|
35
|
+
{ name: 'body:order-received', re: /(?:order received|we.ve received your order)/ },
|
|
36
|
+
{ name: 'body:order-ref', re: /order #/ },
|
|
37
|
+
];
|
|
38
|
+
// Issuer / 3-D Secure verification surfaces. 3DS runs BEFORE authorization,
|
|
39
|
+
// so a detected challenge is its own definitive verdict: "a human must verify
|
|
40
|
+
// — no charge exists until the challenge completes", and the page will never
|
|
41
|
+
// resolve to confirmed or declined on its own. Signals are precision-first
|
|
42
|
+
// heuristics: the two vendor hosts are documented 3DS infrastructure
|
|
43
|
+
// (Cardinal Commerce is Visa's 3DS provider; Stripe serves its challenge
|
|
44
|
+
// frames from hooks.stripe.com/3d_secure), and the generic pattern requires a
|
|
45
|
+
// 3DS path segment FOLLOWED by a challenge-flow segment, so an unrelated
|
|
46
|
+
// `/acs/…` route or a marketing `/3d/` page can never match. A missed
|
|
47
|
+
// challenge degrades to 'unknown' + operator verification — the safe
|
|
48
|
+
// direction, and exactly the pre-detection behavior.
|
|
49
|
+
const ACTION_REQUIRED_FRAME = [
|
|
50
|
+
{ name: 'frame:cardinal-3ds', re: /(?:^|\.)cardinalcommerce\.com\// },
|
|
51
|
+
{ name: 'frame:stripe-3ds', re: /hooks\.stripe\.com\/3d_secure/ },
|
|
52
|
+
{
|
|
53
|
+
name: 'frame:3ds-challenge',
|
|
54
|
+
re: /\/(?:3ds2?|3d[-_]?secure|threeds2?|acs)\/(?:challenge|browser|method|auth)\b/,
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
// Full-page ACS redirect flows (no iframe): matched on the pathname only.
|
|
58
|
+
const ACTION_REQUIRED_URL = [
|
|
59
|
+
{ name: 'url:3ds-redirect', re: /\/(?:3ds2?|3d[-_]?secure|threeds2?)\b/ },
|
|
60
|
+
];
|
|
61
|
+
// Challenge copy on the merchant's own page. Kept tight: each phrase is an
|
|
62
|
+
// instruction to authenticate, not a generic mention of security.
|
|
63
|
+
// 'body:link-wallet' must stay FIRST: the Stripe Link login modal is NOT an
|
|
64
|
+
// issuer challenge (completing it pays with an old saved card, not the minted
|
|
65
|
+
// credential) but its copy also says "Enter the code sent to …", which would
|
|
66
|
+
// otherwise match body:code-sent and misreport it as 3DS. Its own copy is
|
|
67
|
+
// distinctive: "to use your saved information" / "Logging in as <email>".
|
|
68
|
+
// The Stripe Link login modal. Checked BEFORE every other body pattern
|
|
69
|
+
// (including the email-OTP split below): completing it pays with an old saved
|
|
70
|
+
// card, not the minted credential, so it must stay action-required (human) and
|
|
71
|
+
// must never be mistaken for an agent-resolvable email code. See #5879.
|
|
72
|
+
const LINK_WALLET_BODY = [
|
|
73
|
+
{
|
|
74
|
+
name: 'body:link-wallet',
|
|
75
|
+
re: /use your saved information|logging in as .{1,60}?\. your device will be remembered/,
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
// Agent-RESOLVABLE email OTP: a code the MERCHANT emailed to the agent's own
|
|
79
|
+
// inbox, which wallet_mail_await_otp can retrieve. Kept deliberately narrow —
|
|
80
|
+
// it requires an EMAIL indicator (email / inbox / @) so an SMS "code sent to
|
|
81
|
+
// your phone" or an issuer 3DS challenge (both un-retrievable by the agent)
|
|
82
|
+
// stay action-required and fall to a human. This is the ONLY status the OTP
|
|
83
|
+
// subroutine will try to auto-complete.
|
|
84
|
+
const VERIFICATION_REQUIRED_BODY = [
|
|
85
|
+
// "a code was sent / e-mailed to your email / inbox" (code-first order).
|
|
86
|
+
{
|
|
87
|
+
name: 'body:email-code-sent',
|
|
88
|
+
re: /(?:code|passcode).{0,40}(?:sent|e-?mailed).{0,40}(?:e-?mail|inbox|@)/,
|
|
89
|
+
},
|
|
90
|
+
// "we e-mailed you a … code" (email-verb-first order).
|
|
91
|
+
{ name: 'body:email-code-sent', re: /e-?mailed\b.{0,40}(?:code|passcode)/ },
|
|
92
|
+
// "check your email / inbox for a … code".
|
|
93
|
+
{ name: 'body:check-email-code', re: /check your (?:e-?mail|inbox).{0,40}(?:code|verif)/ },
|
|
94
|
+
];
|
|
95
|
+
const ACTION_REQUIRED_BODY = [
|
|
96
|
+
{ name: 'body:3d-secure', re: /3-?d secure/ },
|
|
97
|
+
{ name: 'body:verify-identity', re: /verify your identity/ },
|
|
98
|
+
{ name: 'body:one-time-code', re: /enter (?:the )?(?:one[- ]?time|verification|security) code/ },
|
|
99
|
+
{ name: 'body:code-sent', re: /(?:code|passcode) (?:has been |was )?sent to/ },
|
|
100
|
+
];
|
|
101
|
+
// Interstitial states that mean "no verdict yet — keep watching".
|
|
102
|
+
const PROCESSING = [
|
|
103
|
+
{ name: 'body:processing', re: /processing (?:your )?(?:payment|order)/ },
|
|
104
|
+
{ name: 'body:please-wait', re: /please wait|do not (?:refresh|close)/ },
|
|
105
|
+
{ name: 'url:processing', re: /\/processing\b/ },
|
|
106
|
+
];
|
|
107
|
+
function match(patterns, text) {
|
|
108
|
+
for (const p of patterns)
|
|
109
|
+
if (p.re.test(text))
|
|
110
|
+
return p.name;
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
// URL signals only ever see the pathname: hostname and query/hash must not be
|
|
114
|
+
// able to satisfy a confirmation pattern. Falls back to a manual query/hash
|
|
115
|
+
// strip for non-absolute inputs (page.url() is always absolute in practice).
|
|
116
|
+
function pathnameOf(url) {
|
|
117
|
+
try {
|
|
118
|
+
return new URL(url).pathname.toLowerCase();
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return url.split(/[?#]/)[0].toLowerCase();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Frame signals see host + pathname only — the same #5704 lesson as
|
|
125
|
+
// confirmation URLs: a query string like `?next=https://x.cardinalcommerce.com/`
|
|
126
|
+
// must never satisfy an infrastructure-host pattern.
|
|
127
|
+
function hostAndPathOf(url) {
|
|
128
|
+
try {
|
|
129
|
+
const u = new URL(url);
|
|
130
|
+
return `${u.hostname}${u.pathname}`.toLowerCase();
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return url.split(/[?#]/)[0].toLowerCase();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Pure classification of one page state (case-insensitive on all inputs). */
|
|
137
|
+
export function classifyOutcomePage(url, bodyText, frameUrls = []) {
|
|
138
|
+
const u = pathnameOf(url);
|
|
139
|
+
const b = bodyText.toLowerCase();
|
|
140
|
+
const declined = match(DECLINED_BODY, b);
|
|
141
|
+
if (declined)
|
|
142
|
+
return { status: 'declined', signal: declined };
|
|
143
|
+
for (const frameUrl of frameUrls) {
|
|
144
|
+
const frame = match(ACTION_REQUIRED_FRAME, hostAndPathOf(frameUrl));
|
|
145
|
+
if (frame)
|
|
146
|
+
return { status: 'action-required', signal: frame };
|
|
147
|
+
}
|
|
148
|
+
// Stripe Link modal first — it must never fall through to the email-OTP split.
|
|
149
|
+
const linkWallet = match(LINK_WALLET_BODY, b);
|
|
150
|
+
if (linkWallet)
|
|
151
|
+
return { status: 'action-required', signal: linkWallet };
|
|
152
|
+
// Agent-resolvable email OTP: its own verdict, so the executor can retrieve
|
|
153
|
+
// the code rather than stopping for a human.
|
|
154
|
+
const emailVerify = match(VERIFICATION_REQUIRED_BODY, b);
|
|
155
|
+
if (emailVerify)
|
|
156
|
+
return { status: 'verification-required', signal: emailVerify };
|
|
157
|
+
const challengeUrl = match(ACTION_REQUIRED_URL, u);
|
|
158
|
+
if (challengeUrl)
|
|
159
|
+
return { status: 'action-required', signal: challengeUrl };
|
|
160
|
+
const challengeBody = match(ACTION_REQUIRED_BODY, b);
|
|
161
|
+
if (challengeBody)
|
|
162
|
+
return { status: 'action-required', signal: challengeBody };
|
|
163
|
+
const confirmedUrl = match(CONFIRMED_URL, u);
|
|
164
|
+
if (confirmedUrl)
|
|
165
|
+
return { status: 'confirmed', signal: confirmedUrl };
|
|
166
|
+
const confirmedBody = match(CONFIRMED_BODY, b);
|
|
167
|
+
if (confirmedBody)
|
|
168
|
+
return { status: 'confirmed', signal: confirmedBody };
|
|
169
|
+
const processing = match(PROCESSING, b) ?? match(PROCESSING, u);
|
|
170
|
+
if (processing)
|
|
171
|
+
return { status: 'processing', signal: processing };
|
|
172
|
+
return { status: 'unknown', signal: null };
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Watch the page until a definitive outcome or the deadline. Polling (rather
|
|
176
|
+
* than waiting for one navigation) is deliberate: declines often render in
|
|
177
|
+
* place with NO navigation, while confirmations may arrive after several
|
|
178
|
+
* redirects — both resolve here the moment their signal appears. A detected
|
|
179
|
+
* issuer challenge is equally final for this observer: it will not resolve
|
|
180
|
+
* itself, so waiting out the deadline would only misreport it as unknown.
|
|
181
|
+
*/
|
|
182
|
+
export async function observeOutcome(page, opts = {}) {
|
|
183
|
+
const deadlineMs = opts.deadlineMs ?? 20_000;
|
|
184
|
+
const pollMs = opts.pollMs ?? 500;
|
|
185
|
+
const startedAt = Date.now();
|
|
186
|
+
let attempts = 0;
|
|
187
|
+
for (;;) {
|
|
188
|
+
attempts++;
|
|
189
|
+
// Visible text only (innerText, never textContent): Stripe payment links
|
|
190
|
+
// embed their entire client-side error catalog — including "your card was
|
|
191
|
+
// declined" — in non-rendered DOM, so a hidden-inclusive read classifies a
|
|
192
|
+
// fresh page as declined before any authorization has run.
|
|
193
|
+
const body = (await page.evaluate(() => document.body?.innerText ?? '').catch(() => '')) || '';
|
|
194
|
+
const frameUrls = page.frames().map((f) => f.url());
|
|
195
|
+
const seen = classifyOutcomePage(page.url(), body, frameUrls);
|
|
196
|
+
// An active challenge is normally final for the automated observer (it
|
|
197
|
+
// will not resolve itself). holdThroughChallenge inverts that for the
|
|
198
|
+
// human-in-the-loop hold: the challenge stays "pending" and observation
|
|
199
|
+
// continues until the post-challenge confirmed/declined signal appears.
|
|
200
|
+
if (seen.status === 'confirmed' ||
|
|
201
|
+
seen.status === 'declined' ||
|
|
202
|
+
// Email OTP won't self-resolve either — return it so the executor's
|
|
203
|
+
// single-use OTP subroutine can retrieve the code (or a human is asked).
|
|
204
|
+
seen.status === 'verification-required' ||
|
|
205
|
+
(seen.status === 'action-required' && !opts.holdThroughChallenge)) {
|
|
206
|
+
return {
|
|
207
|
+
status: seen.status,
|
|
208
|
+
signal: seen.signal,
|
|
209
|
+
lastSeen: seen.status,
|
|
210
|
+
attempts,
|
|
211
|
+
elapsedMs: Date.now() - startedAt,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (Date.now() - startedAt >= deadlineMs) {
|
|
215
|
+
return {
|
|
216
|
+
status: 'unknown',
|
|
217
|
+
signal: seen.signal,
|
|
218
|
+
lastSeen: seen.status,
|
|
219
|
+
attempts,
|
|
220
|
+
elapsedMs: Date.now() - startedAt,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
await page.waitForTimeout(pollMs);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lstat-only guard for operator-supplied secret-bearing files: must be a
|
|
3
|
+
* regular file (never a symlink), owner-only (chmod 600), and size-bounded.
|
|
4
|
+
* Reads NOTHING — safe to run as a preflight before the point where the
|
|
5
|
+
* file's contents are allowed to be consumed (the assurance-mode agent
|
|
6
|
+
* credential is only read after the operator approves the checkout review).
|
|
7
|
+
*/
|
|
8
|
+
export declare function assertOwnerOnlyFile(path: string, label: string, maxBytes?: number): Promise<void>;
|
|
9
|
+
/** The same guard, then parse — contents are read only after the guard passes. */
|
|
10
|
+
export declare function readOwnerOnlyJson<T>(path: string, label: string, maxBytes?: number): Promise<T>;
|
|
11
|
+
/**
|
|
12
|
+
* Atomically write an owner-only (chmod 600) JSON artifact: serialize to a
|
|
13
|
+
* sibling temp file created 0600, then rename over the target so a reader never
|
|
14
|
+
* observes a half-written ledger (and a crash mid-write leaves the prior file
|
|
15
|
+
* intact). The parent directory is created if absent (matching ~/.visa-mcp).
|
|
16
|
+
* Used by the card-mandate ledger — the same owner-only posture as
|
|
17
|
+
* agent-credential.json / contact.json.
|
|
18
|
+
*/
|
|
19
|
+
export declare function writeOwnerOnlyJson(path: string, value: unknown): Promise<void>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { chmod, lstat, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* lstat-only guard for operator-supplied secret-bearing files: must be a
|
|
6
|
+
* regular file (never a symlink), owner-only (chmod 600), and size-bounded.
|
|
7
|
+
* Reads NOTHING — safe to run as a preflight before the point where the
|
|
8
|
+
* file's contents are allowed to be consumed (the assurance-mode agent
|
|
9
|
+
* credential is only read after the operator approves the checkout review).
|
|
10
|
+
*/
|
|
11
|
+
export async function assertOwnerOnlyFile(path, label, maxBytes = 16 * 1024) {
|
|
12
|
+
const info = await lstat(path);
|
|
13
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
14
|
+
throw new Error(`${label} must be a regular file`);
|
|
15
|
+
if ((info.mode & 0o077) !== 0)
|
|
16
|
+
throw new Error(`${label} must be owner-only (chmod 600)`);
|
|
17
|
+
if (info.size > maxBytes)
|
|
18
|
+
throw new Error(`${label} is unexpectedly large`);
|
|
19
|
+
}
|
|
20
|
+
/** The same guard, then parse — contents are read only after the guard passes. */
|
|
21
|
+
export async function readOwnerOnlyJson(path, label, maxBytes = 16 * 1024) {
|
|
22
|
+
await assertOwnerOnlyFile(path, label, maxBytes);
|
|
23
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Atomically write an owner-only (chmod 600) JSON artifact: serialize to a
|
|
27
|
+
* sibling temp file created 0600, then rename over the target so a reader never
|
|
28
|
+
* observes a half-written ledger (and a crash mid-write leaves the prior file
|
|
29
|
+
* intact). The parent directory is created if absent (matching ~/.visa-mcp).
|
|
30
|
+
* Used by the card-mandate ledger — the same owner-only posture as
|
|
31
|
+
* agent-credential.json / contact.json.
|
|
32
|
+
*/
|
|
33
|
+
export async function writeOwnerOnlyJson(path, value) {
|
|
34
|
+
await mkdir(dirname(path), { recursive: true });
|
|
35
|
+
const tmp = `${path}.tmp-${randomBytes(6).toString('hex')}`;
|
|
36
|
+
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
37
|
+
// writeFile's mode only applies on create; chmod defensively in case the temp
|
|
38
|
+
// name somehow pre-existed with looser bits.
|
|
39
|
+
await chmod(tmp, 0o600);
|
|
40
|
+
await rename(tmp, path);
|
|
41
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { EvidenceStep } from './evidence.js';
|
|
2
|
+
import type { CheckoutMode, CheckoutOutcome, CheckoutResult, CredentialLifecycle, CredentialTiming } from './executor.js';
|
|
3
|
+
import type { VicConfirmationReport } from './vic-confirmation.js';
|
|
4
|
+
export type CheckoutReceipt = {
|
|
5
|
+
schema: 'checkout-agent-receipt/v1';
|
|
6
|
+
recordedAt: string;
|
|
7
|
+
mode: CheckoutMode;
|
|
8
|
+
reviewId: string | null;
|
|
9
|
+
merchant: {
|
|
10
|
+
name: string;
|
|
11
|
+
host: string;
|
|
12
|
+
};
|
|
13
|
+
transaction: {
|
|
14
|
+
amount: string;
|
|
15
|
+
amountMinor: number;
|
|
16
|
+
currency: string;
|
|
17
|
+
};
|
|
18
|
+
outcome: CheckoutOutcome;
|
|
19
|
+
detail: string | null;
|
|
20
|
+
confirmationRef: string | null;
|
|
21
|
+
vicConfirmation: VicConfirmationReport | null;
|
|
22
|
+
credentialLifecycle: CredentialLifecycle;
|
|
23
|
+
credentialTiming: CredentialTiming;
|
|
24
|
+
detectedRoles: string[];
|
|
25
|
+
requiresAdapter: string[];
|
|
26
|
+
reconciliation: {
|
|
27
|
+
required: boolean;
|
|
28
|
+
reasons: string[];
|
|
29
|
+
};
|
|
30
|
+
evidence: {
|
|
31
|
+
steps: EvidenceStep[];
|
|
32
|
+
snapshotSummary: string | null;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
export type ReceiptWriteReport = {
|
|
36
|
+
written: true;
|
|
37
|
+
path: string;
|
|
38
|
+
panRedactions: number;
|
|
39
|
+
} | {
|
|
40
|
+
written: false;
|
|
41
|
+
reason: string;
|
|
42
|
+
};
|
|
43
|
+
export declare function buildReceipt(input: {
|
|
44
|
+
mode: CheckoutMode;
|
|
45
|
+
reviewId: string | null;
|
|
46
|
+
merchant: {
|
|
47
|
+
name: string;
|
|
48
|
+
host: string;
|
|
49
|
+
};
|
|
50
|
+
transaction: {
|
|
51
|
+
amount: string;
|
|
52
|
+
amountMinor: number;
|
|
53
|
+
currency: string;
|
|
54
|
+
};
|
|
55
|
+
result: CheckoutResult;
|
|
56
|
+
vicConfirmation: VicConfirmationReport | null;
|
|
57
|
+
/** Injectable for tests; defaults to now. */
|
|
58
|
+
recordedAt?: Date;
|
|
59
|
+
}): CheckoutReceipt;
|
|
60
|
+
/**
|
|
61
|
+
* Defense-in-depth scrub before anything touches disk: any standalone
|
|
62
|
+
* 12–19-digit run that passes Luhn is replaced — contiguous OR separated by
|
|
63
|
+
* single spaces/hyphens between digit groups, the way merchant card-preview
|
|
64
|
+
* widgets render a typed number ("4242 4242 4242 4242") into page text that
|
|
65
|
+
* can reach the snapshot summary. Upstream masking means a hit should be
|
|
66
|
+
* impossible; the count surfaces so the runner can flag a leak.
|
|
67
|
+
*/
|
|
68
|
+
export declare function redactPanLikeDigits(text: string): {
|
|
69
|
+
text: string;
|
|
70
|
+
redactions: number;
|
|
71
|
+
};
|
|
72
|
+
export declare function serializeReceipt(receipt: CheckoutReceipt): {
|
|
73
|
+
json: string;
|
|
74
|
+
panRedactions: number;
|
|
75
|
+
};
|
|
76
|
+
export declare function receiptFileName(receipt: CheckoutReceipt): string;
|
|
77
|
+
/**
|
|
78
|
+
* Persist one receipt as an owner-only file (0600) in an owner-only directory
|
|
79
|
+
* (0700). Refuses to overwrite an existing file (`wx`). Never throws.
|
|
80
|
+
*/
|
|
81
|
+
export declare function writeReceipt(dir: string, receipt: CheckoutReceipt): Promise<ReceiptWriteReport>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { submitClickedWithoutConfirmation } from './live-fill-approval.js';
|
|
4
|
+
/**
|
|
5
|
+
* What the operator still owes after this run. Empty for a clean dry-run fill
|
|
6
|
+
* and for a definitive submit outcome whose VIC confirmation posted.
|
|
7
|
+
*/
|
|
8
|
+
function reconciliationFor(result, vicConfirmation) {
|
|
9
|
+
const reasons = [];
|
|
10
|
+
if (submitClickedWithoutConfirmation(result)) {
|
|
11
|
+
reasons.push('the pay control was clicked but no definitive outcome was observed — ' +
|
|
12
|
+
'verify the charge with the merchant dashboard before any retry');
|
|
13
|
+
}
|
|
14
|
+
if ((result.outcome === 'confirmed' || result.outcome === 'declined') &&
|
|
15
|
+
vicConfirmation &&
|
|
16
|
+
!vicConfirmation.posted) {
|
|
17
|
+
reasons.push(`the VIC confirmation was not posted (${vicConfirmation.reason}) — ` +
|
|
18
|
+
'retry it for this intent out-of-band');
|
|
19
|
+
}
|
|
20
|
+
if (result.outcome === 'confirmed' && !result.confirmationRef) {
|
|
21
|
+
reasons.push('confirmed without a merchant confirmation reference — ' +
|
|
22
|
+
'record the order number from the merchant dashboard');
|
|
23
|
+
}
|
|
24
|
+
if (result.outcome === 'action-required') {
|
|
25
|
+
reasons.push('an issuer verification challenge was shown and not completed — ' +
|
|
26
|
+
'finish the purchase manually if still wanted, or confirm with the merchant that no order exists');
|
|
27
|
+
}
|
|
28
|
+
return { required: reasons.length > 0, reasons };
|
|
29
|
+
}
|
|
30
|
+
export function buildReceipt(input) {
|
|
31
|
+
const { result } = input;
|
|
32
|
+
return {
|
|
33
|
+
schema: 'checkout-agent-receipt/v1',
|
|
34
|
+
recordedAt: (input.recordedAt ?? new Date()).toISOString(),
|
|
35
|
+
mode: input.mode,
|
|
36
|
+
reviewId: input.reviewId,
|
|
37
|
+
merchant: { name: input.merchant.name, host: input.merchant.host },
|
|
38
|
+
transaction: input.transaction,
|
|
39
|
+
outcome: result.outcome,
|
|
40
|
+
detail: result.detail ?? null,
|
|
41
|
+
confirmationRef: result.confirmationRef ?? null,
|
|
42
|
+
vicConfirmation: input.vicConfirmation,
|
|
43
|
+
credentialLifecycle: result.credentialLifecycle,
|
|
44
|
+
credentialTiming: result.credentialTiming,
|
|
45
|
+
detectedRoles: Object.keys(result.fields),
|
|
46
|
+
requiresAdapter: [...result.requiresAdapter],
|
|
47
|
+
reconciliation: reconciliationFor(result, input.vicConfirmation),
|
|
48
|
+
evidence: result.evidence.toObject(),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function luhnValid(digits) {
|
|
52
|
+
let sum = 0;
|
|
53
|
+
let double = false;
|
|
54
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
55
|
+
let d = digits.charCodeAt(i) - 48;
|
|
56
|
+
if (double) {
|
|
57
|
+
d *= 2;
|
|
58
|
+
if (d > 9)
|
|
59
|
+
d -= 9;
|
|
60
|
+
}
|
|
61
|
+
sum += d;
|
|
62
|
+
double = !double;
|
|
63
|
+
}
|
|
64
|
+
return sum % 10 === 0;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Defense-in-depth scrub before anything touches disk: any standalone
|
|
68
|
+
* 12–19-digit run that passes Luhn is replaced — contiguous OR separated by
|
|
69
|
+
* single spaces/hyphens between digit groups, the way merchant card-preview
|
|
70
|
+
* widgets render a typed number ("4242 4242 4242 4242") into page text that
|
|
71
|
+
* can reach the snapshot summary. Upstream masking means a hit should be
|
|
72
|
+
* impossible; the count surfaces so the runner can flag a leak.
|
|
73
|
+
*/
|
|
74
|
+
export function redactPanLikeDigits(text) {
|
|
75
|
+
let redactions = 0;
|
|
76
|
+
const scrubbed = text.replace(/(?<!\d)(?<!\d[\s-])\d(?:[\s-]?\d){11,18}(?![\s-]?\d)/g, (run) => {
|
|
77
|
+
const digits = run.replace(/[\s-]/g, '');
|
|
78
|
+
if (digits.length < 12 || digits.length > 19 || !luhnValid(digits))
|
|
79
|
+
return run;
|
|
80
|
+
redactions++;
|
|
81
|
+
return '<redacted:pan-like>';
|
|
82
|
+
});
|
|
83
|
+
return { text: scrubbed, redactions };
|
|
84
|
+
}
|
|
85
|
+
export function serializeReceipt(receipt) {
|
|
86
|
+
const { text, redactions } = redactPanLikeDigits(`${JSON.stringify(receipt, null, 2)}\n`);
|
|
87
|
+
return { json: text, panRedactions: redactions };
|
|
88
|
+
}
|
|
89
|
+
export function receiptFileName(receipt) {
|
|
90
|
+
const ts = receipt.recordedAt.replace(/[:.]/g, '-');
|
|
91
|
+
const review = (receipt.reviewId ?? 'no-review').replace(/[^A-Za-z0-9-]/g, '');
|
|
92
|
+
return `receipt-${ts}-${review || 'no-review'}.json`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Persist one receipt as an owner-only file (0600) in an owner-only directory
|
|
96
|
+
* (0700). Refuses to overwrite an existing file (`wx`). Never throws.
|
|
97
|
+
*/
|
|
98
|
+
export async function writeReceipt(dir, receipt) {
|
|
99
|
+
try {
|
|
100
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
101
|
+
const path = join(dir, receiptFileName(receipt));
|
|
102
|
+
const { json, panRedactions } = serializeReceipt(receipt);
|
|
103
|
+
await writeFile(path, json, { mode: 0o600, flag: 'wx' });
|
|
104
|
+
return { written: true, path, panRedactions };
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
return { written: false, reason: err.message };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load the monorepo root `.env` as DEFAULTS for the runner, so stable
|
|
3
|
+
* per-machine config (VGS credentials, CHECKOUT_APPROVAL_BASE_URL,
|
|
4
|
+
* CHECKOUT_AGENT_ALLOW_SUBMIT, CHECKOUT_AGENT_MODE) is set once instead of
|
|
5
|
+
* retyped on every invocation. `process.loadEnvFile` never overrides
|
|
6
|
+
* variables already present in the environment (verified: shell always wins),
|
|
7
|
+
* so an explicit `FOO=x pnpm pay …` still beats the file. A missing file is
|
|
8
|
+
* fine — the runner then relies on the ambient environment exactly as before.
|
|
9
|
+
*/
|
|
10
|
+
export declare function defaultRepoEnvPath(): string;
|
|
11
|
+
export declare function loadRepoEnvDefaults(path?: string, log?: (line: string) => void): boolean;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
/**
|
|
3
|
+
* Load the monorepo root `.env` as DEFAULTS for the runner, so stable
|
|
4
|
+
* per-machine config (VGS credentials, CHECKOUT_APPROVAL_BASE_URL,
|
|
5
|
+
* CHECKOUT_AGENT_ALLOW_SUBMIT, CHECKOUT_AGENT_MODE) is set once instead of
|
|
6
|
+
* retyped on every invocation. `process.loadEnvFile` never overrides
|
|
7
|
+
* variables already present in the environment (verified: shell always wins),
|
|
8
|
+
* so an explicit `FOO=x pnpm pay …` still beats the file. A missing file is
|
|
9
|
+
* fine — the runner then relies on the ambient environment exactly as before.
|
|
10
|
+
*/
|
|
11
|
+
export function defaultRepoEnvPath() {
|
|
12
|
+
return fileURLToPath(new URL('../../../.env', import.meta.url));
|
|
13
|
+
}
|
|
14
|
+
export function loadRepoEnvDefaults(path = defaultRepoEnvPath(), log = (line) => process.stdout.write(`${line}\n`)) {
|
|
15
|
+
try {
|
|
16
|
+
process.loadEnvFile(path);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
log(`loaded env defaults from ${path} (already-set variables win)`);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const SAFE_TRACE_HANDLE_RE = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
2
|
+
export function safeTraceHandle(value) {
|
|
3
|
+
return typeof value === 'string' && SAFE_TRACE_HANDLE_RE.test(value) ? value : undefined;
|
|
4
|
+
}
|
|
5
|
+
export function traceHandleFields(value) {
|
|
6
|
+
const vgsTraceId = safeTraceHandle(value.vgsTraceId);
|
|
7
|
+
const networkCorrelationId = safeTraceHandle(value.networkCorrelationId);
|
|
8
|
+
return {
|
|
9
|
+
...(vgsTraceId ? { vgsTraceId } : {}),
|
|
10
|
+
...(networkCorrelationId ? { networkCorrelationId } : {}),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type PostalAddress = {
|
|
2
|
+
firstName?: string;
|
|
3
|
+
lastName?: string;
|
|
4
|
+
fullName?: string;
|
|
5
|
+
addressLine1?: string;
|
|
6
|
+
addressLine2?: string;
|
|
7
|
+
city?: string;
|
|
8
|
+
state?: string;
|
|
9
|
+
postalCode?: string;
|
|
10
|
+
country?: string;
|
|
11
|
+
};
|
|
12
|
+
export type Contact = PostalAddress & {
|
|
13
|
+
email?: string;
|
|
14
|
+
phone?: string;
|
|
15
|
+
billingAddress?: PostalAddress;
|
|
16
|
+
};
|
|
17
|
+
export type FilledField = {
|
|
18
|
+
role: string;
|
|
19
|
+
locator: string;
|
|
20
|
+
confidence: number;
|
|
21
|
+
source: string;
|
|
22
|
+
frame?: string;
|
|
23
|
+
value: string;
|
|
24
|
+
ok: boolean;
|
|
25
|
+
error?: string;
|
|
26
|
+
};
|
|
27
|
+
export type FillResult = {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
filled: FilledField[];
|
|
30
|
+
detail?: string;
|
|
31
|
+
};
|
|
32
|
+
export type OtpRequest = {
|
|
33
|
+
/** ISO watermark captured BEFORE the click that triggers the OTP email. */
|
|
34
|
+
after: string;
|
|
35
|
+
/** The merchant hostname; the sender's registrable domain must match it. */
|
|
36
|
+
merchantHost: string;
|
|
37
|
+
};
|
|
38
|
+
export type OtpResolution = {
|
|
39
|
+
/** The single-use code. The executor fills it exactly once; never retried. */
|
|
40
|
+
code: string;
|
|
41
|
+
/** The sender's registrable domain — a trust signal, safe to log (no PII). */
|
|
42
|
+
fromDomain: string;
|
|
43
|
+
};
|
|
44
|
+
export type OtpResolver = (req: OtpRequest) => Promise<OtpResolution | null>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type VgsTransaction = {
|
|
2
|
+
merchantName: string
|
|
3
|
+
merchantUrl: string
|
|
4
|
+
merchantCountryCode: string
|
|
5
|
+
transactionAmount: string
|
|
6
|
+
transactionCurrencyCode: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createIntent(
|
|
10
|
+
input: {
|
|
11
|
+
tokenId: string
|
|
12
|
+
assuranceData: unknown
|
|
13
|
+
transaction: VgsTransaction
|
|
14
|
+
},
|
|
15
|
+
env?: Record<string, string | undefined>
|
|
16
|
+
): Promise<{
|
|
17
|
+
intentId: string
|
|
18
|
+
/**
|
|
19
|
+
* Creation-time status attribute (null when VGS omits it). HTTP 201 alone
|
|
20
|
+
* does not mean the intent is authorized — surface this when a later
|
|
21
|
+
* cryptogram fails (#5709).
|
|
22
|
+
*/
|
|
23
|
+
status: string | null
|
|
24
|
+
}>
|
|
25
|
+
|
|
26
|
+
export function fetchCryptogram(
|
|
27
|
+
input: {
|
|
28
|
+
tokenId: string
|
|
29
|
+
intentId: string
|
|
30
|
+
transaction: VgsTransaction
|
|
31
|
+
},
|
|
32
|
+
env?: Record<string, string | undefined>
|
|
33
|
+
): Promise<{
|
|
34
|
+
networkToken: string
|
|
35
|
+
expMonth: number
|
|
36
|
+
expYear: number
|
|
37
|
+
cryptogramType: string
|
|
38
|
+
cryptogramValue: string
|
|
39
|
+
cryptogramExpiresAt?: string
|
|
40
|
+
}>
|
|
41
|
+
|
|
42
|
+
export function getCredential(
|
|
43
|
+
input: {
|
|
44
|
+
tokenId: string
|
|
45
|
+
assuranceData: unknown
|
|
46
|
+
transaction: VgsTransaction
|
|
47
|
+
},
|
|
48
|
+
env?: Record<string, string | undefined>
|
|
49
|
+
): Promise<{
|
|
50
|
+
networkToken: string
|
|
51
|
+
expMonth: number
|
|
52
|
+
expYear: number
|
|
53
|
+
cryptogramType: string
|
|
54
|
+
cryptogramValue: string
|
|
55
|
+
cryptogramExpiresAt?: string
|
|
56
|
+
}>
|
|
57
|
+
|
|
58
|
+
export function postConfirmation(input: {
|
|
59
|
+
tokenId: string
|
|
60
|
+
intentId: string
|
|
61
|
+
transactionStatus: 'APPROVED' | 'DECLINED' | 'PENDING' | 'ERROR' | 'CANCELLED'
|
|
62
|
+
transactionType:
|
|
63
|
+
| 'PURCHASE'
|
|
64
|
+
| 'AUTHORIZATION'
|
|
65
|
+
| 'CAPTURE'
|
|
66
|
+
| 'REFUND'
|
|
67
|
+
| 'REVERSAL'
|
|
68
|
+
| 'VERIFICATION'
|
|
69
|
+
| 'CHARGEBACK'
|
|
70
|
+
| 'FRAUD'
|
|
71
|
+
/** ISO 8601; converted to the guide's quoted epoch-seconds on the wire. */
|
|
72
|
+
transactionTimestamp: string
|
|
73
|
+
transaction: Pick<VgsTransaction, 'transactionAmount' | 'transactionCurrencyCode'>
|
|
74
|
+
}, env?: Record<string, string | undefined>): Promise<{ ok: true }>
|