@visa/cli 4.1.0-rc.2 → 4.1.0-rc.200
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 +200 -226
- package/dist/checkout-engine/adapters/generic.d.ts +88 -0
- package/dist/checkout-engine/adapters/generic.js +526 -0
- package/dist/checkout-engine/adapters/index.d.ts +10 -0
- package/dist/checkout-engine/adapters/index.js +24 -0
- package/dist/checkout-engine/adapters/shopify.d.ts +55 -0
- package/dist/checkout-engine/adapters/shopify.js +514 -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 +315 -0
- package/dist/checkout-engine/cli-engine.js +996 -0
- package/dist/checkout-engine/confirmed-merchants.d.ts +31 -0
- package/dist/checkout-engine/confirmed-merchants.js +165 -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 +215 -0
- package/dist/checkout-engine/executor.js +1520 -0
- package/dist/checkout-engine/hosted-approval.d.ts +195 -0
- package/dist/checkout-engine/hosted-approval.js +498 -0
- package/dist/checkout-engine/index.d.ts +9 -0
- package/dist/checkout-engine/index.js +11 -0
- package/dist/checkout-engine/instrument.d.ts +61 -0
- package/dist/checkout-engine/instrument.js +87 -0
- package/dist/checkout-engine/known-merchants.d.ts +10 -0
- package/dist/checkout-engine/known-merchants.js +38 -0
- package/dist/checkout-engine/live-fill-approval.d.ts +37 -0
- package/dist/checkout-engine/live-fill-approval.js +76 -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 +178 -0
- package/dist/checkout-engine/mandate/mandate-ledger.js +395 -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-dir.d.ts +6 -0
- package/dist/checkout-engine/receipt-dir.js +8 -0
- package/dist/checkout-engine/receipt.d.ts +121 -0
- package/dist/checkout-engine/receipt.js +138 -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 +52 -0
- package/dist/checkout-engine/types.js +2 -0
- package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
- package/dist/checkout-engine/unresolved-charges.js +125 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +101 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +218 -0
- package/dist/checkout-engine/vgs-live-instrument.d.ts +144 -0
- package/dist/checkout-engine/vgs-live-instrument.js +229 -0
- package/dist/checkout-engine/vic-confirmation.d.ts +52 -0
- package/dist/checkout-engine/vic-confirmation.js +45 -0
- package/dist/checkout-engine/web-bot-auth.d.ts +92 -0
- package/dist/checkout-engine/web-bot-auth.js +159 -0
- package/dist/cli.js +721 -445
- package/dist/mcp-apps/ucp-checkout.html +280 -0
- package/dist/mcp-server/index.js +573 -175
- package/dist/skills/pair-visa-agent/RUNTIMES.md +93 -0
- package/dist/skills/pair-visa-agent/SKILL.md +557 -0
- package/dist/skills/pair-visa-agent/scripts/setup.mjs +48 -0
- package/dist/subway-direct.mjs +1 -0
- package/install.ps1 +5 -43
- package/install.sh +5 -37
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +33 -25
- package/server.json +4 -4
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Mandate gate. The mandate is the user's pre-authorization for a purchase:
|
|
2
|
+
// how much, in what currency, at which merchant, until when. The gate is
|
|
3
|
+
// fail-closed: anything missing, expired, over cap, or mismatched refuses.
|
|
4
|
+
//
|
|
5
|
+
// The gate runs in two phases:
|
|
6
|
+
// pre-fill (checkMandatePreFill) — every fact knowable BEFORE a credential
|
|
7
|
+
// is minted: structure, expiry, merchant host, and the caller's
|
|
8
|
+
// asserted currency if any. No credential leaves the instrument
|
|
9
|
+
// and no field is filled without this passing. This is the
|
|
10
|
+
// boundary that matters once instruments mint live credentials:
|
|
11
|
+
// merchant JS sees field values on input, so filling first and
|
|
12
|
+
// gating at submit would already have exposed the credential.
|
|
13
|
+
// pre-submit (checkMandate) — the full check including the transaction
|
|
14
|
+
// amount and resolved currency, which are only known after the
|
|
15
|
+
// page total is read. No submit ever happens without this passing.
|
|
16
|
+
//
|
|
17
|
+
// All amounts are integer minor units (e.g. cents for USD). There is no
|
|
18
|
+
// floating-point money here on purpose; the executor reads a minor-unit total
|
|
19
|
+
// and compares integers.
|
|
20
|
+
function isInteger(n) {
|
|
21
|
+
return typeof n === 'number' && Number.isInteger(n);
|
|
22
|
+
}
|
|
23
|
+
// Pre-fill gate: everything knowable before a credential is minted.
|
|
24
|
+
export function checkMandatePreFill(mandate, ctx) {
|
|
25
|
+
if (!mandate) {
|
|
26
|
+
return { ok: false, reason: 'no mandate provided' };
|
|
27
|
+
}
|
|
28
|
+
// Structural validation: a malformed mandate must never authorize a spend.
|
|
29
|
+
if (!isInteger(mandate.maxAmountMinor) || mandate.maxAmountMinor <= 0) {
|
|
30
|
+
return { ok: false, reason: 'mandate maxAmountMinor must be a positive integer (minor units)' };
|
|
31
|
+
}
|
|
32
|
+
if (!mandate.currency) {
|
|
33
|
+
return { ok: false, reason: 'mandate is missing a currency' };
|
|
34
|
+
}
|
|
35
|
+
if (!mandate.expiresAt) {
|
|
36
|
+
return { ok: false, reason: 'mandate is missing an expiry' };
|
|
37
|
+
}
|
|
38
|
+
const expiry = new Date(mandate.expiresAt);
|
|
39
|
+
if (Number.isNaN(expiry.getTime())) {
|
|
40
|
+
return { ok: false, reason: `mandate expiry is not a valid date: ${mandate.expiresAt}` };
|
|
41
|
+
}
|
|
42
|
+
const now = ctx.now ?? new Date();
|
|
43
|
+
if (expiry.getTime() <= now.getTime()) {
|
|
44
|
+
return { ok: false, reason: `mandate expired at ${mandate.expiresAt}` };
|
|
45
|
+
}
|
|
46
|
+
if (ctx.currency && mandate.currency.toUpperCase() !== ctx.currency.toUpperCase()) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
reason: `currency mismatch: mandate ${mandate.currency} vs transaction ${ctx.currency}`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (mandate.merchantHost &&
|
|
53
|
+
normalizeHost(mandate.merchantHost) !== normalizeHost(ctx.merchantHost)) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
reason: `merchant host mismatch: mandate ${mandate.merchantHost} vs checkout ${ctx.merchantHost}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return { ok: true };
|
|
60
|
+
}
|
|
61
|
+
// Full (pre-submit) gate: the pre-fill checks plus amount and the resolved
|
|
62
|
+
// transaction currency.
|
|
63
|
+
export function checkMandate(mandate, ctx) {
|
|
64
|
+
const pre = checkMandatePreFill(mandate, ctx);
|
|
65
|
+
if (!pre.ok)
|
|
66
|
+
return pre;
|
|
67
|
+
if (!mandate)
|
|
68
|
+
return { ok: false, reason: 'no mandate provided' };
|
|
69
|
+
// The pre-fill phase skips the currency check when none is asserted yet;
|
|
70
|
+
// here the resolved currency is mandatory.
|
|
71
|
+
if (!ctx.currency) {
|
|
72
|
+
return { ok: false, reason: 'transaction currency could not be determined' };
|
|
73
|
+
}
|
|
74
|
+
// Transaction amount must be a clean integer minor-unit value.
|
|
75
|
+
if (!isInteger(ctx.amountMinor) || ctx.amountMinor < 0) {
|
|
76
|
+
return { ok: false, reason: 'transaction amount is not a non-negative integer (minor units)' };
|
|
77
|
+
}
|
|
78
|
+
if (ctx.amountMinor > mandate.maxAmountMinor) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
reason: `amount ${ctx.amountMinor} exceeds mandate cap ${mandate.maxAmountMinor} (minor units)`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return { ok: true };
|
|
85
|
+
}
|
|
86
|
+
function normalizeHost(host) {
|
|
87
|
+
// Reduce a scheme/path/port-bearing host to its bare hostname for the
|
|
88
|
+
// mandate-vs-checkout equality gate. Uses a linear `split('/')` to drop the
|
|
89
|
+
// path segment instead of a greedy `/\/.*$/` replace: on a merchant-supplied
|
|
90
|
+
// host with many leading slashes the greedy variant is a polynomial-ReDoS
|
|
91
|
+
// vector (CodeQL js/polynomial-redos), and `split` is exact-equivalent here —
|
|
92
|
+
// "everything before the first '/'". The remaining regexes are start/end
|
|
93
|
+
// anchored, so they backtrack linearly.
|
|
94
|
+
const noScheme = host
|
|
95
|
+
.trim()
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.replace(/^https?:\/\//, '');
|
|
98
|
+
const noPath = noScheme.split('/', 1)[0];
|
|
99
|
+
return noPath.replace(/:\d+$/, '');
|
|
100
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Page } from 'playwright-core';
|
|
2
|
+
export type OutcomeStatus = 'confirmed' | 'declined' | 'action-required' | 'verification-required' | 'processing' | 'unknown';
|
|
3
|
+
export type OutcomeClassification = {
|
|
4
|
+
status: OutcomeStatus;
|
|
5
|
+
/** The named pattern that decided the status, e.g. "body:card-declined". */
|
|
6
|
+
signal: string | null;
|
|
7
|
+
};
|
|
8
|
+
/** Pure classification of one page state (case-insensitive on all inputs). */
|
|
9
|
+
export declare function classifyOutcomePage(url: string, bodyText: string, frameUrls?: readonly string[]): OutcomeClassification;
|
|
10
|
+
export type ObservedOutcome = {
|
|
11
|
+
status: 'confirmed' | 'declined' | 'action-required' | 'verification-required' | 'unknown';
|
|
12
|
+
signal: string | null;
|
|
13
|
+
/** Last non-final classification when the deadline expired ('processing' | 'unknown'). */
|
|
14
|
+
lastSeen: OutcomeStatus;
|
|
15
|
+
attempts: number;
|
|
16
|
+
elapsedMs: number;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Watch the page until a definitive outcome or the deadline. Polling (rather
|
|
20
|
+
* than waiting for one navigation) is deliberate: declines often render in
|
|
21
|
+
* place with NO navigation, while confirmations may arrive after several
|
|
22
|
+
* redirects — both resolve here the moment their signal appears. A detected
|
|
23
|
+
* issuer challenge is equally final for this observer: it will not resolve
|
|
24
|
+
* itself, so waiting out the deadline would only misreport it as unknown.
|
|
25
|
+
*/
|
|
26
|
+
export declare function observeOutcome(page: Page, opts?: {
|
|
27
|
+
deadlineMs?: number;
|
|
28
|
+
pollMs?: number;
|
|
29
|
+
holdThroughChallenge?: boolean;
|
|
30
|
+
}): Promise<ObservedOutcome>;
|
|
@@ -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,8 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Where this device's checkout receipts live. Owned by its own module so the
|
|
5
|
+
* writer (cli-engine.ts) and the reader (confirmed-merchants.ts) can never
|
|
6
|
+
* drift onto two paths.
|
|
7
|
+
*/
|
|
8
|
+
export const RECEIPT_DIR = join(homedir(), '.visa-mcp', 'checkout-receipts');
|
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
/** Published v1 artifact shape. Retained so patch releases remain readable/typable. */
|
|
5
|
+
export type CheckoutReceiptV1 = {
|
|
6
|
+
schema: 'checkout-agent-receipt/v1';
|
|
7
|
+
recordedAt: string;
|
|
8
|
+
mode: CheckoutMode;
|
|
9
|
+
reviewId: string | null;
|
|
10
|
+
merchant: {
|
|
11
|
+
name: string;
|
|
12
|
+
host: string;
|
|
13
|
+
};
|
|
14
|
+
transaction: {
|
|
15
|
+
amount: string;
|
|
16
|
+
amountMinor: number;
|
|
17
|
+
currency: string;
|
|
18
|
+
};
|
|
19
|
+
outcome: CheckoutOutcome;
|
|
20
|
+
detail: string | null;
|
|
21
|
+
confirmationRef: string | null;
|
|
22
|
+
vicConfirmation: VicConfirmationReport | null;
|
|
23
|
+
credentialLifecycle: CredentialLifecycle;
|
|
24
|
+
credentialTiming: CredentialTiming;
|
|
25
|
+
detectedRoles: string[];
|
|
26
|
+
requiresAdapter: string[];
|
|
27
|
+
reconciliation: {
|
|
28
|
+
required: boolean;
|
|
29
|
+
reasons: string[];
|
|
30
|
+
};
|
|
31
|
+
evidence: {
|
|
32
|
+
steps: EvidenceStep[];
|
|
33
|
+
snapshotSummary: string | null;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
export type CheckoutReceiptV2 = {
|
|
37
|
+
schema: 'checkout-agent-receipt/v2';
|
|
38
|
+
recordedAt: string;
|
|
39
|
+
merchant: {
|
|
40
|
+
name: string;
|
|
41
|
+
host: string;
|
|
42
|
+
checkoutUrl: string | null;
|
|
43
|
+
};
|
|
44
|
+
transaction: {
|
|
45
|
+
amount: string;
|
|
46
|
+
amountMinor: number;
|
|
47
|
+
currency: string;
|
|
48
|
+
};
|
|
49
|
+
outcome: CheckoutOutcome;
|
|
50
|
+
agent: {
|
|
51
|
+
name: string;
|
|
52
|
+
} | null;
|
|
53
|
+
rail: {
|
|
54
|
+
type: 'card';
|
|
55
|
+
cardLast4: string | null;
|
|
56
|
+
};
|
|
57
|
+
network: {
|
|
58
|
+
confirmation: 'APPROVED' | 'DECLINED' | null;
|
|
59
|
+
};
|
|
60
|
+
/** Local reviewed-attempt identifier. Keeps filenames stable across schema versions. */
|
|
61
|
+
receiptId: string | null;
|
|
62
|
+
/** Merchant confirmation reference only; never substituted with an internal review id. */
|
|
63
|
+
reference: string | null;
|
|
64
|
+
recovery: {
|
|
65
|
+
required: boolean;
|
|
66
|
+
retrySafe: boolean;
|
|
67
|
+
action: string;
|
|
68
|
+
actions: string[];
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
export type CheckoutReceipt = CheckoutReceiptV1 | CheckoutReceiptV2;
|
|
72
|
+
export type ReceiptWriteReport = {
|
|
73
|
+
written: true;
|
|
74
|
+
path: string;
|
|
75
|
+
panRedactions: number;
|
|
76
|
+
} | {
|
|
77
|
+
written: false;
|
|
78
|
+
reason: string;
|
|
79
|
+
};
|
|
80
|
+
export declare function buildReceipt(input: {
|
|
81
|
+
mode: CheckoutMode;
|
|
82
|
+
reviewId: string | null;
|
|
83
|
+
merchant: {
|
|
84
|
+
name: string;
|
|
85
|
+
host: string;
|
|
86
|
+
url?: string;
|
|
87
|
+
};
|
|
88
|
+
transaction: {
|
|
89
|
+
amount: string;
|
|
90
|
+
amountMinor: number;
|
|
91
|
+
currency: string;
|
|
92
|
+
};
|
|
93
|
+
result: CheckoutResult;
|
|
94
|
+
vicConfirmation: VicConfirmationReport | null;
|
|
95
|
+
agentName?: string;
|
|
96
|
+
cardLast4?: string;
|
|
97
|
+
/** Injectable for tests; defaults to now. */
|
|
98
|
+
recordedAt?: Date;
|
|
99
|
+
}): CheckoutReceiptV2;
|
|
100
|
+
/**
|
|
101
|
+
* Defense-in-depth scrub before anything touches disk: any standalone
|
|
102
|
+
* 12–19-digit run that passes Luhn is replaced — contiguous OR separated by
|
|
103
|
+
* single spaces/hyphens between digit groups, the way merchant card-preview
|
|
104
|
+
* widgets render a typed number ("4242 4242 4242 4242") into page text that
|
|
105
|
+
* can reach the snapshot summary. Upstream masking means a hit should be
|
|
106
|
+
* impossible; the count surfaces so the runner can flag a leak.
|
|
107
|
+
*/
|
|
108
|
+
export declare function redactPanLikeDigits(text: string): {
|
|
109
|
+
text: string;
|
|
110
|
+
redactions: number;
|
|
111
|
+
};
|
|
112
|
+
export declare function serializeReceipt(receipt: CheckoutReceipt): {
|
|
113
|
+
json: string;
|
|
114
|
+
panRedactions: number;
|
|
115
|
+
};
|
|
116
|
+
export declare function receiptFileName(receipt: CheckoutReceipt): string;
|
|
117
|
+
/**
|
|
118
|
+
* Persist one receipt as an owner-only file (0600) in an owner-only directory
|
|
119
|
+
* (0700). Refuses to overwrite an existing file (`wx`). Never throws.
|
|
120
|
+
*/
|
|
121
|
+
export declare function writeReceipt(dir: string, receipt: CheckoutReceipt): Promise<ReceiptWriteReport>;
|