@visa/cli 4.1.0-rc.6 → 4.1.0-rc.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +168 -235
  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 +200 -0
  17. package/dist/checkout-engine/cli-engine.js +659 -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 +339 -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 +335 -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 +170 -0
  58. package/dist/checkout-engine/vgs-live-instrument.js +293 -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 +391 -427
  62. package/dist/mcp-server/index.js +312 -163
  63. package/dist/skills/pair-visa-agent/RUNTIMES.md +92 -0
  64. package/dist/skills/pair-visa-agent/SKILL.md +315 -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 +11 -7
  70. package/server.json +3 -3
@@ -0,0 +1,142 @@
1
+ import type { PurchaseAssurance, VgsCheckoutTarget } from './vgs-live-instrument.js';
2
+ /**
3
+ * Hosted purchase approval — the deployed-site replacement for the loopback
4
+ * ceremony page (ceremony.ts). The passkey tap happens on the verify-web
5
+ * deployment's /approve page instead of a 127.0.0.1 tab; this module is the
6
+ * runner's side of that relay (PKCE / device-code shape, mirroring the app's
7
+ * lib/server/agent-approval.ts):
8
+ *
9
+ * 1. Mint a 32-byte VERIFIER in process memory (never on disk — an approval
10
+ * lives and dies inside one run), derive challenge = sha256(verifier).
11
+ * 2. Register the purchase context under the challenge, then open
12
+ * <baseUrl>/approve?req=<challenge> in the operator's browser.
13
+ * 3. Poll the claim route with the verifier: `pending` while the operator
14
+ * signs in and taps the passkey; `completed` returns the assuranceData
15
+ * ONCE and the entry is gone.
16
+ *
17
+ * The URL only ever carries the challenge — useless without the verifier —
18
+ * and the verifier only ever travels to an HTTPS (or explicit-loopback)
19
+ * origin. The claim response echoes the context the approval was COMPLETED
20
+ * against; it is verified field-by-field against this run's checkout target
21
+ * before the assuranceData is accepted, so relay corruption or a divergent
22
+ * registration can never smuggle in an assurance scoped to something else
23
+ * (#5709 would reject it at cryptogram time anyway — this fails minutes
24
+ * earlier, with the divergent field named). Every network wait is bounded by
25
+ * the remaining deadline via AbortController — a stalled connection cannot
26
+ * hang the runner past the advertised timeout.
27
+ */
28
+ export type HostedApprovalOptions = {
29
+ /** The enrollment web deployment origin (apps/web), e.g. https://web-….up.railway.app */
30
+ baseUrl: string;
31
+ /** Durable agentic token id from the CLI agent credential. */
32
+ tokenId: string;
33
+ target: VgsCheckoutTarget;
34
+ /** Prefills the approval page's enrollment-email field (optional). */
35
+ consumerEmail?: string;
36
+ /**
37
+ * BUDGET/mandate approval: `target.transactionAmount` is the approved spend
38
+ * CEILING (not one charge), so the server mints a BUDGET mint token that later
39
+ * accepts many sub-ceiling draws tap-free. Omit for a single-purchase approval.
40
+ */
41
+ budget?: boolean;
42
+ /**
43
+ * Exact current request-key thumbprint receiving a budget. Required for a
44
+ * budget and forbidden for a one-purchase approval; the authenticated page
45
+ * resolves it to the owner's stable agent before displaying or signing.
46
+ */
47
+ agentJkt?: string;
48
+ /** Advisory max draws the ceiling intent may fulfil — carried onto the token. */
49
+ maxDraws?: number;
50
+ /**
51
+ * Per-purchase cap (decimal string, <= the ceiling) on a BUDGET approval —
52
+ * shown on the approval page as a worst-case term and carried onto the mint
53
+ * token so the displayed cap is the enforced cap. Budget mode only.
54
+ */
55
+ perTransaction?: string;
56
+ /**
57
+ * Agent-supplied one-liner describing what this approval is for. Sanitized
58
+ * here (control chars stripped, trimmed, capped) and rendered by the approval
59
+ * page in a clearly-labeled "written by the agent" block — provenance for the
60
+ * human to cross-check, never a trusted field.
61
+ */
62
+ intent?: string;
63
+ log?: (line: string) => void;
64
+ /**
65
+ * Called once with the approval URL as DATA (not a log line) the instant it is
66
+ * known — before any polling. A headless agent (OpenClaw/Hermes) has no browser
67
+ * and no passkey; its whole job is to relay this URL to its human operator, so
68
+ * the URL must be capturable as a value, not just printed. The interactive CLI
69
+ * leaves this unset and relies on `log` + `openUrl`.
70
+ */
71
+ onApprovalUrl?: (url: string) => void;
72
+ /** Injectable for tests; default opens the operator's default browser, best-effort. */
73
+ openUrl?: (url: string) => void;
74
+ fetchImpl?: typeof fetch;
75
+ /** Injectable for tests — never wall-clock-sleep in a unit test. */
76
+ sleep?: (ms: number) => Promise<void>;
77
+ now?: () => number;
78
+ pollIntervalMs?: number;
79
+ timeoutMs?: number;
80
+ };
81
+ /** One short sentence — must match the relay's cap (agent-approval.ts). */
82
+ export declare const APPROVAL_INTENT_MAX_CHARS = 200;
83
+ /**
84
+ * Sanitize an agent-supplied intent to what the relay will accept and the page
85
+ * will display: control chars (and JS line separators) stripped, trimmed,
86
+ * TRUNCATED to the cap (the runner is the agent's own side, so truncating here
87
+ * beats a failed registration; the server still rejects an over-cap value).
88
+ * Returns undefined when nothing displayable remains.
89
+ */
90
+ export declare function sanitizeApprovalIntent(value: string | undefined): string | undefined;
91
+ /**
92
+ * The operator saw the request and said NO. Terminal and non-retryable: the
93
+ * relay entry is consumed, re-running would only re-ask a human who already
94
+ * refused. Callers must not classify this as transient.
95
+ */
96
+ export declare class HostedApprovalDeclinedError extends Error {
97
+ constructor();
98
+ }
99
+ export declare const HOSTED_APPROVAL_TIMEOUT_MS: number;
100
+ export declare const HOSTED_APPROVAL_POLL_MS = 3000;
101
+ /** Emit a "still waiting" heartbeat roughly every this-many ms during the poll. */
102
+ export declare const HOSTED_APPROVAL_HEARTBEAT_MS = 30000;
103
+ /**
104
+ * Resolve the approval timeout: `CHECKOUT_APPROVAL_TIMEOUT_MS` (seconds*1000, an
105
+ * integer ms) overrides the default when it parses to a positive integer.
106
+ * Lets an operator widen the window for separate-device / headless approval
107
+ * without threading a flag through every caller. Invalid values fall back.
108
+ */
109
+ export declare function resolveApprovalTimeoutMs(): number;
110
+ /**
111
+ * The staging apps/web deploy (which now hosts the /agent/enroll/approve page
112
+ * after the v4-verify-web GA graduation) — hosted approval is the NORMAL path
113
+ * (no local certs, no vendored SDK, no localhost anywhere), so its origin is a
114
+ * built-in default rather than per-machine config. Not a secret: it's the
115
+ * public page the operator's browser opens anyway.
116
+ */
117
+ export declare const DEFAULT_APPROVAL_BASE_URL = "https://web-visa-code-preview.up.railway.app";
118
+ /**
119
+ * Flag > environment > built-in default. An explicitly EMPTY value
120
+ * (CHECKOUT_APPROVAL_BASE_URL='') opts out of hosted approval entirely —
121
+ * that is the loopback-ceremony escape hatch, so the default must not
122
+ * resurrect over it (?? not ||).
123
+ */
124
+ export declare function resolveApprovalBaseUrl(flagValue: string | undefined, env?: Record<string, string | undefined>): string;
125
+ /**
126
+ * Validate the approval-page origin: HTTPS everywhere except explicit
127
+ * loopback hosts — the SAME policy the loader applies to the checkout URL.
128
+ * The claim call sends the verifier (the credential that releases the
129
+ * assurance), so a cleartext remote origin is never acceptable.
130
+ */
131
+ export declare function assertApprovalBaseUrl(value: string): string;
132
+ /**
133
+ * The claim also releases the scoped MINT TOKEN (server-side mint, Phase 1) —
134
+ * bound to this exact approved purchase — so the runner can call the gateway
135
+ * mint routes without the VGS secret. Absent when the deployment ran the
136
+ * dev-auth stub (it mints no token); the caller then surfaces a clear error
137
+ * rather than falling back to a client-held secret.
138
+ */
139
+ export declare function runHostedApproval(opts: HostedApprovalOptions): Promise<PurchaseAssurance & {
140
+ mintToken?: string;
141
+ validUntil?: number;
142
+ }>;
@@ -0,0 +1,339 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { exec } from 'node:child_process';
3
+ import { assuranceFromCeremony } from './ceremony.js';
4
+ /** One short sentence — must match the relay's cap (agent-approval.ts). */
5
+ export const APPROVAL_INTENT_MAX_CHARS = 200;
6
+ /**
7
+ * Sanitize an agent-supplied intent to what the relay will accept and the page
8
+ * will display: control chars (and JS line separators) stripped, trimmed,
9
+ * TRUNCATED to the cap (the runner is the agent's own side, so truncating here
10
+ * beats a failed registration; the server still rejects an over-cap value).
11
+ * Returns undefined when nothing displayable remains.
12
+ */
13
+ export function sanitizeApprovalIntent(value) {
14
+ if (value === undefined)
15
+ return undefined;
16
+ const cleaned = value
17
+ .replace(/[\p{Cc}\u2028\u2029]/gu, '')
18
+ .trim()
19
+ .slice(0, APPROVAL_INTENT_MAX_CHARS)
20
+ .trim();
21
+ return cleaned.length > 0 ? cleaned : undefined;
22
+ }
23
+ /**
24
+ * The operator saw the request and said NO. Terminal and non-retryable: the
25
+ * relay entry is consumed, re-running would only re-ask a human who already
26
+ * refused. Callers must not classify this as transient.
27
+ */
28
+ export class HostedApprovalDeclinedError extends Error {
29
+ constructor() {
30
+ super('The approver declined this request — the checkout was cancelled and nothing was charged');
31
+ this.name = 'HostedApprovalDeclinedError';
32
+ }
33
+ }
34
+ export const HOSTED_APPROVAL_TIMEOUT_MS = 4 * 60 * 1000;
35
+ export const HOSTED_APPROVAL_POLL_MS = 3_000;
36
+ /** Emit a "still waiting" heartbeat roughly every this-many ms during the poll. */
37
+ export const HOSTED_APPROVAL_HEARTBEAT_MS = 30_000;
38
+ /**
39
+ * Resolve the approval timeout: `CHECKOUT_APPROVAL_TIMEOUT_MS` (seconds*1000, an
40
+ * integer ms) overrides the default when it parses to a positive integer.
41
+ * Lets an operator widen the window for separate-device / headless approval
42
+ * without threading a flag through every caller. Invalid values fall back.
43
+ */
44
+ export function resolveApprovalTimeoutMs() {
45
+ const raw = process.env.CHECKOUT_APPROVAL_TIMEOUT_MS;
46
+ if (raw !== undefined) {
47
+ const n = Number(raw);
48
+ if (Number.isSafeInteger(n) && n > 0)
49
+ return n;
50
+ }
51
+ return HOSTED_APPROVAL_TIMEOUT_MS;
52
+ }
53
+ /** Unref'd so a raced-and-abandoned deadline timer can never hold the process open. */
54
+ const defaultSleep = (ms) => new Promise((r) => {
55
+ const t = setTimeout(r, ms);
56
+ t.unref?.();
57
+ });
58
+ function defaultOpenUrl(url) {
59
+ // Best-effort convenience only — the URL is always surfaced via `log`/
60
+ // `onApprovalUrl`, so this must never throw or hang if there is no browser.
61
+ // `CHECKOUT_SKIP_BROWSER_OPEN=1` disables it (headless/agent hosts where
62
+ // launching a browser on the WRONG machine is pointless or noisy).
63
+ if (process.env.CHECKOUT_SKIP_BROWSER_OPEN === '1')
64
+ return;
65
+ // Platform-appropriate opener; unknown platforms just skip (the URL is logged).
66
+ const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start ""' : 'xdg-open';
67
+ if (process.platform !== 'darwin' && process.platform !== 'win32' && process.platform !== 'linux')
68
+ return;
69
+ const quoted = `'${url.replaceAll("'", "'\\''")}'`;
70
+ try {
71
+ // exec is async and fire-and-forget; swallow the callback error so a missing
72
+ // opener binary (common on headless Linux) can never surface as a failure.
73
+ exec(`${opener} ${quoted}`, () => { });
74
+ }
75
+ catch {
76
+ // Spawn failure (no shell, sandboxed) — ignore; the printed URL is the path.
77
+ }
78
+ }
79
+ /**
80
+ * The staging apps/web deploy (which now hosts the /agent/enroll/approve page
81
+ * after the v4-verify-web GA graduation) — hosted approval is the NORMAL path
82
+ * (no local certs, no vendored SDK, no localhost anywhere), so its origin is a
83
+ * built-in default rather than per-machine config. Not a secret: it's the
84
+ * public page the operator's browser opens anyway.
85
+ */
86
+ export const DEFAULT_APPROVAL_BASE_URL = 'https://web-visa-code-preview.up.railway.app';
87
+ /**
88
+ * Flag > environment > built-in default. An explicitly EMPTY value
89
+ * (CHECKOUT_APPROVAL_BASE_URL='') opts out of hosted approval entirely —
90
+ * that is the loopback-ceremony escape hatch, so the default must not
91
+ * resurrect over it (?? not ||).
92
+ */
93
+ export function resolveApprovalBaseUrl(flagValue, env = process.env) {
94
+ return (flagValue ?? env.CHECKOUT_APPROVAL_BASE_URL ?? DEFAULT_APPROVAL_BASE_URL).trim();
95
+ }
96
+ /**
97
+ * Validate the approval-page origin: HTTPS everywhere except explicit
98
+ * loopback hosts — the SAME policy the loader applies to the checkout URL.
99
+ * The claim call sends the verifier (the credential that releases the
100
+ * assurance), so a cleartext remote origin is never acceptable.
101
+ */
102
+ export function assertApprovalBaseUrl(value) {
103
+ let url;
104
+ try {
105
+ url = new URL(value);
106
+ }
107
+ catch {
108
+ throw new Error('--approval-base-url must be a valid http(s) origin');
109
+ }
110
+ const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
111
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
112
+ throw new Error('--approval-base-url must use HTTPS (localhost is allowed for local testing) — ' +
113
+ 'the approval claim carries a credential and never travels cleartext');
114
+ }
115
+ return value;
116
+ }
117
+ function stripTrailingSlashes(value) {
118
+ let out = value;
119
+ while (out.endsWith('/'))
120
+ out = out.slice(0, -1);
121
+ return out;
122
+ }
123
+ /**
124
+ * The claim also releases the scoped MINT TOKEN (server-side mint, Phase 1) —
125
+ * bound to this exact approved purchase — so the runner can call the gateway
126
+ * mint routes without the VGS secret. Absent when the deployment ran the
127
+ * dev-auth stub (it mints no token); the caller then surfaces a clear error
128
+ * rather than falling back to a client-held secret.
129
+ */
130
+ export async function runHostedApproval(opts) {
131
+ const { baseUrl, tokenId, target, consumerEmail, budget, agentJkt, maxDraws, perTransaction, intent,
132
+ // Default to stderr, NOT a no-op: the approval URL is the one thing a remote /
133
+ // SSH / headless-terminal human needs to proceed, and swallowing it (the old
134
+ // `() => {}` default) left it emitted nowhere readable. Tests inject their own.
135
+ log = (line) => void process.stderr.write(`${line}\n`), onApprovalUrl, openUrl = defaultOpenUrl, fetchImpl = fetch, sleep = defaultSleep, now = Date.now, pollIntervalMs = HOSTED_APPROVAL_POLL_MS,
136
+ // A human approving on a SEPARATE device (open the link, sign in, tap the
137
+ // passkey) needs more than the interactive 4-minute window; allow an env
138
+ // override without threading a flag through every caller.
139
+ timeoutMs = resolveApprovalTimeoutMs(), } = opts;
140
+ const canonicalJkt = typeof agentJkt === 'string' && /^[A-Za-z0-9_-]{43}$/.test(agentJkt);
141
+ if (budget && !canonicalJkt) {
142
+ throw new Error('a budget approval requires the exact current agent request-key JKT');
143
+ }
144
+ if (!budget && agentJkt !== undefined) {
145
+ throw new Error('agentJkt is accepted only for a budget approval');
146
+ }
147
+ const base = stripTrailingSlashes(assertApprovalBaseUrl(baseUrl));
148
+ // The server's currency map is uppercase ISO 4217; the loader normalizes the
149
+ // target once, but normalize here too so a direct caller with a lowercase
150
+ // code registers (and verifies) the same value the relay stores.
151
+ const currency = target.transactionCurrencyCode.toUpperCase();
152
+ // Sanitize once, register + verify the SAME value: the relay stores the
153
+ // sanitized form, so equality below must compare against it, not the raw input.
154
+ const intentSanitized = sanitizeApprovalIntent(intent);
155
+ const verifier = randomBytes(32).toString('base64url');
156
+ const challenge = createHash('sha256').update(verifier, 'utf8').digest('base64url');
157
+ const deadline = now() + timeoutMs;
158
+ const timeoutError = () => new Error(`no passkey approval within ${Math.round(timeoutMs / 1000)}s — ` +
159
+ 'the checkout was cancelled; run again to retry');
160
+ // Bound EVERY request by the remaining deadline: race the fetch against the
161
+ // (injectable) sleep and abort the request when the deadline wins, so a
162
+ // stalled connection can never hang the runner past the advertised timeout.
163
+ const TIMED_OUT = Symbol('timed-out');
164
+ async function fetchWithDeadline(url, init) {
165
+ const remaining = deadline - now();
166
+ if (remaining <= 0)
167
+ throw timeoutError();
168
+ const controller = new AbortController();
169
+ const winner = await Promise.race([
170
+ fetchImpl(url, { ...init, signal: controller.signal }),
171
+ sleep(remaining).then(() => TIMED_OUT),
172
+ ]);
173
+ if (typeof winner === 'symbol') {
174
+ controller.abort();
175
+ throw timeoutError();
176
+ }
177
+ return winner;
178
+ }
179
+ const registerRes = await fetchWithDeadline(`${base}/api/vgs/agent-approval`, {
180
+ method: 'POST',
181
+ headers: { 'content-type': 'application/json' },
182
+ body: JSON.stringify({
183
+ challenge,
184
+ context: {
185
+ tokenId,
186
+ merchantName: target.merchantName,
187
+ merchantUrl: target.merchantUrl,
188
+ merchantCountryCode: target.merchantCountryCode,
189
+ amount: target.transactionAmount,
190
+ currency,
191
+ ...(consumerEmail ? { consumerEmail } : {}),
192
+ ...(budget ? { budget: true } : {}),
193
+ ...(budget ? { agentJkt } : {}),
194
+ ...(budget && maxDraws !== undefined ? { maxDraws } : {}),
195
+ ...(budget && perTransaction !== undefined ? { perTransaction } : {}),
196
+ ...(intentSanitized !== undefined ? { intent: intentSanitized } : {}),
197
+ },
198
+ }),
199
+ });
200
+ if (!registerRes.ok) {
201
+ const body = (await registerRes.json().catch(() => ({})));
202
+ throw new Error(`could not register the hosted approval (${registerRes.status})` +
203
+ (body.error ? `: ${body.error}` : ''));
204
+ }
205
+ // GA graduation: the approval page moved into apps/web under /agent/enroll
206
+ // (the /api/vgs/** routes above kept their paths verbatim).
207
+ const approveUrl = `${base}/agent/enroll/approve?req=${challenge}`;
208
+ // Surface the URL as DATA first (headless relay), then as a log line + best-
209
+ // effort browser open for the interactive case.
210
+ onApprovalUrl?.(approveUrl);
211
+ log(`approve the purchase in your browser: ${approveUrl}`);
212
+ if (budget && agentJkt) {
213
+ log(`budget recipient request key: ${agentJkt.slice(0, 8)}…${agentJkt.slice(-6)}`);
214
+ }
215
+ openUrl(approveUrl);
216
+ let lastHeartbeat = now();
217
+ // Keep the event loop alive for the whole poll wait. `defaultSleep` unref()'s
218
+ // its timer (so the deadline race can't hang the process past the timeout), but
219
+ // that means the ONLY pending work between polls is an unref'd timer — in a bare
220
+ // CLI invocation (no stdin/other handles) node would exit 0 mid-poll, after the
221
+ // first `pending` claim and before the operator finishes the passkey, so the
222
+ // minted token is parked but never claimed. A ref'd keepalive, cleared on every
223
+ // exit, holds the process open until the poll loop returns/throws.
224
+ const keepAlive = setInterval(() => { }, 60_000);
225
+ try {
226
+ for (;;) {
227
+ const res = await fetchWithDeadline(`${base}/api/vgs/agent-approval/claim`, {
228
+ method: 'POST',
229
+ headers: { 'content-type': 'application/json' },
230
+ body: JSON.stringify({ verifier }),
231
+ });
232
+ if (res.ok) {
233
+ const doc = (await res.json().catch(() => null));
234
+ // The operator refused — terminal and immediate. Exit the wait now
235
+ // rather than polling out the timeout; retrying cannot help.
236
+ if (doc?.status === 'declined')
237
+ throw new HostedApprovalDeclinedError();
238
+ if (doc?.status === 'completed') {
239
+ if (doc.assuranceData === undefined || doc.assuranceData === null) {
240
+ throw new Error('hosted approval completed but carried no assuranceData');
241
+ }
242
+ // The context the approval was COMPLETED against must be exactly this
243
+ // run's checkout target — a divergent field means the relay entry was
244
+ // not ours (corruption, or a mutated registration) and the assurance
245
+ // is scoped to something else. Fail naming the field; no secrets here
246
+ // (merchant facts only).
247
+ const expected = {
248
+ tokenId,
249
+ merchantName: target.merchantName,
250
+ merchantUrl: target.merchantUrl,
251
+ merchantCountryCode: target.merchantCountryCode,
252
+ amount: target.transactionAmount,
253
+ currency,
254
+ };
255
+ for (const [field, want] of Object.entries(expected)) {
256
+ if (doc.context?.[field] !== want) {
257
+ throw new Error(`hosted approval context mismatch on ${field} — the approval was not for this ` +
258
+ 'exact purchase; run the checkout again for a fresh link');
259
+ }
260
+ }
261
+ // A budget approval must complete AS a budget approval — a relay that
262
+ // dropped the flag would mint a single-purchase token that then rejects
263
+ // the first sub-ceiling draw. Compare the boolean explicitly (it is not a
264
+ // string, so it lives outside the string-map loop above).
265
+ if (Boolean(doc.context?.budget) !== Boolean(budget)) {
266
+ throw new Error('hosted approval context mismatch on budget — the approval was not for this ' +
267
+ 'exact purchase; run the checkout again for a fresh link');
268
+ }
269
+ if (budget && doc.context?.agentJkt !== agentJkt) {
270
+ throw new Error('hosted approval context mismatch on agentJkt — the approval was not for this ' +
271
+ 'exact request key; run the checkout again for a fresh link');
272
+ }
273
+ if (!budget && doc.context?.agentJkt !== undefined) {
274
+ throw new Error('hosted approval unexpectedly carried an agentJkt for a one-purchase request');
275
+ }
276
+ // The advisory draw ceiling must survive the relay intact too — a relay
277
+ // that dropped or altered maxDraws would mint a budget token whose draw
278
+ // count no longer matches what the operator approved. Only the budget
279
+ // path registers maxDraws, so only enforce it there; compare exactly,
280
+ // the omitted/undefined case included, so a silently dropped value is
281
+ // caught the same as a mutated one.
282
+ if (budget && doc.context?.maxDraws !== maxDraws) {
283
+ throw new Error('hosted approval context mismatch on maxDraws — the approval was not for this ' +
284
+ 'exact purchase; run the checkout again for a fresh link');
285
+ }
286
+ // Same drop-or-mutate rule for the per-purchase cap: the term the
287
+ // operator read must be the term the token enforces.
288
+ if (budget && doc.context?.perTransaction !== perTransaction) {
289
+ throw new Error('hosted approval context mismatch on perTransaction — the approval was not for ' +
290
+ 'this exact purchase; run the checkout again for a fresh link');
291
+ }
292
+ // The intent is display-only, but a relay that altered it showed the
293
+ // operator different words than the agent sent — refuse, either way.
294
+ if (doc.context?.intent !== intentSanitized) {
295
+ throw new Error('hosted approval context mismatch on intent — the approval was not for this ' +
296
+ 'exact purchase; run the checkout again for a fresh link');
297
+ }
298
+ if (budget) {
299
+ if (!Number.isSafeInteger(doc.validUntil) ||
300
+ doc.validUntil <= Math.floor(now() / 1000)) {
301
+ throw new Error('hosted budget approval completed without a valid future expiry');
302
+ }
303
+ }
304
+ else if (doc.validUntil !== undefined) {
305
+ throw new Error('hosted one-purchase approval unexpectedly carried a budget expiry');
306
+ }
307
+ log('passkey approval received from the hosted page.');
308
+ return {
309
+ ...assuranceFromCeremony(target, doc.assuranceData),
310
+ ...(typeof doc.mintToken === 'string' && doc.mintToken
311
+ ? { mintToken: doc.mintToken }
312
+ : {}),
313
+ ...(budget ? { validUntil: doc.validUntil } : {}),
314
+ };
315
+ }
316
+ // status 'pending' — the operator is still signing in / tapping.
317
+ }
318
+ else if (res.status === 404) {
319
+ // Registered moments ago, so absent now means expired or already claimed.
320
+ throw new Error('the hosted approval expired or was already used — run the checkout again for a fresh link');
321
+ }
322
+ // Any other status (429 rate bucket, transient 5xx) polls through.
323
+ if (now() >= deadline)
324
+ throw timeoutError();
325
+ // Heartbeat so a human staring at a terminal (or an agent tailing logs)
326
+ // knows the wait is live and where to approve — the loop is otherwise silent
327
+ // for up to the full timeout between the open and the completed claim.
328
+ if (now() - lastHeartbeat >= HOSTED_APPROVAL_HEARTBEAT_MS) {
329
+ lastHeartbeat = now();
330
+ const leftS = Math.max(0, Math.round((deadline - now()) / 1000));
331
+ log(`still waiting for approval (~${leftS}s left) — approve at ${approveUrl}`);
332
+ }
333
+ await sleep(pollIntervalMs);
334
+ }
335
+ }
336
+ finally {
337
+ clearInterval(keepAlive);
338
+ }
339
+ }
@@ -0,0 +1,6 @@
1
+ export { createCliCheckoutEngine, type CliReviewInput, type CliReviewFacts, type CliPayInput, type CliReceiptFacts, type CliStartMandateInput, type CliMandateFacts, type CliEngineDeps, } from './cli-engine.js';
2
+ export { prepareCheckout, submitApprovedCheckout, runCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
3
+ export type { CheckoutResult, CheckoutReview, CheckoutOutcome } from './executor.js';
4
+ export { HostedApprovalDeclinedError, sanitizeApprovalIntent, APPROVAL_INTENT_MAX_CHARS, } from './hosted-approval.js';
5
+ export { createCardMandate, drawFromMandate, MandateDrawDeclinedError, DEFAULT_MANDATE_MAX_DRAWS, type CreateCardMandateInput, type CreateCardMandateDeps, type CardMandateFacts, type DrawFromMandateInput, type DrawFromMandateDeps, type DrawResult, type CardMandateMerchant, } from './mandate/card-mandate.js';
6
+ export { MandateLedger, remainingMinor, defaultLedgerPath, CARD_MANDATE_LEDGER_VERSION, type CardMandateRecord, type CardMandateLedgerFile, type CardMandateDraw, type CardMandateReservation, } from './mandate/mandate-ledger.js';
@@ -0,0 +1,8 @@
1
+ // Public API of @visa/checkout-engine. The pay_merchant tool in @visa/cli
2
+ // consumes createCliCheckoutEngine() through a structural seam; the core engine
3
+ // primitives are re-exported for direct/embedded use.
4
+ export { createCliCheckoutEngine, } from './cli-engine.js';
5
+ export { prepareCheckout, submitApprovedCheckout, runCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
6
+ export { HostedApprovalDeclinedError, sanitizeApprovalIntent, APPROVAL_INTENT_MAX_CHARS, } from './hosted-approval.js';
7
+ export { createCardMandate, drawFromMandate, MandateDrawDeclinedError, DEFAULT_MANDATE_MAX_DRAWS, } from './mandate/card-mandate.js';
8
+ export { MandateLedger, remainingMinor, defaultLedgerPath, CARD_MANDATE_LEDGER_VERSION, } from './mandate/mandate-ledger.js';
@@ -0,0 +1,13 @@
1
+ import type { VgsCheckoutTarget } from './vgs-live-instrument.js';
2
+ /**
3
+ * Build a checkout target from inline CLI flags (`--merchant-url` +
4
+ * `--amount`, with optional `--merchant-name` / `--country` / `--currency`)
5
+ * so any merchant can be paid without authoring a checkout JSON file first.
6
+ *
7
+ * Returns null when neither driving flag is present (file mode). Shape-only:
8
+ * the returned target flows through run-live-fill's existing validation block
9
+ * (positive-decimal amount, ISO codes, HTTPS), which stays the single source
10
+ * of truth — nothing is double-validated here except the URL parse, which
11
+ * must happen early because the merchant-name default derives from it.
12
+ */
13
+ export declare function inlineTargetFromFlags(input: Map<string, string>): VgsCheckoutTarget | null;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Build a checkout target from inline CLI flags (`--merchant-url` +
3
+ * `--amount`, with optional `--merchant-name` / `--country` / `--currency`)
4
+ * so any merchant can be paid without authoring a checkout JSON file first.
5
+ *
6
+ * Returns null when neither driving flag is present (file mode). Shape-only:
7
+ * the returned target flows through run-live-fill's existing validation block
8
+ * (positive-decimal amount, ISO codes, HTTPS), which stays the single source
9
+ * of truth — nothing is double-validated here except the URL parse, which
10
+ * must happen early because the merchant-name default derives from it.
11
+ */
12
+ export function inlineTargetFromFlags(input) {
13
+ const merchantUrl = input.get('--merchant-url');
14
+ const amount = input.get('--amount');
15
+ if (merchantUrl === undefined && amount === undefined)
16
+ return null;
17
+ if (!merchantUrl || !amount) {
18
+ throw new Error('inline checkout needs both --merchant-url and --amount');
19
+ }
20
+ let host;
21
+ try {
22
+ host = new URL(merchantUrl).hostname;
23
+ }
24
+ catch {
25
+ throw new Error('--merchant-url must be a valid URL');
26
+ }
27
+ if (!host) {
28
+ throw new Error('--merchant-url must have a hostname');
29
+ }
30
+ return {
31
+ merchantName: input.get('--merchant-name') ?? host,
32
+ merchantUrl,
33
+ merchantCountryCode: input.get('--country') ?? 'US',
34
+ transactionAmount: amount,
35
+ transactionCurrencyCode: input.get('--currency') ?? 'USD',
36
+ };
37
+ }
@@ -0,0 +1,61 @@
1
+ export type CardCredential = {
2
+ pan: string;
3
+ expMonth: number;
4
+ expYear: number;
5
+ cvc: string;
6
+ cardholderName: string;
7
+ /** Dynamic payment credential expiry, distinct from the card expiration. */
8
+ credentialExpiresAt?: string;
9
+ /**
10
+ * Non-sensitive, bounded request-correlation handles returned by the mint
11
+ * route. These are evidence pointers, not card credential material.
12
+ */
13
+ vgsTraceId?: string;
14
+ networkCorrelationId?: string;
15
+ };
16
+ export type InstrumentContext = {
17
+ merchantHost: string;
18
+ amountMinor: number;
19
+ currency: string;
20
+ };
21
+ export type InstrumentKind = 'test-card' | 'vgs-alias' | 'agentic-token';
22
+ export interface Instrument {
23
+ kind: InstrumentKind;
24
+ getCredential(ctx: InstrumentContext): Promise<CardCredential>;
25
+ }
26
+ export declare const OFFICIAL_TEST_PANS: {
27
+ readonly visa: "4242424242424242";
28
+ readonly visaAlt: "4111111111111111";
29
+ readonly visaDecline: "4000000000000002";
30
+ readonly visaSlowConfirm: "4000000000000069";
31
+ readonly visaUnknownOutcome: "4000000000000044";
32
+ readonly visaChallenge: "4000000000000010";
33
+ readonly visaEmailOtp: "4000000000000077";
34
+ };
35
+ export type TestCardOptions = {
36
+ pan?: string;
37
+ cardholderName?: string;
38
+ cvc?: string;
39
+ expMonth?: number;
40
+ expYear?: number;
41
+ };
42
+ export declare class TestCardInstrument implements Instrument {
43
+ readonly kind: "test-card";
44
+ private readonly credential;
45
+ constructor(opts?: TestCardOptions);
46
+ getCredential(_ctx: InstrumentContext): Promise<CardCredential>;
47
+ }
48
+ export declare class VgsAliasInstrument implements Instrument {
49
+ private readonly _alias;
50
+ readonly kind: "vgs-alias";
51
+ constructor(_alias: string);
52
+ getCredential(_ctx: InstrumentContext): Promise<CardCredential>;
53
+ }
54
+ export declare class AgenticTokenInstrument implements Instrument {
55
+ private readonly tokenRef;
56
+ private readonly mintCredential?;
57
+ readonly kind: "agentic-token";
58
+ private used;
59
+ constructor(tokenRef: string, mintCredential?: ((ctx: InstrumentContext) => Promise<CardCredential>) | undefined);
60
+ getCredential(ctx: InstrumentContext): Promise<CardCredential>;
61
+ }