@visa/cli 4.1.0-rc.9 → 4.1.0-rc.90

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 (70) hide show
  1. package/README.md +178 -231
  2. package/dist/checkout-engine/adapters/generic.d.ts +23 -0
  3. package/dist/checkout-engine/adapters/generic.js +216 -0
  4. package/dist/checkout-engine/adapters/index.d.ts +8 -0
  5. package/dist/checkout-engine/adapters/index.js +21 -0
  6. package/dist/checkout-engine/adapters/shopify.d.ts +31 -0
  7. package/dist/checkout-engine/adapters/shopify.js +423 -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 +214 -0
  17. package/dist/checkout-engine/cli-engine.js +701 -0
  18. package/dist/checkout-engine/detect.d.ts +61 -0
  19. package/dist/checkout-engine/detect.js +398 -0
  20. package/dist/checkout-engine/evidence.d.ts +25 -0
  21. package/dist/checkout-engine/evidence.js +104 -0
  22. package/dist/checkout-engine/executor.d.ts +176 -0
  23. package/dist/checkout-engine/executor.js +1322 -0
  24. package/dist/checkout-engine/hosted-approval.d.ts +142 -0
  25. package/dist/checkout-engine/hosted-approval.js +348 -0
  26. package/dist/checkout-engine/index.d.ts +6 -0
  27. package/dist/checkout-engine/index.js +8 -0
  28. package/dist/checkout-engine/inline-target.d.ts +13 -0
  29. package/dist/checkout-engine/inline-target.js +37 -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/live-fill-approval.d.ts +43 -0
  33. package/dist/checkout-engine/live-fill-approval.js +90 -0
  34. package/dist/checkout-engine/mandate/card-mandate.d.ts +121 -0
  35. package/dist/checkout-engine/mandate/card-mandate.js +227 -0
  36. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +142 -0
  37. package/dist/checkout-engine/mandate/mandate-ledger.js +338 -0
  38. package/dist/checkout-engine/mandate.d.ts +25 -0
  39. package/dist/checkout-engine/mandate.js +100 -0
  40. package/dist/checkout-engine/outcome.d.ts +30 -0
  41. package/dist/checkout-engine/outcome.js +225 -0
  42. package/dist/checkout-engine/owner-only-file.d.ts +19 -0
  43. package/dist/checkout-engine/owner-only-file.js +41 -0
  44. package/dist/checkout-engine/package.json +3 -0
  45. package/dist/checkout-engine/receipt.d.ts +81 -0
  46. package/dist/checkout-engine/receipt.js +109 -0
  47. package/dist/checkout-engine/repo-env.d.ts +11 -0
  48. package/dist/checkout-engine/repo-env.js +23 -0
  49. package/dist/checkout-engine/trace-handles.d.ts +8 -0
  50. package/dist/checkout-engine/trace-handles.js +12 -0
  51. package/dist/checkout-engine/types.d.ts +44 -0
  52. package/dist/checkout-engine/types.js +2 -0
  53. package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +74 -0
  54. package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +248 -0
  55. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +82 -0
  56. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +180 -0
  57. package/dist/checkout-engine/vgs-live-instrument.d.ts +179 -0
  58. package/dist/checkout-engine/vgs-live-instrument.js +296 -0
  59. package/dist/checkout-engine/vic-confirmation.d.ts +34 -0
  60. package/dist/checkout-engine/vic-confirmation.js +39 -0
  61. package/dist/cli.js +448 -433
  62. package/dist/mcp-server/index.js +366 -170
  63. package/dist/skills/pair-visa-agent/RUNTIMES.md +92 -0
  64. package/dist/skills/pair-visa-agent/SKILL.md +468 -0
  65. package/dist/skills/pair-visa-agent/scripts/setup.mjs +48 -0
  66. package/install.ps1 +3 -41
  67. package/install.sh +3 -35
  68. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  69. package/package.json +16 -12
  70. 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,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -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,8 @@
1
+ export declare function safeTraceHandle(value: unknown): string | undefined;
2
+ export declare function traceHandleFields(value: {
3
+ vgsTraceId?: unknown;
4
+ networkCorrelationId?: unknown;
5
+ }): {
6
+ vgsTraceId?: string;
7
+ networkCorrelationId?: string;
8
+ };
@@ -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,2 @@
1
+ // Shared value types used across the engine.
2
+ export {};
@@ -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 }>