@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.
Files changed (76) hide show
  1. package/README.md +200 -226
  2. package/dist/checkout-engine/adapters/generic.d.ts +88 -0
  3. package/dist/checkout-engine/adapters/generic.js +526 -0
  4. package/dist/checkout-engine/adapters/index.d.ts +10 -0
  5. package/dist/checkout-engine/adapters/index.js +24 -0
  6. package/dist/checkout-engine/adapters/shopify.d.ts +55 -0
  7. package/dist/checkout-engine/adapters/shopify.js +514 -0
  8. package/dist/checkout-engine/adapters/stripe-like.d.ts +10 -0
  9. package/dist/checkout-engine/adapters/stripe-like.js +21 -0
  10. package/dist/checkout-engine/amount.d.ts +15 -0
  11. package/dist/checkout-engine/amount.js +72 -0
  12. package/dist/checkout-engine/browser-launch.d.ts +46 -0
  13. package/dist/checkout-engine/browser-launch.js +81 -0
  14. package/dist/checkout-engine/ceremony.d.ts +64 -0
  15. package/dist/checkout-engine/ceremony.js +261 -0
  16. package/dist/checkout-engine/cli-engine.d.ts +315 -0
  17. package/dist/checkout-engine/cli-engine.js +996 -0
  18. package/dist/checkout-engine/confirmed-merchants.d.ts +31 -0
  19. package/dist/checkout-engine/confirmed-merchants.js +165 -0
  20. package/dist/checkout-engine/detect.d.ts +61 -0
  21. package/dist/checkout-engine/detect.js +398 -0
  22. package/dist/checkout-engine/evidence.d.ts +25 -0
  23. package/dist/checkout-engine/evidence.js +104 -0
  24. package/dist/checkout-engine/executor.d.ts +215 -0
  25. package/dist/checkout-engine/executor.js +1520 -0
  26. package/dist/checkout-engine/hosted-approval.d.ts +195 -0
  27. package/dist/checkout-engine/hosted-approval.js +498 -0
  28. package/dist/checkout-engine/index.d.ts +9 -0
  29. package/dist/checkout-engine/index.js +11 -0
  30. package/dist/checkout-engine/instrument.d.ts +61 -0
  31. package/dist/checkout-engine/instrument.js +87 -0
  32. package/dist/checkout-engine/known-merchants.d.ts +10 -0
  33. package/dist/checkout-engine/known-merchants.js +38 -0
  34. package/dist/checkout-engine/live-fill-approval.d.ts +37 -0
  35. package/dist/checkout-engine/live-fill-approval.js +76 -0
  36. package/dist/checkout-engine/mandate/card-mandate.d.ts +121 -0
  37. package/dist/checkout-engine/mandate/card-mandate.js +227 -0
  38. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +178 -0
  39. package/dist/checkout-engine/mandate/mandate-ledger.js +395 -0
  40. package/dist/checkout-engine/mandate.d.ts +25 -0
  41. package/dist/checkout-engine/mandate.js +100 -0
  42. package/dist/checkout-engine/outcome.d.ts +30 -0
  43. package/dist/checkout-engine/outcome.js +225 -0
  44. package/dist/checkout-engine/owner-only-file.d.ts +19 -0
  45. package/dist/checkout-engine/owner-only-file.js +41 -0
  46. package/dist/checkout-engine/package.json +3 -0
  47. package/dist/checkout-engine/receipt-dir.d.ts +6 -0
  48. package/dist/checkout-engine/receipt-dir.js +8 -0
  49. package/dist/checkout-engine/receipt.d.ts +121 -0
  50. package/dist/checkout-engine/receipt.js +138 -0
  51. package/dist/checkout-engine/trace-handles.d.ts +8 -0
  52. package/dist/checkout-engine/trace-handles.js +12 -0
  53. package/dist/checkout-engine/types.d.ts +52 -0
  54. package/dist/checkout-engine/types.js +2 -0
  55. package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
  56. package/dist/checkout-engine/unresolved-charges.js +125 -0
  57. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +101 -0
  58. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +218 -0
  59. package/dist/checkout-engine/vgs-live-instrument.d.ts +144 -0
  60. package/dist/checkout-engine/vgs-live-instrument.js +229 -0
  61. package/dist/checkout-engine/vic-confirmation.d.ts +52 -0
  62. package/dist/checkout-engine/vic-confirmation.js +45 -0
  63. package/dist/checkout-engine/web-bot-auth.d.ts +92 -0
  64. package/dist/checkout-engine/web-bot-auth.js +159 -0
  65. package/dist/cli.js +721 -445
  66. package/dist/mcp-apps/ucp-checkout.html +280 -0
  67. package/dist/mcp-server/index.js +573 -175
  68. package/dist/skills/pair-visa-agent/RUNTIMES.md +93 -0
  69. package/dist/skills/pair-visa-agent/SKILL.md +557 -0
  70. package/dist/skills/pair-visa-agent/scripts/setup.mjs +48 -0
  71. package/dist/subway-direct.mjs +1 -0
  72. package/install.ps1 +5 -43
  73. package/install.sh +5 -37
  74. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  75. package/package.json +33 -25
  76. 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,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Where this device's checkout receipts live. Owned by its own module so the
3
+ * writer (cli-engine.ts) and the reader (confirmed-merchants.ts) can never
4
+ * drift onto two paths.
5
+ */
6
+ export declare const RECEIPT_DIR: string;
@@ -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>;