@visa/cli 4.1.0-rc.25 → 4.1.0-rc.27

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 (40) hide show
  1. package/README.md +132 -242
  2. package/dist/checkout-engine/cli-engine.d.ts +142 -0
  3. package/dist/checkout-engine/cli-engine.js +377 -35
  4. package/dist/checkout-engine/detect.d.ts +1 -1
  5. package/dist/checkout-engine/detect.js +20 -0
  6. package/dist/checkout-engine/evidence.d.ts +3 -0
  7. package/dist/checkout-engine/evidence.js +51 -6
  8. package/dist/checkout-engine/executor.d.ts +3 -1
  9. package/dist/checkout-engine/executor.js +75 -2
  10. package/dist/checkout-engine/hosted-approval.d.ts +64 -7
  11. package/dist/checkout-engine/hosted-approval.js +194 -54
  12. package/dist/checkout-engine/index.d.ts +4 -1
  13. package/dist/checkout-engine/index.js +3 -0
  14. package/dist/checkout-engine/instrument.d.ts +1 -0
  15. package/dist/checkout-engine/instrument.js +4 -0
  16. package/dist/checkout-engine/live-fill-approval.d.ts +0 -9
  17. package/dist/checkout-engine/live-fill-approval.js +0 -17
  18. package/dist/checkout-engine/mandate/card-mandate.d.ts +117 -0
  19. package/dist/checkout-engine/mandate/card-mandate.js +221 -0
  20. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +135 -0
  21. package/dist/checkout-engine/mandate/mandate-ledger.js +318 -0
  22. package/dist/checkout-engine/outcome.d.ts +2 -2
  23. package/dist/checkout-engine/outcome.js +36 -1
  24. package/dist/checkout-engine/owner-only-file.d.ts +9 -0
  25. package/dist/checkout-engine/owner-only-file.js +20 -1
  26. package/dist/checkout-engine/run-live-fill.js +151 -101
  27. package/dist/checkout-engine/types.d.ts +13 -0
  28. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +34 -1
  29. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +35 -7
  30. package/dist/checkout-engine/vgs-live-instrument.d.ts +27 -0
  31. package/dist/checkout-engine/vgs-live-instrument.js +37 -0
  32. package/dist/cli.js +268 -385
  33. package/dist/mcp-server/index.js +249 -159
  34. package/dist/skills/pair-visa-agent/RUNTIMES.md +1 -1
  35. package/dist/skills/pair-visa-agent/SKILL.md +89 -47
  36. package/install.ps1 +3 -41
  37. package/install.sh +3 -35
  38. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  39. package/package.json +5 -4
  40. package/server.json +3 -3
@@ -2,7 +2,7 @@ import type { Browser, BrowserContext, Page } from 'playwright-core';
2
2
  import { type FieldMap } from './detect.js';
3
3
  import { type Mandate } from './mandate.js';
4
4
  import type { Instrument } from './instrument.js';
5
- import type { Contact } from './types.js';
5
+ import type { Contact, OtpResolver } from './types.js';
6
6
  import { EvidenceLog } from './evidence.js';
7
7
  import { type ObservedOutcome } from './outcome.js';
8
8
  export type CheckoutMode = 'dry-run' | 'submit';
@@ -37,6 +37,7 @@ export type RunCheckoutOptions = PrepareCheckoutOptions & {
37
37
  contact: Contact;
38
38
  mode: CheckoutMode;
39
39
  outcomeDeadlineMs?: number;
40
+ resolveEmailOtp?: OtpResolver;
40
41
  };
41
42
  export type CheckoutReview = {
42
43
  id: string;
@@ -86,6 +87,7 @@ export type SubmitApprovedCheckoutOptions = {
86
87
  outcomeDeadlineMs?: number;
87
88
  challengeHoldMs?: number;
88
89
  onChallengeHold?: (signal: string | null) => void;
90
+ resolveEmailOtp?: OtpResolver;
89
91
  };
90
92
  export declare function minorFromDecimal(text: string): number | null;
91
93
  export declare function pageCurrency(text: string): string | null;
@@ -18,7 +18,7 @@ import { mkdir } from 'node:fs/promises';
18
18
  import { join } from 'node:path';
19
19
  import { detectFields } from './detect.js';
20
20
  import { checkMandate, checkMandatePreFill } from './mandate.js';
21
- import { EvidenceLog } from './evidence.js';
21
+ import { EvidenceLog, maskOtp } from './evidence.js';
22
22
  import { observeOutcome } from './outcome.js';
23
23
  import { selectAdapter } from './adapters/index.js';
24
24
  const SUBMIT_TEXT = /pay|place order|complete|buy|submit|checkout/i;
@@ -1118,6 +1118,13 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1118
1118
  evidence.setSnapshotSummary(await snapshotSummary(page));
1119
1119
  return makeResult('failed', state.fields, evidence, requiresAdapter, 'no submit control detected');
1120
1120
  }
1121
+ // Capture the OTP poll watermark BEFORE the click. The click is what
1122
+ // triggers the merchant's verification email, so the watermark must precede
1123
+ // it — otherwise a fast OTP could arrive before we start looking (the 5s
1124
+ // waitForMessage skew is a second line of defence, but ordering matters).
1125
+ // This is just a timestamp — no PII — so it is safe to record.
1126
+ const otpWatermark = new Date().toISOString();
1127
+ evidence.step('note', { otpWatermarkCaptured: true });
1121
1128
  // The suppressed Link lookup must settle before the click or it breaks
1122
1129
  // Stripe's submit chain mid-flight (#5879) — see waitForLinkLookupQuiet.
1123
1130
  const linkQuiet = await waitForLinkLookupQuiet(page);
@@ -1142,6 +1149,57 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1142
1149
  });
1143
1150
  observed = reconcileHeldOutcome(observed, held);
1144
1151
  }
1152
+ // Agent-resolvable email OTP subroutine (SINGLE-USE). Fires only on a
1153
+ // 'verification-required' verdict (the merchant emailed a code to the
1154
+ // agent's own inbox) AND when a resolver is injected. Fills the code EXACTLY
1155
+ // ONCE, re-submits, and re-observes. Merchants invalidate a code on first
1156
+ // use, so a stale code is NEVER retried. On no resolver / timeout / missing
1157
+ // code field it falls through to the action-required (human) path below —
1158
+ // it never hangs and never re-fills credential material.
1159
+ if (observed.status === 'verification-required' && opts.resolveEmailOtp) {
1160
+ const otpDetect = await detectFields(page);
1161
+ const codeField = otpDetect.fields.oneTimeCode;
1162
+ if (!codeField) {
1163
+ evidence.step('note', { emailOtp: 'no one-time-code field detected' });
1164
+ }
1165
+ else {
1166
+ const resolution = await opts.resolveEmailOtp({
1167
+ after: otpWatermark,
1168
+ merchantHost: submitMerchantHost,
1169
+ });
1170
+ if (!resolution) {
1171
+ // Fail CLEAN: no code retrieved before the resolver's timeout.
1172
+ evidence.step('note', { emailOtp: 'not retrieved before timeout' });
1173
+ }
1174
+ else {
1175
+ // Fill once. maskOtp() ensures the code NEVER enters the evidence log
1176
+ // (receipt.ts's PAN backstop does not catch a 4-8 digit OTP). The
1177
+ // sender domain is a non-PII trust signal, safe to record.
1178
+ await page.locator(codeField.locator).fill(resolution.code);
1179
+ evidence.step('field-fill', {
1180
+ role: 'oneTimeCode',
1181
+ confidence: codeField.confidence,
1182
+ source: codeField.source,
1183
+ frame: codeField.frame,
1184
+ value: maskOtp(),
1185
+ ok: true,
1186
+ fromDomain: resolution.fromDomain,
1187
+ });
1188
+ const otpSubmit = await findSubmit(page);
1189
+ if (!otpSubmit) {
1190
+ evidence.step('note', { emailOtp: 'code filled but no submit control found' });
1191
+ }
1192
+ else {
1193
+ const otpLinkQuiet = await waitForLinkLookupQuiet(page);
1194
+ evidence.step('note', { linkQuiet: otpLinkQuiet, phase: 'post-otp' });
1195
+ evidence.step('submit', { clicked: true, target: otpSubmit.desc, phase: 'post-otp' });
1196
+ await otpSubmit.click();
1197
+ await settle(page);
1198
+ observed = await observeOutcome(page, { deadlineMs: opts.outcomeDeadlineMs });
1199
+ }
1200
+ }
1201
+ }
1202
+ }
1145
1203
  evidence.setSnapshotSummary(await snapshotSummary(page));
1146
1204
  if (options.debugShotsDir) {
1147
1205
  await captureDebugShot(page, options.debugShotsDir, checkout.review.id, '3-outcome', evidence, state.fields);
@@ -1167,6 +1225,20 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1167
1225
  });
1168
1226
  return makeResult('action-required', state.fields, evidence, requiresAdapter, `issuer verification required (${observed.signal}) — a human must complete the challenge; no charge exists until it is completed`);
1169
1227
  }
1228
+ if (observed.status === 'verification-required') {
1229
+ // Still needing an email code after the subroutine (no resolver injected,
1230
+ // the code never arrived, or no code field) — hand off to a human. Mapped
1231
+ // to the same action-required outcome; the code is single-use so we never
1232
+ // retry here.
1233
+ evidence.step('outcome', {
1234
+ outcome: 'action-required',
1235
+ signal: observed.signal,
1236
+ reason: 'email verification code required but not auto-resolved',
1237
+ attempts: observed.attempts,
1238
+ elapsedMs: observed.elapsedMs,
1239
+ });
1240
+ return makeResult('action-required', state.fields, evidence, requiresAdapter, `email verification required (${observed.signal}) — a human must enter the code sent to the inbox`);
1241
+ }
1170
1242
  if (observed.status === 'confirmed') {
1171
1243
  const confirmationRef = await readConfirmationRef(page);
1172
1244
  evidence.step('outcome', {
@@ -1219,7 +1291,7 @@ export async function cancelPreparedCheckout(reviewId, detail = 'checkout cancel
1219
1291
  // approves that exact review ID. Human-in-the-loop callers should use the
1220
1292
  // explicit prepareCheckout()/submitApprovedCheckout() pair instead.
1221
1293
  export async function runCheckout(opts, store = defaultPreparedCheckoutStore) {
1222
- const { instrument, contact, mode, outcomeDeadlineMs, ...prepareOptions } = opts;
1294
+ const { instrument, contact, mode, outcomeDeadlineMs, resolveEmailOtp, ...prepareOptions } = opts;
1223
1295
  const preparation = await prepareCheckout(prepareOptions, store);
1224
1296
  if (preparation.status === 'finished')
1225
1297
  return preparation.result;
@@ -1229,5 +1301,6 @@ export async function runCheckout(opts, store = defaultPreparedCheckoutStore) {
1229
1301
  contact,
1230
1302
  mode,
1231
1303
  outcomeDeadlineMs,
1304
+ ...(resolveEmailOtp ? { resolveEmailOtp } : {}),
1232
1305
  }, store);
1233
1306
  }
@@ -26,15 +26,44 @@ import type { PurchaseAssurance, VgsCheckoutTarget } from './vgs-live-instrument
26
26
  * hang the runner past the advertised timeout.
27
27
  */
28
28
  export type HostedApprovalOptions = {
29
- /** The verify-web deployment origin, e.g. https://v4-verify-web-….up.railway.app */
29
+ /** The enrollment web deployment origin (apps/web), e.g. https://web-….up.railway.app */
30
30
  baseUrl: string;
31
31
  /** Durable agentic token id from the CLI agent credential. */
32
32
  tokenId: string;
33
33
  target: VgsCheckoutTarget;
34
34
  /** Prefills the approval page's enrollment-email field (optional). */
35
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
+ /** Advisory max draws the ceiling intent may fulfil — carried onto the token. */
43
+ maxDraws?: number;
44
+ /**
45
+ * Per-purchase cap (decimal string, <= the ceiling) on a BUDGET approval —
46
+ * shown on the approval page as a worst-case term and carried onto the mint
47
+ * token so the displayed cap is the enforced cap. Budget mode only.
48
+ */
49
+ perTransaction?: string;
50
+ /**
51
+ * Agent-supplied one-liner describing what this approval is for. Sanitized
52
+ * here (control chars stripped, trimmed, capped) and rendered by the approval
53
+ * page in a clearly-labeled "written by the agent" block — provenance for the
54
+ * human to cross-check, never a trusted field.
55
+ */
56
+ intent?: string;
36
57
  log?: (line: string) => void;
37
- /** Injectable for tests; default opens the operator's default browser (macOS). */
58
+ /**
59
+ * Called once with the approval URL as DATA (not a log line) the instant it is
60
+ * known — before any polling. A headless agent (OpenClaw/Hermes) has no browser
61
+ * and no passkey; its whole job is to relay this URL to its human operator, so
62
+ * the URL must be capturable as a value, not just printed. The interactive CLI
63
+ * leaves this unset and relies on `log` + `openUrl`.
64
+ */
65
+ onApprovalUrl?: (url: string) => void;
66
+ /** Injectable for tests; default opens the operator's default browser, best-effort. */
38
67
  openUrl?: (url: string) => void;
39
68
  fetchImpl?: typeof fetch;
40
69
  /** Injectable for tests — never wall-clock-sleep in a unit test. */
@@ -43,15 +72,43 @@ export type HostedApprovalOptions = {
43
72
  pollIntervalMs?: number;
44
73
  timeoutMs?: number;
45
74
  };
75
+ /** One short sentence — must match the relay's cap (agent-approval.ts). */
76
+ export declare const APPROVAL_INTENT_MAX_CHARS = 200;
77
+ /**
78
+ * Sanitize an agent-supplied intent to what the relay will accept and the page
79
+ * will display: control chars (and JS line separators) stripped, trimmed,
80
+ * TRUNCATED to the cap (the runner is the agent's own side, so truncating here
81
+ * beats a failed registration; the server still rejects an over-cap value).
82
+ * Returns undefined when nothing displayable remains.
83
+ */
84
+ export declare function sanitizeApprovalIntent(value: string | undefined): string | undefined;
85
+ /**
86
+ * The operator saw the request and said NO. Terminal and non-retryable: the
87
+ * relay entry is consumed, re-running would only re-ask a human who already
88
+ * refused. Callers must not classify this as transient.
89
+ */
90
+ export declare class HostedApprovalDeclinedError extends Error {
91
+ constructor();
92
+ }
46
93
  export declare const HOSTED_APPROVAL_TIMEOUT_MS: number;
47
94
  export declare const HOSTED_APPROVAL_POLL_MS = 3000;
95
+ /** Emit a "still waiting" heartbeat roughly every this-many ms during the poll. */
96
+ export declare const HOSTED_APPROVAL_HEARTBEAT_MS = 30000;
97
+ /**
98
+ * Resolve the approval timeout: `CHECKOUT_APPROVAL_TIMEOUT_MS` (seconds*1000, an
99
+ * integer ms) overrides the default when it parses to a positive integer.
100
+ * Lets an operator widen the window for separate-device / headless approval
101
+ * without threading a flag through every caller. Invalid values fall back.
102
+ */
103
+ export declare function resolveApprovalTimeoutMs(): number;
48
104
  /**
49
- * The one deployed verify site hosted approval is the NORMAL path (no local
50
- * certs, no vendored SDK, no localhost anywhere), so its origin is a built-in
51
- * default rather than per-machine config. Not a secret: it's the public page
52
- * the operator's browser opens anyway.
105
+ * The staging apps/web deploy (which now hosts the /agent/enroll/approve page
106
+ * after the v4-verify-web GA graduation) hosted approval is the NORMAL path
107
+ * (no local certs, no vendored SDK, no localhost anywhere), so its origin is a
108
+ * built-in default rather than per-machine config. Not a secret: it's the
109
+ * public page the operator's browser opens anyway.
53
110
  */
54
- export declare const DEFAULT_APPROVAL_BASE_URL = "https://v4-verify-web-visa-code-preview.up.railway.app";
111
+ export declare const DEFAULT_APPROVAL_BASE_URL = "https://web-visa-code-preview.up.railway.app";
55
112
  /**
56
113
  * Flag > environment > built-in default. An explicitly EMPTY value
57
114
  * (CHECKOUT_APPROVAL_BASE_URL='') opts out of hosted approval entirely —
@@ -1,25 +1,89 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import { exec } from 'node:child_process';
3
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
+ }
4
34
  export const HOSTED_APPROVAL_TIMEOUT_MS = 4 * 60 * 1000;
5
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
+ }
6
53
  /** Unref'd so a raced-and-abandoned deadline timer can never hold the process open. */
7
54
  const defaultSleep = (ms) => new Promise((r) => {
8
55
  const t = setTimeout(r, ms);
9
56
  t.unref?.();
10
57
  });
11
58
  function defaultOpenUrl(url) {
12
- // Quoting: the URL is server-origin + our own base64url challenge — no
13
- // shell-hostile characters but single-quote anyway.
14
- exec(`open '${url.replaceAll("'", "'\\''")}'`);
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
+ }
15
78
  }
16
79
  /**
17
- * The one deployed verify site hosted approval is the NORMAL path (no local
18
- * certs, no vendored SDK, no localhost anywhere), so its origin is a built-in
19
- * default rather than per-machine config. Not a secret: it's the public page
20
- * the operator's browser opens anyway.
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.
21
85
  */
22
- export const DEFAULT_APPROVAL_BASE_URL = 'https://v4-verify-web-visa-code-preview.up.railway.app';
86
+ export const DEFAULT_APPROVAL_BASE_URL = 'https://web-visa-code-preview.up.railway.app';
23
87
  /**
24
88
  * Flag > environment > built-in default. An explicitly EMPTY value
25
89
  * (CHECKOUT_APPROVAL_BASE_URL='') opts out of hosted approval entirely —
@@ -64,12 +128,23 @@ function stripTrailingSlashes(value) {
64
128
  * rather than falling back to a client-held secret.
65
129
  */
66
130
  export async function runHostedApproval(opts) {
67
- const { baseUrl, tokenId, target, consumerEmail, log = () => { }, openUrl = defaultOpenUrl, fetchImpl = fetch, sleep = defaultSleep, now = Date.now, pollIntervalMs = HOSTED_APPROVAL_POLL_MS, timeoutMs = HOSTED_APPROVAL_TIMEOUT_MS, } = opts;
131
+ const { baseUrl, tokenId, target, consumerEmail, budget, 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;
68
140
  const base = stripTrailingSlashes(assertApprovalBaseUrl(baseUrl));
69
141
  // The server's currency map is uppercase ISO 4217; the loader normalizes the
70
142
  // target once, but normalize here too so a direct caller with a lowercase
71
143
  // code registers (and verifies) the same value the relay stores.
72
144
  const currency = target.transactionCurrencyCode.toUpperCase();
145
+ // Sanitize once, register + verify the SAME value: the relay stores the
146
+ // sanitized form, so equality below must compare against it, not the raw input.
147
+ const intentSanitized = sanitizeApprovalIntent(intent);
73
148
  const verifier = randomBytes(32).toString('base64url');
74
149
  const challenge = createHash('sha256').update(verifier, 'utf8').digest('base64url');
75
150
  const deadline = now() + timeoutMs;
@@ -107,6 +182,10 @@ export async function runHostedApproval(opts) {
107
182
  amount: target.transactionAmount,
108
183
  currency,
109
184
  ...(consumerEmail ? { consumerEmail } : {}),
185
+ ...(budget ? { budget: true } : {}),
186
+ ...(budget && maxDraws !== undefined ? { maxDraws } : {}),
187
+ ...(budget && perTransaction !== undefined ? { perTransaction } : {}),
188
+ ...(intentSanitized !== undefined ? { intent: intentSanitized } : {}),
110
189
  },
111
190
  }),
112
191
  });
@@ -115,57 +194,118 @@ export async function runHostedApproval(opts) {
115
194
  throw new Error(`could not register the hosted approval (${registerRes.status})` +
116
195
  (body.error ? `: ${body.error}` : ''));
117
196
  }
118
- const approveUrl = `${base}/approve?req=${challenge}`;
197
+ // GA graduation: the approval page moved into apps/web under /agent/enroll
198
+ // (the /api/vgs/** routes above kept their paths verbatim).
199
+ const approveUrl = `${base}/agent/enroll/approve?req=${challenge}`;
200
+ // Surface the URL as DATA first (headless relay), then as a log line + best-
201
+ // effort browser open for the interactive case.
202
+ onApprovalUrl?.(approveUrl);
119
203
  log(`approve the purchase in your browser: ${approveUrl}`);
120
204
  openUrl(approveUrl);
121
- for (;;) {
122
- const res = await fetchWithDeadline(`${base}/api/vgs/agent-approval/claim`, {
123
- method: 'POST',
124
- headers: { 'content-type': 'application/json' },
125
- body: JSON.stringify({ verifier }),
126
- });
127
- if (res.ok) {
128
- const doc = (await res.json().catch(() => null));
129
- if (doc?.status === 'completed') {
130
- if (doc.assuranceData === undefined || doc.assuranceData === null) {
131
- throw new Error('hosted approval completed but carried no assuranceData');
132
- }
133
- // The context the approval was COMPLETED against must be exactly this
134
- // run's checkout target — a divergent field means the relay entry was
135
- // not ours (corruption, or a mutated registration) and the assurance
136
- // is scoped to something else. Fail naming the field; no secrets here
137
- // (merchant facts only).
138
- const expected = {
139
- tokenId,
140
- merchantName: target.merchantName,
141
- merchantUrl: target.merchantUrl,
142
- merchantCountryCode: target.merchantCountryCode,
143
- amount: target.transactionAmount,
144
- currency,
145
- };
146
- for (const [field, want] of Object.entries(expected)) {
147
- if (doc.context?.[field] !== want) {
148
- throw new Error(`hosted approval context mismatch on ${field} the approval was not for this ` +
205
+ let lastHeartbeat = now();
206
+ // Keep the event loop alive for the whole poll wait. `defaultSleep` unref()'s
207
+ // its timer (so the deadline race can't hang the process past the timeout), but
208
+ // that means the ONLY pending work between polls is an unref'd timer — in a bare
209
+ // CLI invocation (no stdin/other handles) node would exit 0 mid-poll, after the
210
+ // first `pending` claim and before the operator finishes the passkey, so the
211
+ // minted token is parked but never claimed. A ref'd keepalive, cleared on every
212
+ // exit, holds the process open until the poll loop returns/throws.
213
+ const keepAlive = setInterval(() => { }, 60_000);
214
+ try {
215
+ for (;;) {
216
+ const res = await fetchWithDeadline(`${base}/api/vgs/agent-approval/claim`, {
217
+ method: 'POST',
218
+ headers: { 'content-type': 'application/json' },
219
+ body: JSON.stringify({ verifier }),
220
+ });
221
+ if (res.ok) {
222
+ const doc = (await res.json().catch(() => null));
223
+ // The operator refused — terminal and immediate. Exit the wait now
224
+ // rather than polling out the timeout; retrying cannot help.
225
+ if (doc?.status === 'declined')
226
+ throw new HostedApprovalDeclinedError();
227
+ if (doc?.status === 'completed') {
228
+ if (doc.assuranceData === undefined || doc.assuranceData === null) {
229
+ throw new Error('hosted approval completed but carried no assuranceData');
230
+ }
231
+ // The context the approval was COMPLETED against must be exactly this
232
+ // run's checkout target a divergent field means the relay entry was
233
+ // not ours (corruption, or a mutated registration) and the assurance
234
+ // is scoped to something else. Fail naming the field; no secrets here
235
+ // (merchant facts only).
236
+ const expected = {
237
+ tokenId,
238
+ merchantName: target.merchantName,
239
+ merchantUrl: target.merchantUrl,
240
+ merchantCountryCode: target.merchantCountryCode,
241
+ amount: target.transactionAmount,
242
+ currency,
243
+ };
244
+ for (const [field, want] of Object.entries(expected)) {
245
+ if (doc.context?.[field] !== want) {
246
+ throw new Error(`hosted approval context mismatch on ${field} — the approval was not for this ` +
247
+ 'exact purchase; run the checkout again for a fresh link');
248
+ }
249
+ }
250
+ // A budget approval must complete AS a budget approval — a relay that
251
+ // dropped the flag would mint a single-purchase token that then rejects
252
+ // the first sub-ceiling draw. Compare the boolean explicitly (it is not a
253
+ // string, so it lives outside the string-map loop above).
254
+ if (Boolean(doc.context?.budget) !== Boolean(budget)) {
255
+ throw new Error('hosted approval context mismatch on budget — the approval was not for this ' +
256
+ 'exact purchase; run the checkout again for a fresh link');
257
+ }
258
+ // The advisory draw ceiling must survive the relay intact too — a relay
259
+ // that dropped or altered maxDraws would mint a budget token whose draw
260
+ // count no longer matches what the operator approved. Only the budget
261
+ // path registers maxDraws, so only enforce it there; compare exactly,
262
+ // the omitted/undefined case included, so a silently dropped value is
263
+ // caught the same as a mutated one.
264
+ if (budget && doc.context?.maxDraws !== maxDraws) {
265
+ throw new Error('hosted approval context mismatch on maxDraws — the approval was not for this ' +
266
+ 'exact purchase; run the checkout again for a fresh link');
267
+ }
268
+ // Same drop-or-mutate rule for the per-purchase cap: the term the
269
+ // operator read must be the term the token enforces.
270
+ if (budget && doc.context?.perTransaction !== perTransaction) {
271
+ throw new Error('hosted approval context mismatch on perTransaction — the approval was not for ' +
272
+ 'this exact purchase; run the checkout again for a fresh link');
273
+ }
274
+ // The intent is display-only, but a relay that altered it showed the
275
+ // operator different words than the agent sent — refuse, either way.
276
+ if (doc.context?.intent !== intentSanitized) {
277
+ throw new Error('hosted approval context mismatch on intent — the approval was not for this ' +
149
278
  'exact purchase; run the checkout again for a fresh link');
150
279
  }
280
+ log('passkey approval received from the hosted page.');
281
+ return {
282
+ ...assuranceFromCeremony(target, doc.assuranceData),
283
+ ...(typeof doc.mintToken === 'string' && doc.mintToken
284
+ ? { mintToken: doc.mintToken }
285
+ : {}),
286
+ };
151
287
  }
152
- log('passkey approval received from the hosted page.');
153
- return {
154
- ...assuranceFromCeremony(target, doc.assuranceData),
155
- ...(typeof doc.mintToken === 'string' && doc.mintToken
156
- ? { mintToken: doc.mintToken }
157
- : {}),
158
- };
288
+ // status 'pending' the operator is still signing in / tapping.
159
289
  }
160
- // status 'pending' the operator is still signing in / tapping.
161
- }
162
- else if (res.status === 404) {
163
- // Registered moments ago, so absent now means expired or already claimed.
164
- throw new Error('the hosted approval expired or was already used — run the checkout again for a fresh link');
290
+ else if (res.status === 404) {
291
+ // Registered moments ago, so absent now means expired or already claimed.
292
+ throw new Error('the hosted approval expired or was already used — run the checkout again for a fresh link');
293
+ }
294
+ // Any other status (429 rate bucket, transient 5xx) polls through.
295
+ if (now() >= deadline)
296
+ throw timeoutError();
297
+ // Heartbeat so a human staring at a terminal (or an agent tailing logs)
298
+ // knows the wait is live and where to approve — the loop is otherwise silent
299
+ // for up to the full timeout between the open and the completed claim.
300
+ if (now() - lastHeartbeat >= HOSTED_APPROVAL_HEARTBEAT_MS) {
301
+ lastHeartbeat = now();
302
+ const leftS = Math.max(0, Math.round((deadline - now()) / 1000));
303
+ log(`still waiting for approval (~${leftS}s left) — approve at ${approveUrl}`);
304
+ }
305
+ await sleep(pollIntervalMs);
165
306
  }
166
- // Any other status (429 rate bucket, transient 5xx) polls through.
167
- if (now() >= deadline)
168
- throw timeoutError();
169
- await sleep(pollIntervalMs);
307
+ }
308
+ finally {
309
+ clearInterval(keepAlive);
170
310
  }
171
311
  }
@@ -1,3 +1,6 @@
1
- export { createCliCheckoutEngine, type CliReviewInput, type CliReviewFacts, type CliPayInput, type CliReceiptFacts, } from './cli-engine.js';
1
+ export { createCliCheckoutEngine, type CliReviewInput, type CliReviewFacts, type CliPayInput, type CliReceiptFacts, type CliStartMandateInput, type CliMandateFacts, type CliEngineDeps, } from './cli-engine.js';
2
2
  export { prepareCheckout, submitApprovedCheckout, runCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
3
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';
@@ -3,3 +3,6 @@
3
3
  // primitives are re-exported for direct/embedded use.
4
4
  export { createCliCheckoutEngine, } from './cli-engine.js';
5
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';
@@ -24,6 +24,7 @@ export declare const OFFICIAL_TEST_PANS: {
24
24
  readonly visaSlowConfirm: "4000000000000069";
25
25
  readonly visaUnknownOutcome: "4000000000000044";
26
26
  readonly visaChallenge: "4000000000000010";
27
+ readonly visaEmailOtp: "4000000000000077";
27
28
  };
28
29
  export type TestCardOptions = {
29
30
  pan?: string;
@@ -22,6 +22,10 @@ export const OFFICIAL_TEST_PANS = {
22
22
  // page embedding an ACS challenge iframe that never completes — exercises
23
23
  // the observer's action-required (issuer challenge) path.
24
24
  visaChallenge: '4000000000000010',
25
+ // Ending 0077: the fixture acquirer emails a one-time code to the agent
26
+ // inbox and shows a 'check your email for a code' page with a one-time-code
27
+ // field — exercises the executor's agent-resolvable email-OTP subroutine.
28
+ visaEmailOtp: '4000000000000077',
25
29
  };
26
30
  function futureExpiry() {
27
31
  const now = new Date();
@@ -16,15 +16,6 @@ export declare function parseCheckoutMode(value: string | undefined, env?: Recor
16
16
  * review id; the passkey is a FIDO ceremony on the verify site).
17
17
  */
18
18
  export declare function assertSubmitAllowed(mode: CheckoutMode, env?: Record<string, string | undefined>, isInteractive?: boolean): void;
19
- export declare const LEGACY_REFERENCE_ENV_FLAG = "CHECKOUT_AGENT_ALLOW_LEGACY_REFERENCE";
20
- /**
21
- * The legacy --reference-file path replays a PRE-CREATED intent, so the
22
- * purchase-assurance gate (#5709) cannot vet the ceremony that backed it —
23
- * scope/freshness problems surface only as the opaque cryptogram failure the
24
- * gate exists to prevent. Default-off escape hatch, same shape as the submit
25
- * gate: fail fast before any file is read.
26
- */
27
- export declare function assertLegacyReferenceAllowed(usingReferenceFile: boolean, env?: Record<string, string | undefined>): void;
28
19
  /** Distinct phrases per mode so dry-run muscle memory can never authorize a payment. */
29
20
  export declare function approvalPhrase(mode: CheckoutMode, reviewId: string): string;
30
21
  /** Integer-only minor→display formatting (two-decimal currencies, the same assumption as decimalToMinor). */
@@ -46,23 +46,6 @@ export function assertSubmitAllowed(mode, env = process.env, isInteractive = pro
46
46
  `require ${SUBMIT_ENV_FLAG}=1. The gate is default-off so automation cannot ` +
47
47
  'reach a live charge by accident.');
48
48
  }
49
- export const LEGACY_REFERENCE_ENV_FLAG = 'CHECKOUT_AGENT_ALLOW_LEGACY_REFERENCE';
50
- /**
51
- * The legacy --reference-file path replays a PRE-CREATED intent, so the
52
- * purchase-assurance gate (#5709) cannot vet the ceremony that backed it —
53
- * scope/freshness problems surface only as the opaque cryptogram failure the
54
- * gate exists to prevent. Default-off escape hatch, same shape as the submit
55
- * gate: fail fast before any file is read.
56
- */
57
- export function assertLegacyReferenceAllowed(usingReferenceFile, env = process.env) {
58
- if (!usingReferenceFile)
59
- return;
60
- if (env[LEGACY_REFERENCE_ENV_FLAG] !== '1') {
61
- throw new Error(`--reference-file refused: this legacy path bypasses purchase-assurance validation ` +
62
- `(#5709) — prefer --checkout-file with --purchase-assurance-file. Set ` +
63
- `${LEGACY_REFERENCE_ENV_FLAG}=1 to proceed anyway.`);
64
- }
65
- }
66
49
  /** Distinct phrases per mode so dry-run muscle memory can never authorize a payment. */
67
50
  export function approvalPhrase(mode, reviewId) {
68
51
  return `${mode === 'submit' ? 'PAY' : 'FILL'} ${reviewId}`;