@vonpay/checkout-cli 0.17.0 → 0.18.0

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 (3) hide show
  1. package/README.md +18 -6
  2. package/dist/index.js +1261 -281
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,10 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/index.ts
3
+ // src/program.ts
4
4
  import { Command as Command13 } from "commander";
5
- import { readFileSync as readFileSync4 } from "fs";
6
- import { dirname as dirname2, join as join3 } from "path";
7
- import { fileURLToPath as fileURLToPath2 } from "url";
8
5
 
9
6
  // src/commands/login.ts
10
7
  import { Command, Option } from "commander";
@@ -12,9 +9,9 @@ import { createInterface } from "readline";
12
9
  import chalk from "chalk";
13
10
 
14
11
  // src/config.ts
15
- import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
16
- import { homedir } from "os";
17
- import { join } from "path";
12
+ import { chmodSync as chmodSync2, existsSync, mkdirSync as mkdirSync2, readFileSync, rmSync, writeFileSync } from "fs";
13
+ import { homedir as homedir2 } from "os";
14
+ import { join as join2 } from "path";
18
15
 
19
16
  // src/utils/json-errors.ts
20
17
  var jsonErrors = false;
@@ -50,9 +47,447 @@ function secretKeyMode(key) {
50
47
  }
51
48
  var NOT_A_SECRET_KEY_MESSAGE = "This is not a Von Payments secret key: it must start with vp_sk_test_ (sandbox) or vp_sk_live_ (live). Publishable keys (vp_pk_\u2026) and keys from other services cannot be used with this CLI.";
52
49
 
50
+ // src/utils/audit-log.ts
51
+ import {
52
+ chmodSync,
53
+ closeSync,
54
+ constants as FS,
55
+ fchmodSync,
56
+ fstatSync,
57
+ lstatSync,
58
+ mkdirSync,
59
+ openSync,
60
+ readdirSync,
61
+ unlinkSync,
62
+ writeSync
63
+ } from "fs";
64
+ import { homedir } from "os";
65
+ import { join, resolve } from "path";
66
+ import { isErrorCode, stripHiddenCharacters } from "@vonpay/checkout-node";
67
+ var AUDIT_FIELDS = [
68
+ "ts",
69
+ "surface",
70
+ "version",
71
+ "command",
72
+ "key_mode",
73
+ "key_fingerprint",
74
+ "outcome",
75
+ "error_code",
76
+ "request_id",
77
+ "amount",
78
+ "currency",
79
+ "target"
80
+ ];
81
+ var AUDIT_OUTCOMES = [
82
+ "pending",
83
+ // a money command is about to send; the command's final line follows
84
+ "sent",
85
+ // an API command ran to the end (sent and answered); no person was asked
86
+ "local",
87
+ // a command that sends nothing to the API (login, logout, init, trigger) ran to the end
88
+ "approved",
89
+ // a person confirmed it at the terminal, and it ran to the end
90
+ "declined",
91
+ // the person did not type "confirm"
92
+ "budget_refused",
93
+ // (the MCP server's spending budget; the CLI has none)
94
+ "read_mode_blocked",
95
+ // (the MCP server's read-only mode; the CLI has none)
96
+ "withheld",
97
+ // a live action with no person at an interactive terminal to confirm it
98
+ "lookup_refused",
99
+ // the check before the confirmation failed (a refund's payment could not be looked up or did not match)
100
+ "refused_local",
101
+ // refused by the CLI before anything was sent (a flag, a key rule, an unsafe base URL, a command line it cannot parse, …)
102
+ "error"
103
+ // the API (or the network) answered with an error, the run was interrupted, or the CLI crashed (internal_error)
104
+ ];
105
+ var LOCAL_ERROR_CODES = [
106
+ "auth_failed",
107
+ "budget",
108
+ "cancelled",
109
+ "confirm_live_required",
110
+ "currency_mismatch",
111
+ "declined",
112
+ "dismissed",
113
+ "excess_arguments",
114
+ "idempotency_key_required",
115
+ "internal_error",
116
+ "interrupted",
117
+ "invalid_argument",
118
+ "invalid_input",
119
+ "live_loopback_url",
120
+ "local_refusal",
121
+ "lookup_failed",
122
+ "missing_argument",
123
+ "network_error",
124
+ "no_approval_channel",
125
+ "no_terminal",
126
+ "not_ticked",
127
+ "prompt_failed",
128
+ "read_mode",
129
+ "unknown_command",
130
+ "unknown_option",
131
+ "unknown_tool",
132
+ "usage_error"
133
+ ];
134
+ var LOCAL_CODES = new Set(LOCAL_ERROR_CODES);
135
+ var AUDIT_DIR_ENV = "VONPAY_AUDIT_DIR";
136
+ var AUDIT_SWITCH_ENV = "VONPAY_AUDIT_LOG";
137
+ var AUDIT_RETENTION_ENV = "VONPAY_AUDIT_RETENTION_DAYS";
138
+ var DEFAULT_RETENTION_DAYS = 30;
139
+ var MAX_FILE_BYTES = 50 * 1024 * 1024;
140
+ function auditText(s, max) {
141
+ const line = stripHiddenCharacters(s).replace(/[\p{Cc}\u{2028}\u{2029}]/gu, " ").trim();
142
+ return line.length > max ? line.slice(0, max) : line;
143
+ }
144
+ function keyFingerprint(key) {
145
+ if (key.length >= 24) return `${key.slice(0, 12)}\u2026${key.slice(-4)}`;
146
+ const head = Math.min(12, Math.floor(key.length / 3));
147
+ const tail = Math.min(4, Math.floor(key.length / 4));
148
+ return `${key.slice(0, head)}\u2026${key.slice(key.length - tail)}`;
149
+ }
150
+ var TARGET_ID = /^(vpi|vp_tx|vsub|vp_cs)_(live|test)_[A-Za-z0-9_-]{16}$/;
151
+ var REQUEST_ID = /^[A-Za-z0-9_-]{12}$/;
152
+ var CURRENCY = /^[A-Za-z]{3}$/;
153
+ var COMMAND = /^[a-z0-9-]+( [a-z0-9-]+){0,3}$/;
154
+ var UNKNOWN_COMMAND = "[unknown command]";
155
+ var UNRECOGNISED_ID = "[unrecognised id]";
156
+ var UNRECOGNISED_REQUEST_ID = "[unrecognised request id]";
157
+ function knownErrorCode(code) {
158
+ return typeof code === "string" && (isErrorCode(code) || LOCAL_CODES.has(code));
159
+ }
160
+ function auditLine(record) {
161
+ const out = {};
162
+ for (const field of AUDIT_FIELDS) {
163
+ const v = record[field];
164
+ if (v === void 0 || v === null) continue;
165
+ switch (field) {
166
+ case "amount":
167
+ if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0) out[field] = v;
168
+ break;
169
+ case "currency":
170
+ if (typeof v === "string" && CURRENCY.test(v)) out[field] = v.toUpperCase();
171
+ break;
172
+ case "target":
173
+ if (typeof v === "string") out[field] = TARGET_ID.test(v) ? v : UNRECOGNISED_ID;
174
+ break;
175
+ case "error_code":
176
+ if (typeof v === "string") out[field] = knownErrorCode(v) ? v : "unrecognised_code";
177
+ break;
178
+ case "request_id":
179
+ if (typeof v === "string") out[field] = REQUEST_ID.test(v) ? v : UNRECOGNISED_REQUEST_ID;
180
+ break;
181
+ case "outcome":
182
+ if (typeof v === "string" && AUDIT_OUTCOMES.includes(v)) out[field] = v;
183
+ break;
184
+ case "key_mode":
185
+ if (v === "test" || v === "live") out[field] = v;
186
+ break;
187
+ case "command":
188
+ if (typeof v === "string") out[field] = COMMAND.test(v) ? v : UNKNOWN_COMMAND;
189
+ break;
190
+ default:
191
+ if (typeof v === "string") out[field] = auditText(v, field === "ts" ? 40 : 80);
192
+ }
193
+ }
194
+ return `${JSON.stringify(out)}
195
+ `;
196
+ }
197
+ function auditSettingsFromEnv(env = process.env, warn = stderrWarn) {
198
+ const off = ["off", "0", "false", "no"].includes((env[AUDIT_SWITCH_ENV] ?? "").trim().toLowerCase());
199
+ const raw = (env[AUDIT_RETENTION_ENV] ?? "").trim();
200
+ let retentionDays = DEFAULT_RETENTION_DAYS;
201
+ if (raw !== "") {
202
+ const n = Number(raw);
203
+ if (/^[0-9]{1,3}$/.test(raw) && n >= 1 && n <= 365) retentionDays = n;
204
+ else warn(`[vonpay] ${AUDIT_RETENTION_ENV} must be a whole number from 1 to 365; keeping audit files ${DEFAULT_RETENTION_DAYS} days.`);
205
+ }
206
+ const dir = env[AUDIT_DIR_ENV]?.trim() ? resolve(env[AUDIT_DIR_ENV].trim()) : join(homedir(), ".vonpay", "audit");
207
+ return { dir: off ? null : dir, retentionDays };
208
+ }
209
+ function stderrWarn(m) {
210
+ try {
211
+ process.stderr.write(`${m}
212
+ `);
213
+ } catch (err) {
214
+ void err;
215
+ }
216
+ }
217
+ function errorCodeOf(err) {
218
+ return err !== null && typeof err === "object" && typeof err.code === "string" ? err.code : "error";
219
+ }
220
+ var AuditPlaceRefused = class extends Error {
221
+ constructor(code) {
222
+ super(code);
223
+ this.code = code;
224
+ }
225
+ code;
226
+ };
227
+ function myUid() {
228
+ return typeof process.getuid === "function" ? process.getuid() : void 0;
229
+ }
230
+ function checkDir(dir, st) {
231
+ if (st.isSymbolicLink()) throw new AuditPlaceRefused("directory_is_a_symlink");
232
+ if (!st.isDirectory()) throw new AuditPlaceRefused("not_a_directory");
233
+ const uid = myUid();
234
+ if (uid !== void 0 && st.uid !== uid) throw new AuditPlaceRefused("directory_owned_by_another_user");
235
+ if (uid !== void 0 && (st.mode & 63) !== 0) chmodSync(dir, 448);
236
+ }
237
+ var FILE_NAME = /^cli-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
238
+ var appendFlags = () => FS.O_APPEND | FS.O_CREAT | FS.O_WRONLY | (FS.O_NOFOLLOW ?? 0);
239
+ var warned = false;
240
+ var warnedFull = false;
241
+ function warnOnce(dir, err, warn) {
242
+ if (warned) return;
243
+ warned = true;
244
+ warn(
245
+ `[vonpay] The local audit log cannot be written (${errorCodeOf(err)}) in ${dir}; the command went on without it. Set ${AUDIT_DIR_ENV} to a writable directory of your own, or ${AUDIT_SWITCH_ENV}=off.`
246
+ );
247
+ }
248
+ function pruneAuditFiles(dir, retentionDays, now = /* @__PURE__ */ new Date(), warn = stderrWarn) {
249
+ let names;
250
+ try {
251
+ checkDir(dir, lstatSync(dir));
252
+ names = readdirSync(dir);
253
+ } catch (err) {
254
+ if (errorCodeOf(err) !== "ENOENT") warnOnce(dir, err, warn);
255
+ return 0;
256
+ }
257
+ const today = Date.parse(`${now.toISOString().slice(0, 10)}T00:00:00Z`);
258
+ let removed = 0;
259
+ let skipped = 0;
260
+ for (const name of names) {
261
+ const m = FILE_NAME.exec(name);
262
+ if (!m) continue;
263
+ const day = Date.parse(`${m[1]}-${m[2]}-${m[3]}T00:00:00Z`);
264
+ if (!Number.isFinite(day) || (today - day) / 864e5 <= retentionDays) continue;
265
+ const path = join(dir, name);
266
+ try {
267
+ if (!lstatSync(path).isFile()) {
268
+ skipped++;
269
+ continue;
270
+ }
271
+ unlinkSync(path);
272
+ removed++;
273
+ } catch {
274
+ skipped++;
275
+ }
276
+ }
277
+ if (skipped > 0) warn(`[vonpay] ${skipped} old audit file(s) in ${dir} were not deleted (not a regular file, or could not be removed).`);
278
+ return removed;
279
+ }
280
+ function appendSecure(file, line, maxBytes, dir, warn) {
281
+ const fd = openSync(file, appendFlags(), 384);
282
+ try {
283
+ const st = fstatSync(fd);
284
+ if (!st.isFile()) throw new AuditPlaceRefused("not_a_regular_file");
285
+ const uid = myUid();
286
+ if (uid !== void 0 && st.uid !== uid) throw new AuditPlaceRefused("file_owned_by_another_user");
287
+ if (uid !== void 0 && (st.mode & 63) !== 0) fchmodSync(fd, 384);
288
+ if (st.size + Buffer.byteLength(line) > maxBytes) {
289
+ if (!warnedFull) {
290
+ warnedFull = true;
291
+ warn(`[vonpay] Today's audit file in ${dir} is full; no more lines are written to it today.`);
292
+ }
293
+ return;
294
+ }
295
+ writeSync(fd, line);
296
+ } finally {
297
+ closeSync(fd);
298
+ }
299
+ }
300
+ function writeAuditLine(dir, record, now = /* @__PURE__ */ new Date(), warn = stderrWarn, maxBytes = MAX_FILE_BYTES) {
301
+ const file = join(dir, `cli-${now.toISOString().slice(0, 10)}.jsonl`);
302
+ try {
303
+ const line = auditLine(record);
304
+ const attempt = () => {
305
+ mkdirSync(dir, { recursive: true, mode: 448 });
306
+ checkDir(dir, lstatSync(dir));
307
+ appendSecure(file, line, maxBytes, dir, warn);
308
+ };
309
+ try {
310
+ attempt();
311
+ } catch (err) {
312
+ if (errorCodeOf(err) !== "ENOENT") throw err;
313
+ attempt();
314
+ }
315
+ return true;
316
+ } catch (err) {
317
+ warnOnce(dir, err, warn);
318
+ return false;
319
+ }
320
+ }
321
+ var COMMAND_KIND = {
322
+ "checkout login": "local",
323
+ "checkout logout": "local",
324
+ "checkout init": "local",
325
+ "checkout trigger": "local",
326
+ // signs a synthetic event and sends it to YOUR endpoint, not the API
327
+ "checkout sessions create": "api",
328
+ "checkout sessions get": "api",
329
+ "checkout payment-intents create": "api",
330
+ "checkout payment-intents get": "api",
331
+ "checkout payment-intents capture": "api",
332
+ "checkout payment-intents void": "api",
333
+ "checkout payment-intents list": "api",
334
+ "checkout payment-intents summary": "api",
335
+ "checkout refunds create": "api",
336
+ "checkout refunds list": "api",
337
+ "checkout tokens create": "api",
338
+ "checkout capabilities": "api",
339
+ "checkout listen": "api",
340
+ "checkout health": "api",
341
+ "checkout doctor": "api"
342
+ };
343
+ var MONEY = {
344
+ "checkout payment-intents create": (o) => ({ amount: wholeNumber(o.amount), currency: o.currency, target: o.sessionId }),
345
+ "checkout payment-intents capture": (o, a) => ({ amount: wholeNumber(o.amountToCapture), target: a[0] }),
346
+ "checkout payment-intents void": (_o, a) => ({ target: a[0] }),
347
+ "checkout refunds create": (o) => ({ amount: wholeNumber(o.amount), currency: o.currency, target: o.paymentIntent ?? o.transaction }),
348
+ "checkout sessions create": (o) => ({ amount: wholeNumber(o.amount), currency: o.currency })
349
+ };
350
+ function wholeNumber(v) {
351
+ return typeof v === "string" && /^[0-9]{1,15}$/.test(v.trim()) ? Number(v.trim()) : typeof v === "number" ? v : void 0;
352
+ }
353
+ var pending = null;
354
+ var hooked = false;
355
+ var SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143 };
356
+ var processHandlers = [];
357
+ function keyFields(apiKey) {
358
+ return apiKey ? { key_mode: apiKey.startsWith("vp_sk_test_") ? "test" : "live", key_fingerprint: keyFingerprint(apiKey) } : {};
359
+ }
360
+ var signalsHandledByCommand = false;
361
+ function auditSignalHandledByCommand() {
362
+ signalsHandledByCommand = true;
363
+ }
364
+ function hookProcess() {
365
+ if (hooked) return;
366
+ hooked = true;
367
+ const onExit = (code) => auditFinish(code);
368
+ const onCrash = () => auditNote({ crashed: true });
369
+ process.on("exit", onExit);
370
+ process.on("uncaughtExceptionMonitor", onCrash);
371
+ processHandlers.push(["exit", onExit], ["uncaughtExceptionMonitor", onCrash]);
372
+ for (const sig of ["SIGINT", "SIGTERM"]) {
373
+ const handler = () => {
374
+ if (signalsHandledByCommand) return;
375
+ auditNote({ interrupted: true });
376
+ auditFinish(SIGNAL_EXIT[sig]);
377
+ process.exit(SIGNAL_EXIT[sig]);
378
+ };
379
+ processHandlers.push([sig, handler]);
380
+ process.on(sig, handler);
381
+ }
382
+ }
383
+ function auditStart(command, version, opts = {}, args = []) {
384
+ const settings = auditSettingsFromEnv();
385
+ if (settings.dir === null) {
386
+ pending = null;
387
+ return;
388
+ }
389
+ pruneAuditFiles(settings.dir, settings.retentionDays);
390
+ let money = {};
391
+ try {
392
+ money = Object.prototype.hasOwnProperty.call(MONEY, command) ? MONEY[command](opts, args) : {};
393
+ } catch {
394
+ money = {};
395
+ }
396
+ pending = { command, money, settings, version, intentWritten: false, written: false };
397
+ if (typeof opts.apiKey === "string") pending.apiKey = opts.apiKey;
398
+ hookProcess();
399
+ }
400
+ function auditNote(note, onlyIfUnset = false) {
401
+ if (!pending) return;
402
+ if (onlyIfUnset && pending.outcome !== void 0 && note.outcome !== void 0) return;
403
+ Object.assign(pending, note);
404
+ }
405
+ function auditError(err) {
406
+ if (err === null || typeof err !== "object") return;
407
+ const e = err;
408
+ if (typeof e.status === "number" && typeof e.code === "string") {
409
+ auditNote({ apiCode: e.code, ...typeof e.requestId === "string" ? { requestId: e.requestId } : {} });
410
+ } else if (e.cause !== void 0 && err instanceof TypeError) {
411
+ auditNote({ networkError: true });
412
+ }
413
+ }
414
+ function auditIntent(now = /* @__PURE__ */ new Date()) {
415
+ const p = pending;
416
+ if (!p || p.intentWritten || p.settings.dir === null || !Object.prototype.hasOwnProperty.call(MONEY, p.command)) return;
417
+ p.intentWritten = true;
418
+ writeAuditLine(p.settings.dir, { command: p.command, ts: now.toISOString(), surface: "cli", version: p.version, ...keyFields(p.apiKey), outcome: "pending", ...p.money }, now);
419
+ }
420
+ function auditFinish(exitCode, now = /* @__PURE__ */ new Date()) {
421
+ const p = pending;
422
+ if (!p || p.written || p.settings.dir === null) return;
423
+ p.written = true;
424
+ try {
425
+ let outcome;
426
+ let code = p.code;
427
+ if (p.crashed) [outcome, code] = ["error", "internal_error"];
428
+ else if (p.interrupted) [outcome, code] = ["error", "interrupted"];
429
+ else if (p.outcome) outcome = p.outcome;
430
+ else if (p.apiCode) [outcome, code] = ["error", p.apiCode];
431
+ else if (p.networkError) [outcome, code] = ["error", "network_error"];
432
+ else if (exitCode === 0) outcome = p.approved ? "approved" : COMMAND_KIND[p.command] === "local" ? "local" : "sent";
433
+ else [outcome, code] = ["refused_local", code ?? "local_refusal"];
434
+ writeAuditLine(
435
+ p.settings.dir,
436
+ {
437
+ command: p.command,
438
+ ts: now.toISOString(),
439
+ surface: "cli",
440
+ version: p.version,
441
+ ...keyFields(p.apiKey),
442
+ outcome,
443
+ ...code ? { error_code: code } : {},
444
+ ...p.requestId ? { request_id: p.requestId } : {},
445
+ ...p.money
446
+ },
447
+ now
448
+ );
449
+ } catch (err) {
450
+ warnOnce(p.settings.dir, err, stderrWarn);
451
+ }
452
+ }
453
+ function usageReason(commanderCode) {
454
+ switch (commanderCode) {
455
+ case "commander.unknownCommand":
456
+ return "unknown_command";
457
+ case "commander.unknownOption":
458
+ return "unknown_option";
459
+ case "commander.missingArgument":
460
+ case "commander.optionMissingArgument":
461
+ case "commander.missingMandatoryOptionValue":
462
+ return "missing_argument";
463
+ case "commander.invalidArgument":
464
+ return "invalid_argument";
465
+ case "commander.excessArguments":
466
+ return "excess_arguments";
467
+ default:
468
+ return "usage_error";
469
+ }
470
+ }
471
+ function auditUsageError(command, version, commanderCode, exitCode, now = /* @__PURE__ */ new Date()) {
472
+ try {
473
+ if (exitCode === 0) return;
474
+ const reason = usageReason(commanderCode);
475
+ if (pending) {
476
+ auditNote({ outcome: "refused_local", code: reason }, true);
477
+ return;
478
+ }
479
+ const settings = auditSettingsFromEnv();
480
+ if (settings.dir === null) return;
481
+ const name = reason === "unknown_command" || command === "" ? UNKNOWN_COMMAND : command;
482
+ writeAuditLine(settings.dir, { command: name, ts: now.toISOString(), surface: "cli", version, outcome: "refused_local", error_code: reason }, now);
483
+ } catch (err) {
484
+ void err;
485
+ }
486
+ }
487
+
53
488
  // src/config.ts
54
- var CONFIG_DIR = join(homedir(), ".vonpay");
55
- var CONFIG_FILE = join(CONFIG_DIR, "config.json");
489
+ var CONFIG_DIR = join2(homedir2(), ".vonpay");
490
+ var CONFIG_FILE = join2(CONFIG_DIR, "config.json");
56
491
  function loadConfig() {
57
492
  if (!existsSync(CONFIG_FILE)) return {};
58
493
  try {
@@ -79,13 +514,13 @@ function saveConfig(config) {
79
514
  warnStoredLiveKey();
80
515
  }
81
516
  if (!existsSync(CONFIG_DIR)) {
82
- mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
517
+ mkdirSync2(CONFIG_DIR, { recursive: true, mode: 448 });
83
518
  }
84
519
  writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", {
85
520
  encoding: "utf-8",
86
521
  mode: 384
87
522
  });
88
- chmodSync(CONFIG_FILE, 384);
523
+ chmodSync2(CONFIG_FILE, 384);
89
524
  }
90
525
  function clearStoredApiKey() {
91
526
  const config = loadConfig();
@@ -124,6 +559,7 @@ function requireApiKey() {
124
559
  if (secretKeyMode(key) === null) {
125
560
  emitCliError({ error: NOT_A_SECRET_KEY_MESSAGE, code: null }, NOT_A_SECRET_KEY_MESSAGE);
126
561
  }
562
+ auditNote({ apiKey: key });
127
563
  return key;
128
564
  }
129
565
 
@@ -146,10 +582,10 @@ function evaluateKeyToSave(key) {
146
582
  }
147
583
  function readLine(prompt) {
148
584
  const rl = createInterface({ input: process.stdin, output: process.stderr });
149
- return new Promise((resolve2) => {
585
+ return new Promise((resolve3) => {
150
586
  rl.question(prompt, (answer) => {
151
587
  rl.close();
152
- resolve2(answer.trim());
588
+ resolve3(answer.trim());
153
589
  });
154
590
  });
155
591
  }
@@ -222,8 +658,8 @@ function logoutCommand() {
222
658
 
223
659
  // src/commands/init.ts
224
660
  import { Command as Command3 } from "commander";
225
- import { appendFileSync, chmodSync as chmodSync2, existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
226
- import { resolve } from "path";
661
+ import { appendFileSync, chmodSync as chmodSync3, existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
662
+ import { resolve as resolve2 } from "path";
227
663
  import chalk3 from "chalk";
228
664
  var LIVE_KEY_NOT_WRITTEN_MESSAGE = "Refusing to write a live key (vp_sk_live_\u2026) to .env: live keys are never written to disk. Run `init` with a sandbox key (vp_sk_test_\u2026), and give your app its live key through your deployment's secret store or the environment when you go live.";
229
665
  function ensureGitignoreBlocksEnv() {
@@ -274,7 +710,7 @@ function initCommand() {
274
710
  process.exit(1);
275
711
  }
276
712
  const envFile = ".env";
277
- const absPath = resolve(envFile);
713
+ const absPath = resolve2(envFile);
278
714
  if (existsSync2(envFile) && !opts.force) {
279
715
  console.error(
280
716
  chalk3.yellow(
@@ -299,7 +735,7 @@ function initCommand() {
299
735
  ""
300
736
  ].join("\n");
301
737
  writeFileSync2(envFile, content, { encoding: "utf-8", mode: 384 });
302
- chmodSync2(envFile, 384);
738
+ chmodSync3(envFile, 384);
303
739
  console.log(chalk3.green(`\u2713 .env created (${mode} mode) \u2192 ${absPath}`));
304
740
  const gitignoreState = ensureGitignoreBlocksEnv();
305
741
  if (gitignoreState === "added") {
@@ -318,7 +754,7 @@ function initCommand() {
318
754
 
319
755
  // src/commands/sessions.ts
320
756
  import { Command as Command4 } from "commander";
321
- import chalk5 from "chalk";
757
+ import chalk6 from "chalk";
322
758
 
323
759
  // src/utils/spinner.ts
324
760
  import ora from "ora";
@@ -334,10 +770,10 @@ import { VonPayCheckout } from "@vonpay/checkout-node";
334
770
 
335
771
  // src/app-info.ts
336
772
  import { readFileSync as readFileSync3 } from "fs";
337
- import { dirname, join as join2 } from "path";
773
+ import { dirname, join as join3 } from "path";
338
774
  import { fileURLToPath } from "url";
339
775
  var pkg = JSON.parse(
340
- readFileSync3(join2(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")
776
+ readFileSync3(join3(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")
341
777
  );
342
778
  var CLI_VERSION = pkg.version;
343
779
  var CLI_APP_INFO = { name: "vonpay-cli", version: CLI_VERSION };
@@ -347,12 +783,18 @@ var SILENT_ERROR_REPORTER = () => {
347
783
  // src/utils/base-url.ts
348
784
  import { keyDestinationProblem as sdkKeyDestinationProblem } from "@vonpay/checkout-node";
349
785
  var ALLOW_CUSTOM_BASE_URL_ENV = "VONPAY_ALLOW_CUSTOM_BASE_URL";
350
- function customBaseUrlAllowed(env = process.env) {
351
- return env[ALLOW_CUSTOM_BASE_URL_ENV] === "1";
786
+ var ALLOW_CUSTOM_BASE_URL_FLAG = "--allow-custom-base-url";
787
+ var flagGiven = false;
788
+ function setAllowCustomBaseUrlFlag(given) {
789
+ flagGiven = given === true;
790
+ }
791
+ function customBaseUrlAllowed(env = process.env, flag = flagGiven) {
792
+ return env[ALLOW_CUSTOM_BASE_URL_ENV] === "1" && flag === true;
352
793
  }
353
794
  var UNSAFE_URL_CHARS = /[\s\x00-\x1f\x7f\\]/;
354
795
  var WHERE = "It comes from VONPAY_BASE_URL or baseUrl in ~/.vonpay/config.json; fix it, or unset it to use https://checkout.vonpay.com.";
355
- function keyDestinationProblem(apiKey, baseUrl, env = process.env) {
796
+ var CLI_OPT_IN = `For a proxy you run yourself, set ${ALLOW_CUSTOM_BASE_URL_ENV}=1 AND pass ${ALLOW_CUSTOM_BASE_URL_FLAG} on the command; either alone is not enough.`;
797
+ function keyDestinationProblem(apiKey, baseUrl, env = process.env, flag = flagGiven) {
356
798
  if (baseUrl === void 0) return null;
357
799
  if (UNSAFE_URL_CHARS.test(baseUrl)) {
358
800
  return `Refusing to use the base URL: it contains spaces, control characters or backslashes. ${WHERE}`;
@@ -366,11 +808,11 @@ function keyDestinationProblem(apiKey, baseUrl, env = process.env) {
366
808
  if (url.username !== "" || url.password !== "") {
367
809
  return `Refusing to use the base URL: it contains a user name or password, which would be sent with every request. ${WHERE}`;
368
810
  }
369
- const problem = sdkKeyDestinationProblem(apiKey, baseUrl, { allowCustom: customBaseUrlAllowed(env) });
370
- return problem === null ? null : `${problem} ${WHERE}`;
811
+ const problem = sdkKeyDestinationProblem(apiKey, baseUrl, { allowCustom: customBaseUrlAllowed(env, flag) });
812
+ return problem === null ? null : `${problem} ${WHERE} ${CLI_OPT_IN}`;
371
813
  }
372
- function allowCustomBaseUrl(env = process.env) {
373
- return customBaseUrlAllowed(env);
814
+ function allowCustomBaseUrl(env = process.env, flag = flagGiven) {
815
+ return customBaseUrlAllowed(env, flag);
374
816
  }
375
817
 
376
818
  // src/utils/errors.ts
@@ -404,10 +846,13 @@ function scrubSecrets(s) {
404
846
  }
405
847
 
406
848
  // src/utils/sanitize.ts
849
+ import { stripHiddenCharacters as stripHiddenCharacters2 } from "@vonpay/checkout-node";
407
850
  var ANSI_PATTERN = /\x1B\[[0-9;?]*[A-Za-z]/g;
408
- var C0_CONTROL_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
851
+ var C1_CSI_PATTERN = /\u{9B}[0-9;?]*[A-Za-z]/gu;
852
+ var CONTROL_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\u{80}-\u{9F}]/gu;
853
+ var LINE_SEPARATOR_PATTERN = /[\u{2028}\u{2029}]/gu;
409
854
  function stripAnsi(s) {
410
- return s.replace(ANSI_PATTERN, "").replace(C0_CONTROL_PATTERN, "");
855
+ return stripHiddenCharacters2(s.replace(ANSI_PATTERN, "").replace(C1_CSI_PATTERN, "").replace(CONTROL_PATTERN, ""));
411
856
  }
412
857
  function truncate(s, max) {
413
858
  if (s.length <= max) return s;
@@ -417,7 +862,7 @@ function safeDisplay(s, max = 200) {
417
862
  return truncate(stripAnsi(s), max);
418
863
  }
419
864
  function safeInline(s, max = 200) {
420
- return safeDisplay(s.replace(/[\r\n]+/g, " "), max);
865
+ return safeDisplay(s.replace(/[\r\n]+/g, " ").replace(LINE_SEPARATOR_PATTERN, " "), max);
421
866
  }
422
867
 
423
868
  // src/utils/errors.ts
@@ -425,6 +870,11 @@ var CLI_READ_BACK = "From this CLI: `vonpay checkout payment-intents get <id>` (
425
870
  var READ_BACK_FIRST = "Check the payment first: read it back (`vonpay checkout payment-intents get <id>` for a charge, capture or void; `vonpay checkout refunds list --payment-intent <id>` (or `--transaction <id>`) for a refund; `sessions get <id>` for a checkout session) and act only on what it shows.";
426
871
  var UNKNOWN_OUTCOME_GUIDANCE = `${READ_BACK_FIRST} If you then retry, reuse the SAME --idempotency-key as the first attempt, never a new one: a new key is a new action and can move money twice.`;
427
872
  var str = (v) => v === void 0 || v === null ? null : String(v);
873
+ var oneLine = (s) => safeInline(s, 2e3);
874
+ function paramFlagHint(param) {
875
+ const flag = param.startsWith("metadata") ? "--metadata" : `--${param.replace(/\[(gte|lt)\]$/, "-$1").replace(/_/g, "-")}`;
876
+ return /^--[a-z-]+$/.test(flag) ? ` (the ${flag} flag)` : "";
877
+ }
428
878
  var FIELD_PRINTERS = {
429
879
  code: (e) => `Code: ${str(e.code)}`,
430
880
  fix: (e) => `Fix: ${str(e.fix)}`,
@@ -464,8 +914,8 @@ var FIELD_PRINTERS = {
464
914
  usedMinor: (e) => typeof e.usedMinor === "number" ? `Used in the last 24 hours: ${safeInline(String(e.usedMinor))} (minor units)` : null,
465
915
  limitCurrency: (e) => e.limitCurrency ? `Key limit currency: ${safeInline(String(e.limitCurrency))}` : null,
466
916
  refusedFields: (e) => Array.isArray(e.refusedFields) && e.refusedFields.length > 0 ? `Fields this key may not send: ${e.refusedFields.map((f) => safeInline(String(f), 128)).join(", ")}` : null,
467
- // A refused payment-only settle (mirror_settle_only_*; built ahead of the
468
- // API's production release): the charge asked for and the store order's
917
+ // A refused payment-only settle (mirror_settle_only_*; served in production
918
+ // since 2026-10-27): the charge asked for and the store order's
469
919
  // facts. Validated by the SDK, printed through safeInline all the same.
470
920
  chargeAmount: (e) => typeof e.chargeAmount === "number" ? `Charge asked for: ${safeInline(String(e.chargeAmount))} (minor units)` : null,
471
921
  chargeCurrency: (e) => e.chargeCurrency ? `Charge currency: ${safeInline(String(e.chargeCurrency))}` : null,
@@ -474,7 +924,7 @@ var FIELD_PRINTERS = {
474
924
  orderCurrency: (e) => e.orderCurrency ? `Store order currency: ${safeInline(String(e.orderCurrency))}` : null,
475
925
  orderStatus: (e) => e.orderStatus ? `Store order status: ${safeInline(String(e.orderStatus))}` : null,
476
926
  orderPaymentStatus: (e) => e.orderPaymentStatus ? `Store order payment status: ${safeInline(String(e.orderPaymentStatus))}` : null,
477
- // A refused subscription change (built ahead of the API's production release). Validated by
927
+ // A refused subscription change (served in production since 2026-10-30). Validated by
478
928
  // the SDK, printed through safeInline all the same.
479
929
  subscriptionStatus: (e) => e.subscriptionStatus ? `Subscription status: ${safeInline(String(e.subscriptionStatus))}` : null,
480
930
  refusalReason: (e) => e.refusalReason ? `Refused because: ${safeInline(String(e.refusalReason))}` : null,
@@ -484,14 +934,23 @@ var FIELD_PRINTERS = {
484
934
  maxAmount: (e) => typeof e.maxAmount === "number" ? `Most this key may make a renewal charge: ${safeInline(String(e.maxAmount))} (minor units)` : e.maxAmount === null ? "Most this key may make a renewal charge: none \u2014 this key may only lower the price" : null,
485
935
  amountCurrency: (e) => e.amountCurrency ? `Renewal charge currency: ${safeInline(String(e.amountCurrency))}` : null,
486
936
  pendingKind: (e) => e.pendingKind ? `Schedule change waiting for its customer notice: ${safeInline(String(e.pendingKind))}` : null,
487
- // A charge the payment provider refused before any money moved (built ahead
488
- // of the API's production release): the failed payment intent that records
937
+ nextChangeAllowedOn: (e) => e.nextChangeAllowedOn ? `Customer's next schedule change allowed from: ${safeInline(String(e.nextChangeAllowedOn))}` : null,
938
+ // A charge the payment provider refused before any money moved (served in
939
+ // production since 2026-10-30): the failed payment intent that records
489
940
  // the attempt. The SDK has checked it is a vpi_* id; printed through
490
941
  // safeInline all the same. The next step depends on the code: a card the
491
942
  // processor is still storing (payment_method_unvaulted) is retried as is
492
943
  // after a short wait; every other refusal needs the request or card fixed.
493
944
  // Both under a NEW key: the refused record does not hold the key, and the
494
945
  // processor may refuse a resend under the same one.
946
+ // A refused payment list / summary filter (`payment-intents list` / `summary`): the ONE
947
+ // query parameter at fault, a plain parameter name the SDK has checked. The flag that sets it
948
+ // is named alongside, so the fix is obvious.
949
+ param: (e) => e.param ? `Refused parameter: ${safeInline(String(e.param))}${paramFlagHint(String(e.param))}` : null,
950
+ // A payment report too large to answer (report_too_large): which limit refused it. The SDK
951
+ // validates both as positive whole numbers.
952
+ maxGroups: (e) => typeof e.maxGroups === "number" ? `Most groups one summary returns: ${e.maxGroups} \u2014 group by fewer dimensions or a coarser time unit, or add filters` : null,
953
+ timeoutMs: (e) => typeof e.timeoutMs === "number" ? `Time limit exceeded: ${e.timeoutMs} ms \u2014 use a shorter window or add filters (retrying unchanged fails the same way)` : null,
495
954
  paymentIntentId: (e) => e.paymentIntentId ? `Refused attempt recorded as: ${safeInline(String(e.paymentIntentId))} (status failed, nothing was charged; ` + (e.code === "payment_method_unvaulted" ? "wait a few seconds, then send the same charge again with a NEW --idempotency-key)" : "fix the request or the card, then charge with a NEW --idempotency-key)") : null,
496
955
  // On a withheld retry the SDK sets this to RETRY_WITHHELD_HELP.llmHint — the
497
956
  // one wording for every surface — so it is printed as is.
@@ -507,12 +966,12 @@ function errorDetailLines(err) {
507
966
  const lines = [];
508
967
  for (const print of Object.values(FIELD_PRINTERS)) {
509
968
  const line = print(source);
510
- if (line !== null) lines.push(` ${scrubSecrets(line)}`);
969
+ if (line !== null) lines.push(` ${scrubSecrets(oneLine(line))}`);
511
970
  }
512
971
  return lines;
513
972
  }
514
973
  function scrubDeep(value) {
515
- if (typeof value === "string") return scrubSecrets(value);
974
+ if (typeof value === "string") return scrubSecrets(oneLine(value));
516
975
  if (Array.isArray(value)) return value.map(scrubDeep);
517
976
  if (value !== null && typeof value === "object") {
518
977
  const out = {};
@@ -532,23 +991,25 @@ function errorJson(err) {
532
991
  return scrubDeep(out);
533
992
  }
534
993
  const message = err instanceof Error ? err.message : String(err);
535
- return { error: scrubSecrets(message), code: null };
994
+ return { error: scrubSecrets(oneLine(message)), code: null };
536
995
  }
537
996
  function exitWithCliError(message, text) {
997
+ auditNote({ outcome: "refused_local", code: "local_refusal" }, true);
538
998
  return emitCliError({ error: scrubSecrets(message), code: null }, text);
539
999
  }
540
1000
  function handleError(err) {
1001
+ auditError(err);
541
1002
  if (jsonErrorsEnabled()) {
542
1003
  printJsonError(errorJson(err));
543
1004
  process.exit(1);
544
1005
  }
545
1006
  if (err instanceof VonPayError) {
546
- console.error(chalk4.red(`Error: ${scrubSecrets(err.message)}`));
1007
+ console.error(chalk4.red(`Error: ${scrubSecrets(oneLine(err.message))}`));
547
1008
  for (const line of errorDetailLines(err)) console.error(chalk4.dim(line));
548
1009
  process.exit(1);
549
1010
  }
550
1011
  const message = err instanceof Error ? err.message : String(err);
551
- console.error(chalk4.red(`Error: ${scrubSecrets(message)}`));
1012
+ console.error(chalk4.red(`Error: ${scrubSecrets(oneLine(message))}`));
552
1013
  process.exit(1);
553
1014
  }
554
1015
 
@@ -573,11 +1034,30 @@ function apiHostDetail() {
573
1034
  }
574
1035
  return origin === "https://checkout.vonpay.com" ? [] : [["API host", origin]];
575
1036
  }
1037
+ var MONEY_METHODS = {
1038
+ paymentIntents: ["create", "capture", "void"],
1039
+ refunds: ["create"],
1040
+ sessions: ["create"]
1041
+ };
1042
+ function withAuditIntent(client) {
1043
+ for (const [resource, methods] of Object.entries(MONEY_METHODS)) {
1044
+ const target = client[resource];
1045
+ for (const method of methods) {
1046
+ const original = target?.[method];
1047
+ if (typeof original !== "function") continue;
1048
+ target[method] = (...args) => {
1049
+ auditIntent();
1050
+ return original.apply(target, args);
1051
+ };
1052
+ }
1053
+ }
1054
+ return client;
1055
+ }
576
1056
  function makeCheckoutClient() {
577
1057
  const apiKey = requireApiKey();
578
1058
  const baseUrl = getBaseUrl();
579
1059
  refuseUnsafeLiveDestination(apiKey);
580
- return new VonPayCheckout({
1060
+ return withAuditIntent(new VonPayCheckout({
581
1061
  apiKey,
582
1062
  ...baseUrl ? { baseUrl } : {},
583
1063
  // The SDK applies the same host rule at construction; the opt-in is the
@@ -587,7 +1067,86 @@ function makeCheckoutClient() {
587
1067
  // The CLI prints every error itself (handleError / --json). Without this the SDK's fallback
588
1068
  // reporter adds its own `[vonpay] …` line to stderr on EVERY API error, live key included.
589
1069
  errorReporter: SILENT_ERROR_REPORTER
590
- });
1070
+ }));
1071
+ }
1072
+
1073
+ // src/utils/live-key.ts
1074
+ import { createInterface as createInterface2 } from "readline";
1075
+ import chalk5 from "chalk";
1076
+ import { Option as Option2 } from "commander";
1077
+ import { isLiveKey as isLiveKey2 } from "@vonpay/checkout-node";
1078
+ var CONFIRM_WORD = "confirm";
1079
+ var terminal = {
1080
+ /**
1081
+ * Both stdin and stdout are a terminal. A command run by a script, a CI job or
1082
+ * an AI agent's shell tool has at least one of them piped.
1083
+ */
1084
+ isInteractive() {
1085
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
1086
+ },
1087
+ /** Ask one line on the terminal. End of input or Ctrl-C answers "" (cancel). */
1088
+ ask(prompt) {
1089
+ return new Promise((resolve3) => {
1090
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
1091
+ let answered = false;
1092
+ rl.on("SIGINT", () => rl.close());
1093
+ rl.on("close", () => {
1094
+ if (!answered) resolve3("");
1095
+ });
1096
+ rl.question(prompt, (answer) => {
1097
+ answered = true;
1098
+ rl.close();
1099
+ resolve3(answer);
1100
+ });
1101
+ });
1102
+ }
1103
+ };
1104
+ function keyFingerprint2(key) {
1105
+ const m = key.match(/^(vp_sk_live_)([A-Za-z0-9_-]{0,4})/);
1106
+ return m ? `${m[1]}${m[2]}\u2026` : "a key that is not a sandbox key";
1107
+ }
1108
+ async function confirmLiveAction(key, summary, opts = {}) {
1109
+ if (!requiresLiveConfirmation(key)) return;
1110
+ if (!terminal.isInteractive()) {
1111
+ const message = `Refusing to ${summary.action} with a live key: no person is at an interactive terminal to confirm it. A live-key action must be confirmed by a person typing at a terminal (stdin and stdout both a terminal); scripts, CI jobs and AI agents cannot confirm it, and --confirm-live is no longer enough. Use a sandbox key (vp_sk_test_\u2026) for automation, or run the command yourself in a terminal. Nothing was sent.`;
1112
+ auditNote({ outcome: "withheld", code: "no_terminal" });
1113
+ exitWithCliError(message, chalk5.red(message));
1114
+ }
1115
+ if (opts.confirmLive) {
1116
+ console.warn(
1117
+ chalk5.yellow(
1118
+ "Note: --confirm-live no longer confirms a live action by itself. A person confirms it at the terminal."
1119
+ )
1120
+ );
1121
+ }
1122
+ const lines = [
1123
+ chalk5.bold.red(`You are about to ${summary.action} with a LIVE key (${keyFingerprint2(key)}).`),
1124
+ // At least one space after the label, however long it is.
1125
+ ...(summary.details ?? []).map(([label, value]) => ` ${`${label}:`.padEnd(Math.max(17, label.length + 2))}${safeInline(value)}`),
1126
+ chalk5.yellow(summary.consequence ?? "This moves real money and cannot be undone from here.")
1127
+ ];
1128
+ for (const line of lines) console.log(line);
1129
+ const answer = await terminal.ask(`Type "${CONFIRM_WORD}" to proceed, anything else to cancel: `);
1130
+ if (answer.trim() !== CONFIRM_WORD) {
1131
+ auditNote({ outcome: "declined", code: "declined" });
1132
+ const message = `Cancelled: you did not type "${CONFIRM_WORD}". Nothing was sent.`;
1133
+ exitWithCliError(message, chalk5.red(message));
1134
+ }
1135
+ auditNote({ approved: true });
1136
+ }
1137
+ function retiredConfirmLiveOption() {
1138
+ return new Option2(
1139
+ "--confirm-live",
1140
+ "No longer confirms anything: a live-key action is confirmed by a person at the terminal"
1141
+ ).hideHelp();
1142
+ }
1143
+ function warnLiveSecretFlag(key, flag) {
1144
+ if (!requiresLiveConfirmation(key)) return;
1145
+ console.warn(
1146
+ chalk5.yellow(
1147
+ `Warning: passing ${flag} as a command argument with a live key exposes it to shell history and process-list (\`ps\`). Consider piping or env-injecting the value instead.`
1148
+ )
1149
+ );
591
1150
  }
592
1151
 
593
1152
  // src/utils/amount.ts
@@ -631,18 +1190,18 @@ function sessionJson(session, showSecrets) {
631
1190
  // src/commands/sessions.ts
632
1191
  var makeClient = makeCheckoutClient;
633
1192
  function sessionLines(session) {
634
- const statusColor = session.status === "succeeded" ? chalk5.green : session.status === "failed" ? chalk5.red : session.status === "expired" ? chalk5.gray : chalk5.yellow;
1193
+ const statusColor = session.status === "succeeded" ? chalk6.green : session.status === "failed" ? chalk6.red : session.status === "expired" ? chalk6.gray : chalk6.yellow;
635
1194
  const lines = [
636
1195
  ` ID: ${session.id}`,
637
1196
  ` Status: ${statusColor(session.status)}`,
638
1197
  ` Amount: ${formatMinorUnits(session.amount, session.currency)}`
639
1198
  ];
640
1199
  if (session.paymentStatus) {
641
- const paymentColor = session.paymentStatus === "paid" ? chalk5.green : session.paymentStatus === "held" ? chalk5.yellow : chalk5.gray;
1200
+ const paymentColor = session.paymentStatus === "paid" ? chalk6.green : session.paymentStatus === "held" ? chalk6.yellow : chalk6.gray;
642
1201
  lines.push(` Payment: ${paymentColor(session.paymentStatus)}`);
643
1202
  if (session.paymentStatus === "held") {
644
1203
  lines.push(
645
- chalk5.yellow(
1204
+ chalk6.yellow(
646
1205
  " Held, not taken: capture it to take the money, or void it. Do not ship on a hold alone."
647
1206
  )
648
1207
  );
@@ -661,7 +1220,7 @@ function sessionLines(session) {
661
1220
  lines.push(` Payments: ${session.vp_tx_count}`);
662
1221
  if (session.vp_tx_count >= 2) {
663
1222
  lines.push(
664
- chalk5.red(
1223
+ chalk6.red(
665
1224
  ` \u26A0 Charged ${session.vp_tx_count} times: there is no single refund id. Have a person check the payments before refunding or fulfilling.`
666
1225
  )
667
1226
  );
@@ -671,12 +1230,44 @@ function sessionLines(session) {
671
1230
  lines.push(` Expires: ${session.expiresAt}`);
672
1231
  return lines;
673
1232
  }
1233
+ function redirectHost(url) {
1234
+ if (url === void 0) return "none given (the account default)";
1235
+ let parsed;
1236
+ try {
1237
+ parsed = new URL(url);
1238
+ } catch {
1239
+ return "[not a valid address]";
1240
+ }
1241
+ const host = parsed.hostname;
1242
+ if (host === "") return "[not a web address]";
1243
+ if (host.length > 253) return "[host too long]";
1244
+ if (!/^([a-z0-9-]{1,63}\.)*[a-z0-9-]{1,63}$/.test(host) && !/^\[[0-9a-f:.]+\]$/.test(host)) return "[unrecognised host withheld]";
1245
+ return parsed.port ? `${host}:${parsed.port}` : host;
1246
+ }
674
1247
  function sessionsCommand() {
675
1248
  const sessions = new Command4("sessions").description(
676
1249
  "Create and manage checkout sessions"
677
1250
  );
678
- sessions.command("create").description("Create a new checkout session").requiredOption("--amount <cents>", "Amount in minor units, a whole number (e.g. 1499 = $14.99)").requiredOption("--currency <code>", "Currency code (e.g. USD)").option("--country <code>", "Country code (e.g. US)").option("--description <text>", "Session description").option("--success-url <url>", "Redirect URL on success").option("--cancel-url <url>", "Redirect URL on cancel").option("--idempotency-key <key>", "Idempotency key for safe retries").option("--dry-run", "Validate without creating a session").option("--json", "Output raw JSON (the session's client secret masked, see --show-secrets)").option("--show-secrets", SHOW_SECRETS_HELP).action(async (opts) => {
1251
+ sessions.command("create").description("Create a new checkout session").requiredOption("--amount <cents>", "Amount in minor units, a whole number (e.g. 1499 = $14.99)").requiredOption("--currency <code>", "Currency code (e.g. USD)").option("--country <code>", "Country code (e.g. US)").option("--description <text>", "Session description").option("--success-url <url>", "Redirect URL on success").option("--cancel-url <url>", "Redirect URL on cancel").option("--idempotency-key <key>", "Idempotency key for safe retries").option("--dry-run", "Validate without creating a session").addOption(retiredConfirmLiveOption()).option("--json", "Output raw JSON (the session's client secret masked, see --show-secrets)").option("--show-secrets", SHOW_SECRETS_HELP).action(async (opts) => {
679
1252
  const amount = minorUnitsOrExit(String(opts.amount), "--amount");
1253
+ if (!opts.dryRun) {
1254
+ const apiKey = requireApiKey();
1255
+ refuseUnsafeLiveDestination(apiKey);
1256
+ await confirmLiveAction(
1257
+ apiKey,
1258
+ {
1259
+ action: "create a checkout session (a payment page a buyer can pay)",
1260
+ details: [
1261
+ ...apiHostDetail(),
1262
+ ["Amount", formatMinorUnits(amount, String(opts.currency).toUpperCase())],
1263
+ ["After paying, to", redirectHost(opts.successUrl)],
1264
+ ["If cancelled, to", redirectHost(opts.cancelUrl)]
1265
+ ],
1266
+ consequence: "A buyer can pay real money on this page until it expires."
1267
+ },
1268
+ opts
1269
+ );
1270
+ }
680
1271
  const client = makeClient();
681
1272
  const spinner = startSpinner("Creating session...");
682
1273
  try {
@@ -695,14 +1286,14 @@ function sessionsCommand() {
695
1286
  if (opts.json) {
696
1287
  console.log(JSON.stringify(result, null, 2));
697
1288
  } else if (result.valid) {
698
- console.log(chalk5.green("\u2713 Valid request"));
1289
+ console.log(chalk6.green("\u2713 Valid request"));
699
1290
  if (result.warnings.length > 0) {
700
1291
  for (const w of result.warnings) {
701
- console.log(chalk5.yellow(` \u26A0 ${w}`));
1292
+ console.log(chalk6.yellow(` \u26A0 ${w}`));
702
1293
  }
703
1294
  }
704
1295
  } else {
705
- console.log(chalk5.red("\u2717 Invalid request"));
1296
+ console.log(chalk6.red("\u2717 Invalid request"));
706
1297
  }
707
1298
  return;
708
1299
  }
@@ -714,9 +1305,9 @@ function sessionsCommand() {
714
1305
  if (opts.json) {
715
1306
  console.log(sessionJson(session, opts.showSecrets));
716
1307
  } else {
717
- console.log(chalk5.green("\u2713 Session created"));
1308
+ console.log(chalk6.green("\u2713 Session created"));
718
1309
  console.log(` ID: ${session.id}`);
719
- console.log(` URL: ${chalk5.cyan(session.checkoutUrl)}`);
1310
+ console.log(` URL: ${chalk6.cyan(session.checkoutUrl)}`);
720
1311
  console.log(` Expires: ${session.expiresAt}`);
721
1312
  }
722
1313
  } catch (err) {
@@ -745,28 +1336,37 @@ function sessionsCommand() {
745
1336
 
746
1337
  // src/commands/payment-intents.ts
747
1338
  import { Command as Command5 } from "commander";
748
- import chalk8 from "chalk";
749
- import { formatMinorUnits as formatMinorUnits2 } from "@vonpay/checkout-node";
1339
+ import chalk9 from "chalk";
1340
+ import { formatMinorUnits as formatMinorUnits3 } from "@vonpay/checkout-node";
750
1341
 
751
1342
  // src/utils/idempotency.ts
752
1343
  import { randomUUID } from "crypto";
753
- import chalk6 from "chalk";
1344
+ import chalk7 from "chalk";
754
1345
  function resolveIdempotencyKey(flag) {
755
1346
  const given = flag?.trim();
756
1347
  if (given) return { key: given, generated: false };
757
1348
  return { key: `cli-${randomUUID()}`, generated: true };
758
1349
  }
759
- function idempotencyKeyFor(flag, what) {
1350
+ function idempotencyKeyFor(flag, what, apiKey) {
760
1351
  const { key, generated } = resolveIdempotencyKey(flag);
1352
+ if (generated && apiKey !== void 0 && requiresLiveConfirmation(apiKey)) {
1353
+ const message = `Refusing to send this ${what} with a live key without --idempotency-key. Choose one key for this ${what} (for example your order id plus the action, such as "order_1042:${what}") and pass the SAME key if you ever run the command again: a new key is a new action and can move money twice. Nothing was sent.`;
1354
+ auditNote({ outcome: "refused_local", code: "idempotency_key_required" });
1355
+ exitWithCliError(message, chalk7.red(message));
1356
+ }
761
1357
  if (generated) {
762
1358
  console.error(
763
- chalk6.dim(
1359
+ chalk7.dim(
764
1360
  `Idempotency key: ${key} (to retry this same ${what}, re-run with --idempotency-key ${key})`
765
1361
  )
766
1362
  );
767
1363
  }
768
1364
  return key;
769
1365
  }
1366
+ var DISPLAYABLE_KEY = /^[A-Za-z0-9_:.-]{1,64}$/;
1367
+ function displayIdempotencyKey(key) {
1368
+ return DISPLAYABLE_KEY.test(key) ? key : "[custom key withheld]";
1369
+ }
770
1370
 
771
1371
  // src/utils/metadata.ts
772
1372
  var MAX_LEN = 256;
@@ -807,80 +1407,268 @@ function parseMetadata(pairs) {
807
1407
  }
808
1408
  return out;
809
1409
  }
810
-
811
- // src/utils/live-key.ts
812
- import { createInterface as createInterface2 } from "readline";
813
- import chalk7 from "chalk";
814
- import { Option as Option2 } from "commander";
815
- import { isLiveKey as isLiveKey2 } from "@vonpay/checkout-node";
816
- var CONFIRM_WORD = "confirm";
817
- var terminal = {
818
- /**
819
- * Both stdin and stdout are a terminal. A command run by a script, a CI job or
820
- * an AI agent's shell tool has at least one of them piped.
821
- */
822
- isInteractive() {
823
- return process.stdin.isTTY === true && process.stdout.isTTY === true;
824
- },
825
- /** Ask one line on the terminal. End of input or Ctrl-C answers "" (cancel). */
826
- ask(prompt) {
827
- return new Promise((resolve2) => {
828
- const rl = createInterface2({ input: process.stdin, output: process.stdout });
829
- let answered = false;
830
- rl.on("SIGINT", () => rl.close());
831
- rl.on("close", () => {
832
- if (!answered) resolve2("");
833
- });
834
- rl.question(prompt, (answer) => {
835
- answered = true;
836
- rl.close();
837
- resolve2(answer);
838
- });
839
- });
1410
+ var METADATA_FILTER_KEY = /^[A-Za-z0-9_.:-]{1,64}$/;
1411
+ var METADATA_FILTER_VALUE_MAX = 500;
1412
+ function assertMetadataFilterKey(key, flag) {
1413
+ if (!METADATA_FILTER_KEY.test(key)) {
1414
+ throw new Error(
1415
+ `${flag}: the metadata key must be 1-64 letters, digits, _ . : or - (the API's pattern ^[A-Za-z0-9_.:-]{1,64}$); got ${JSON.stringify(key.slice(0, 80))}.`
1416
+ );
840
1417
  }
841
- };
842
- function keyFingerprint(key) {
843
- const m = key.match(/^(vp_sk_live_)([A-Za-z0-9_-]{0,4})/);
844
- return m ? `${m[1]}${m[2]}\u2026` : "a key that is not a sandbox key";
1418
+ return key;
845
1419
  }
846
- async function confirmLiveAction(key, summary, opts = {}) {
847
- if (!requiresLiveConfirmation(key)) return;
848
- if (!terminal.isInteractive()) {
849
- const message = `Refusing to ${summary.action} with a live key: no person is at an interactive terminal to confirm it. A live-key action must be confirmed by a person typing at a terminal (stdin and stdout both a terminal); scripts, CI jobs and AI agents cannot confirm it, and --confirm-live is no longer enough. Use a sandbox key (vp_sk_test_\u2026) for automation, or run the command yourself in a terminal. Nothing was sent.`;
850
- exitWithCliError(message, chalk7.red(message));
1420
+ function parseMetadataFilter(raw, flag) {
1421
+ const idx = raw.indexOf("=");
1422
+ if (idx === -1) throw new Error(`${flag}: expected key=value (ONE tag); got ${JSON.stringify(raw.slice(0, 80))}.`);
1423
+ const key = assertMetadataFilterKey(raw.slice(0, idx), flag);
1424
+ const value = raw.slice(idx + 1);
1425
+ if (value.length < 1 || value.length > METADATA_FILTER_VALUE_MAX) {
1426
+ throw new Error(`${flag}: the value must be 1-${METADATA_FILTER_VALUE_MAX} characters; got ${value.length}.`);
851
1427
  }
852
- if (opts.confirmLive) {
853
- console.warn(
854
- chalk7.yellow(
855
- "Note: --confirm-live no longer confirms a live action by itself. A person confirms it at the terminal."
856
- )
1428
+ return { [key]: value };
1429
+ }
1430
+
1431
+ // src/commands/payment-reports.ts
1432
+ import chalk8 from "chalk";
1433
+ import { formatMinorUnits as formatMinorUnits2 } from "@vonpay/checkout-node";
1434
+
1435
+ // src/utils/list-flag.ts
1436
+ function parseListFlag(raw, flag, options = {}) {
1437
+ const items = raw.split(",").map((s) => options.lowercase ? s.trim().toLowerCase() : s.trim());
1438
+ if (items.length === 0 || items.some((s) => s.length === 0)) {
1439
+ throw new Error(`${flag} must be a comma-separated list with no empty items; got ${JSON.stringify(raw)}.`);
1440
+ }
1441
+ return [...new Set(items)];
1442
+ }
1443
+
1444
+ // src/commands/payment-reports.ts
1445
+ function buildFilters(opts) {
1446
+ const filters = {};
1447
+ if (opts.createdGte !== void 0) filters.createdGte = opts.createdGte;
1448
+ if (opts.createdLt !== void 0) filters.createdLt = opts.createdLt;
1449
+ if (opts.outcome !== void 0) filters.outcome = parseListFlag(opts.outcome, "--outcome", { lowercase: true });
1450
+ if (opts.store !== void 0) filters.store = opts.store;
1451
+ if (opts.metadata !== void 0) filters.metadata = parseMetadataFilter(opts.metadata, "--metadata");
1452
+ if (opts.cardBrand !== void 0) {
1453
+ filters.cardBrand = parseListFlag(opts.cardBrand, "--card-brand", { lowercase: true });
1454
+ }
1455
+ if (opts.currency !== void 0) filters.currency = opts.currency.toUpperCase();
1456
+ if (opts.initiator !== void 0) {
1457
+ const initiator = opts.initiator.toLowerCase();
1458
+ if (initiator !== "customer" && initiator !== "merchant") {
1459
+ throw new Error(`--initiator must be customer or merchant; got ${JSON.stringify(opts.initiator)}.`);
1460
+ }
1461
+ filters.initiator = initiator;
1462
+ }
1463
+ if (opts.declineCode !== void 0) {
1464
+ filters.declineCode = parseListFlag(opts.declineCode, "--decline-code", { lowercase: true });
1465
+ }
1466
+ return filters;
1467
+ }
1468
+ function wholeNumber2(raw, flag) {
1469
+ const parsed = parseMinorUnits(raw, flag);
1470
+ if (!parsed.ok || parsed.value < 1) throw new Error(`${flag} must be a whole number of at least 1; got ${JSON.stringify(raw)}.`);
1471
+ return parsed.value;
1472
+ }
1473
+ var PAYMENT_LIST_JSON_FIELDS = [
1474
+ "id",
1475
+ "status",
1476
+ "outcome",
1477
+ "failureClass",
1478
+ "amount",
1479
+ "currency",
1480
+ "captureMethod",
1481
+ "createdAt",
1482
+ "updatedAt",
1483
+ "store",
1484
+ "card",
1485
+ "declineCode",
1486
+ "action",
1487
+ "ruleCode",
1488
+ "networkAdvice"
1489
+ ];
1490
+ function listItemJson(pi, includeMetadata) {
1491
+ if (includeMetadata) return pi;
1492
+ const src = pi;
1493
+ return Object.fromEntries(PAYMENT_LIST_JSON_FIELDS.filter((k) => src[k] !== void 0).map((k) => [k, src[k]]));
1494
+ }
1495
+ var ALL_JSON_DEFAULT_MAX_ITEMS = 1e4;
1496
+ function buildListParams(opts) {
1497
+ const params = buildFilters(opts);
1498
+ if (opts.status !== void 0) {
1499
+ params.status = parseListFlag(opts.status, "--status", { lowercase: true });
1500
+ }
1501
+ if (opts.limit !== void 0) params.limit = wholeNumber2(opts.limit, "--limit");
1502
+ if (opts.startingAfter !== void 0) params.startingAfter = opts.startingAfter;
1503
+ if (opts.maxItems !== void 0 && !opts.all) throw new Error("--max-items is used with --all.");
1504
+ if (opts.includeMetadata && !opts.json) throw new Error("--include-metadata is used with --json.");
1505
+ const maxItems = opts.maxItems !== void 0 ? wholeNumber2(opts.maxItems, "--max-items") : opts.all && opts.json ? ALL_JSON_DEFAULT_MAX_ITEMS : void 0;
1506
+ return { params, ...maxItems !== void 0 ? { maxItems } : {} };
1507
+ }
1508
+ function buildSummaryParams(opts) {
1509
+ const params = buildFilters(opts);
1510
+ if (opts.groupBy !== void 0) {
1511
+ params.groupBy = parseListFlag(opts.groupBy, "--group-by", { lowercase: true });
1512
+ }
1513
+ if (opts.groupByMetadataKey !== void 0) {
1514
+ params.groupByMetadataKey = assertMetadataFilterKey(opts.groupByMetadataKey, "--group-by-metadata-key");
1515
+ }
1516
+ if (opts.timeZone !== void 0) params.timeZone = opts.timeZone;
1517
+ return params;
1518
+ }
1519
+ function outcomeTone(outcome) {
1520
+ if (outcome === "approved") return chalk8.green(outcome);
1521
+ if (outcome === "authorized" || outcome === "in_progress" || outcome === "voided") return chalk8.yellow(outcome);
1522
+ return chalk8.red(outcome);
1523
+ }
1524
+ function paymentListItemLine(pi) {
1525
+ const card = pi.card.brand || pi.card.last4 ? ` ${safeInline(pi.card.brand ?? "card")} ${pi.card.last4 ? `\u2022\u2022\u2022\u2022 ${safeInline(pi.card.last4)}` : ""}`.trimEnd() : "";
1526
+ return ` ${safeInline(pi.id)} ${outcomeTone(safeInline(pi.outcome))} ${safeInline(formatMinorUnits2(pi.amount, pi.currency))} ${safeInline(pi.createdAt)}` + (pi.store ? ` ${safeInline(pi.store)}` : "") + card + (pi.declineCode ? ` ${safeInline(pi.declineCode)}` : "");
1527
+ }
1528
+ function paymentListLines(page) {
1529
+ const lines = [` Window: ${safeInline(page.created.gte)} to ${safeInline(page.created.lt)} (end exclusive)`];
1530
+ if (page.data.length === 0) lines.push(" No payments match.");
1531
+ for (const pi of page.data) lines.push(paymentListItemLine(pi));
1532
+ if (page.hasMore && page.nextCursor) {
1533
+ lines.push(
1534
+ ` More: re-run with exactly the same filters (and window, or none if you gave none) and --starting-after ${safeInline(page.nextCursor, 1100)} \u2014 the cursor lasts 24 hours; or use --all`
857
1535
  );
858
1536
  }
1537
+ return lines;
1538
+ }
1539
+ function formatRate(rate) {
1540
+ return rate === null ? "no data" : `${(rate * 100).toFixed(1)}%`;
1541
+ }
1542
+ function metricsLine(m) {
1543
+ return `attempts ${m.counts.attempts} approved ${m.counts.approved} declined ${m.counts.declined} approval ${formatRate(m.rates.approvalRate)} bank decline ${formatRate(m.rates.bankDeclineRate)} decline ${formatRate(m.rates.declineRate)}`;
1544
+ }
1545
+ function summaryLines(s) {
1546
+ const age = typeof s.ageSeconds === "number" ? `, ${s.ageSeconds} s ago` : "";
859
1547
  const lines = [
860
- chalk7.bold.red(`You are about to ${summary.action} with a LIVE key (${keyFingerprint(key)}).`),
861
- ...(summary.details ?? []).map(([label, value]) => ` ${`${label}:`.padEnd(17)}${safeDisplay(value)}`),
862
- chalk7.yellow(summary.consequence ?? "This moves real money and cannot be undone from here.")
1548
+ ` Payment attempts (as the dashboard counts them), ${safeInline(s.created.gte)} to ${safeInline(s.created.lt)}, time zone ${safeInline(s.timeZone)}`,
1549
+ ` Computed at ${safeInline(s.generatedAt)}${age} (a summary is kept 45 s: for fresh numbers change the window or wait)`,
1550
+ ` Total: ${metricsLine(s.totals)}`
863
1551
  ];
864
- for (const line of lines) console.log(line);
865
- const answer = await terminal.ask(`Type "${CONFIRM_WORD}" to proceed, anything else to cancel: `);
866
- if (answer.trim() !== CONFIRM_WORD) {
867
- const message = `Cancelled: you did not type "${CONFIRM_WORD}". Nothing was sent.`;
868
- exitWithCliError(message, chalk7.red(message));
1552
+ for (const a of s.totals.amounts) {
1553
+ lines.push(
1554
+ ` ${safeInline(a.currency)}: approved ${safeInline(formatMinorUnits2(a.approved, a.currency))}, declined ${safeInline(formatMinorUnits2(a.declined, a.currency))}`
1555
+ );
1556
+ }
1557
+ for (const g of s.groups) {
1558
+ const key = Object.entries(g.key).map(([dimension, value]) => `${safeInline(dimension)}=${value === null ? "(none)" : safeInline(value, 80)}`).join(" ");
1559
+ lines.push(` ${key}: ${metricsLine(g)}`);
869
1560
  }
1561
+ if (s.groupBy.length > 0) lines.push(" Only groups with at least one attempt are shown. Add counts to combine groups; never average rates.");
1562
+ return lines;
870
1563
  }
871
- function retiredConfirmLiveOption() {
872
- return new Option2(
873
- "--confirm-live",
874
- "No longer confirms anything: a live-key action is confirmed by a person at the terminal"
875
- ).hideHelp();
1564
+ var FILTER_HELP = {
1565
+ createdGte: "Start of the window, inclusive: Unix seconds or an ISO-8601 time with a zone (default: 31 days before the end; 7 with summary --group-by hour)",
1566
+ createdLt: "End of the window, exclusive (default: now). At most 31 days after the start (7 with summary --group-by hour)",
1567
+ outcome: "Only these outcomes, comma-separated (e.g. bank_declined,refused_before_bank)",
1568
+ store: "Only payments for this connected store hostname (e.g. shop.example.com)",
1569
+ metadata: "Only payments with this metadata tag, key=value (ONE tag; key 1-64 of letters, digits, _ . : -; value 1-500 characters)",
1570
+ cardBrand: "Only these card brands, comma-separated (visa, mastercard, amex, discover, unionpay, jcb, diners, unknown)",
1571
+ currency: "Only payments in this currency (e.g. USD)",
1572
+ initiator: "merchant (merchant-initiated charges on a saved card) or customer",
1573
+ declineCode: "Only declined payments with these decline codes, comma-separated (e.g. insufficient_funds)"
1574
+ };
1575
+ function addFilterOptions(cmd) {
1576
+ return cmd.option("--created-gte <time>", FILTER_HELP.createdGte).option("--created-lt <time>", FILTER_HELP.createdLt).option("--outcome <list>", FILTER_HELP.outcome).option("--store <hostname>", FILTER_HELP.store).option("--metadata <key=value>", FILTER_HELP.metadata).option("--card-brand <list>", FILTER_HELP.cardBrand).option("--currency <code>", FILTER_HELP.currency).option("--initiator <who>", FILTER_HELP.initiator).option("--decline-code <list>", FILTER_HELP.declineCode);
876
1577
  }
877
- function warnLiveSecretFlag(key, flag) {
878
- if (!requiresLiveConfirmation(key)) return;
879
- console.warn(
880
- chalk7.yellow(
881
- `Warning: passing ${flag} as a command argument with a live key exposes it to shell history and process-list (\`ps\`). Consider piping or env-injecting the value instead.`
1578
+ function exitOnBadFlag(err) {
1579
+ const message = err.message;
1580
+ exitWithCliError(message, chalk8.red(`Error: ${message}`));
1581
+ }
1582
+ function addPaymentReportCommands(intents) {
1583
+ addFilterOptions(
1584
+ intents.command("list").description(
1585
+ "List payment intents, newest first, with filters \u2014 raw data. For approval and decline rates use `payment-intents summary`"
882
1586
  )
883
- );
1587
+ ).option("--status <list>", "Only these statuses, comma-separated (e.g. failed,succeeded)").option("--limit <n>", "Page size, up to 100 per page (default 25; larger values are treated as 100)").option(
1588
+ "--starting-after <cursor>",
1589
+ "The next-page cursor printed by a previous run, unchanged, with exactly the same filters and window (it lasts 24 hours)"
1590
+ ).option("--all", "Read every page, one request at a time (text: printed as each payment arrives)").option("--max-items <n>", `With --all: stop after this many payments (with --json, default ${ALL_JSON_DEFAULT_MAX_ITEMS})`).option(
1591
+ "--json",
1592
+ "Output JSON: each payment's status, outcome, amount, card, store and decline fields \u2014 not your metadata (with --all: one JSON array)"
1593
+ ).option("--include-metadata", "With --json: print every field of each payment, your metadata included").action(async (opts) => {
1594
+ let built;
1595
+ try {
1596
+ built = buildListParams(opts);
1597
+ } catch (err) {
1598
+ exitOnBadFlag(err);
1599
+ }
1600
+ const client = makeCheckoutClient();
1601
+ const spinner = startSpinner("Listing payment intents...");
1602
+ try {
1603
+ if (opts.all) {
1604
+ const pager = client.paymentIntents.listAutoPaging({
1605
+ ...built.params,
1606
+ ...built.maxItems ? { maxItems: built.maxItems } : {}
1607
+ });
1608
+ if (opts.json) {
1609
+ const items = [];
1610
+ for await (const pi of pager) items.push(listItemJson(pi, opts.includeMetadata));
1611
+ spinner.stop();
1612
+ console.log(JSON.stringify(items, null, 2));
1613
+ if (opts.maxItems === void 0 && items.length >= ALL_JSON_DEFAULT_MAX_ITEMS) {
1614
+ console.error(
1615
+ `Stopped at ${ALL_JSON_DEFAULT_MAX_ITEMS} payments (the --all --json default). Pass --max-items to read more, or narrow the window.`
1616
+ );
1617
+ }
1618
+ return;
1619
+ }
1620
+ let count = 0;
1621
+ for await (const pi of pager) {
1622
+ if (count === 0) spinner.stop();
1623
+ console.log(paymentListItemLine(pi));
1624
+ count += 1;
1625
+ }
1626
+ spinner.stop();
1627
+ if (count === 0) console.log(" No payments match.");
1628
+ console.log(` ${count} payment(s).`);
1629
+ return;
1630
+ }
1631
+ const page = await client.paymentIntents.list(built.params);
1632
+ spinner.stop();
1633
+ if (opts.json) {
1634
+ console.log(JSON.stringify({ ...page, data: page.data.map((pi) => listItemJson(pi, opts.includeMetadata)) }, null, 2));
1635
+ return;
1636
+ }
1637
+ for (const line of paymentListLines(page)) console.log(line);
1638
+ } catch (err) {
1639
+ spinner.stop();
1640
+ handleError(err);
1641
+ }
1642
+ });
1643
+ addFilterOptions(
1644
+ intents.command("summary").description(
1645
+ "Approval and decline rates, counts and amounts for a window, as the dashboard counts payment attempts. A rate with no traffic shows as no data, never 0%. A summary is kept 45 s: for fresh numbers, change the window or wait"
1646
+ )
1647
+ ).option("--group-by <list>", "Up to 3 of hour, day, week, store, metadata, card_brand, currency, outcome, decline_code (one time unit)").option(
1648
+ "--group-by-metadata-key <key>",
1649
+ "With --group-by metadata: the metadata key whose values become groups (1-64 of letters, digits, _ . : -)"
1650
+ ).option("--time-zone <iana>", "Time zone for hour / day / week buckets (default UTC), e.g. America/New_York").option("--json", "Output raw JSON").action(async (opts) => {
1651
+ let params;
1652
+ try {
1653
+ params = buildSummaryParams(opts);
1654
+ } catch (err) {
1655
+ exitOnBadFlag(err);
1656
+ }
1657
+ const client = makeCheckoutClient();
1658
+ const spinner = startSpinner("Summarizing payments...");
1659
+ try {
1660
+ const summary = await client.paymentIntents.summary(params);
1661
+ spinner.stop();
1662
+ if (opts.json) {
1663
+ console.log(JSON.stringify(summary, null, 2));
1664
+ return;
1665
+ }
1666
+ for (const line of summaryLines(summary)) console.log(line);
1667
+ } catch (err) {
1668
+ spinner.stop();
1669
+ handleError(err);
1670
+ }
1671
+ });
884
1672
  }
885
1673
 
886
1674
  // src/commands/payment-intents.ts
@@ -959,28 +1747,28 @@ function statusTone(status) {
959
1747
  return "gray";
960
1748
  }
961
1749
  function printPaymentIntent(intent) {
962
- const statusColor = chalk8[statusTone(intent.status)];
1750
+ const statusColor = chalk9[statusTone(intent.status)];
963
1751
  console.log(` ID: ${intent.id}`);
964
1752
  console.log(` Status: ${statusColor(intent.status)}`);
965
1753
  console.log(
966
- ` Amount: ${formatMinorUnits2(intent.amount, intent.currency)}`
1754
+ ` Amount: ${formatMinorUnits3(intent.amount, intent.currency)}`
967
1755
  );
968
1756
  console.log(` Capture: ${intent.captureMethod}`);
969
1757
  if (intent.nextAction) {
970
1758
  console.log(
971
- ` Next action: ${chalk8.yellow(safeDisplay(intent.nextAction.type))}`
1759
+ ` Next action: ${chalk9.yellow(safeDisplay(intent.nextAction.type))}`
972
1760
  );
973
1761
  if (intent.nextAction.type === "redirect_to_url") {
974
1762
  console.log(
975
- ` Redirect: ${chalk8.yellow(safeDisplay(intent.nextAction.redirectToUrl.url))}`
1763
+ ` Redirect: ${chalk9.yellow(safeDisplay(intent.nextAction.redirectToUrl.url))}`
976
1764
  );
977
1765
  }
978
1766
  }
979
1767
  if (intent.declineCode) {
980
- console.log(` Decline code: ${chalk8.red(safeDisplay(intent.declineCode))}`);
1768
+ console.log(` Decline code: ${chalk9.red(safeDisplay(intent.declineCode))}`);
981
1769
  }
982
1770
  if (intent.networkAdvice) {
983
- console.log(` Network advice: ${chalk8.red(safeInline(intent.networkAdvice))}`);
1771
+ console.log(` Network advice: ${chalk9.red(safeInline(intent.networkAdvice))}`);
984
1772
  if (typeof intent.networkAdviceRetryAfterMs === "number") {
985
1773
  console.log(` Retry after: ${intent.networkAdviceRetryAfterMs} ms (earliest)`);
986
1774
  }
@@ -1018,14 +1806,14 @@ function paymentIntentsCommand() {
1018
1806
  "Your customer reference. Send on EVERY saved-card charge \u2014 it is what makes the server reject a charge against the wrong customer's stored card"
1019
1807
  ).option("--buyer-email <email>", "Buyer email for this charge").option(
1020
1808
  "--idempotency-key <key>",
1021
- "Idempotency key for this charge. Reuse the SAME key to retry the same charge safely. Generated (and printed) when omitted"
1809
+ "Idempotency key for this charge. Reuse the SAME key to retry the same charge safely. REQUIRED on a live key; on a sandbox key generated (and printed) when omitted"
1022
1810
  ).addOption(retiredConfirmLiveOption()).option("--json", "Output raw JSON").action(async (raw) => {
1023
1811
  const apiKey = requireApiKey();
1024
1812
  const opts = { ...raw, amount: minorUnitsOrExit(raw.amount, "--amount") };
1025
1813
  const captureMethod = opts.captureMethod?.toLowerCase();
1026
1814
  if (captureMethod !== void 0 && captureMethod !== "automatic" && captureMethod !== "manual") {
1027
1815
  const message = `Invalid --capture-method "${opts.captureMethod}". Expected automatic or manual.`;
1028
- exitWithCliError(message, chalk8.red(message));
1816
+ exitWithCliError(message, chalk9.red(message));
1029
1817
  }
1030
1818
  let params;
1031
1819
  try {
@@ -1036,19 +1824,19 @@ function paymentIntentsCommand() {
1036
1824
  handleError(err);
1037
1825
  }
1038
1826
  refuseUnsafeLiveDestination(apiKey);
1039
- const idempotencyKey = idempotencyKeyFor(opts.idempotencyKey, "charge");
1827
+ const idempotencyKey = idempotencyKeyFor(opts.idempotencyKey, "charge", apiKey);
1040
1828
  await confirmLiveAction(
1041
1829
  apiKey,
1042
1830
  {
1043
1831
  action: "create a payment intent (charge a payment)",
1044
1832
  details: [
1045
1833
  ...apiHostDetail(),
1046
- ["Amount", formatMinorUnits2(params.amount, params.currency)],
1834
+ ["Amount", formatMinorUnits3(params.amount, params.currency)],
1047
1835
  ["Capture", params.captureMethod ?? "automatic"],
1048
1836
  ...params.paymentMethod ? [["Saved card", params.paymentMethod.id]] : [],
1049
1837
  ...params.buyerId ? [["Buyer", params.buyerId]] : [],
1050
1838
  ...params.sessionId ? [["Session", params.sessionId]] : [],
1051
- ["Idempotency key", idempotencyKey]
1839
+ ["Idempotency key", displayIdempotencyKey(idempotencyKey)]
1052
1840
  ]
1053
1841
  },
1054
1842
  opts
@@ -1063,7 +1851,7 @@ function paymentIntentsCommand() {
1063
1851
  if (opts.json) {
1064
1852
  console.log(JSON.stringify(intent, null, 2));
1065
1853
  } else {
1066
- console.log(chalk8.green("\u2713 Payment intent created"));
1854
+ console.log(chalk9.green("\u2713 Payment intent created"));
1067
1855
  printPaymentIntent(intent);
1068
1856
  }
1069
1857
  } catch (err) {
@@ -1094,7 +1882,7 @@ function paymentIntentsCommand() {
1094
1882
  "Partial capture amount in minor units, a whole number (omit for full)"
1095
1883
  ).option(
1096
1884
  "--idempotency-key <key>",
1097
- "Idempotency key. Reuse the SAME key to retry safely. Generated (and printed) when omitted"
1885
+ "Idempotency key. Reuse the SAME key to retry safely. REQUIRED on a live key; on a sandbox key generated (and printed) when omitted"
1098
1886
  ).addOption(retiredConfirmLiveOption()).option("--json", "Output raw JSON").action(
1099
1887
  async (id, opts) => {
1100
1888
  const apiKey = requireApiKey();
@@ -1103,7 +1891,7 @@ function paymentIntentsCommand() {
1103
1891
  "--amount-to-capture"
1104
1892
  );
1105
1893
  refuseUnsafeLiveDestination(apiKey);
1106
- const idempotencyKey = idempotencyKeyFor(opts.idempotencyKey, "capture");
1894
+ const idempotencyKey = idempotencyKeyFor(opts.idempotencyKey, "capture", apiKey);
1107
1895
  await confirmLiveAction(
1108
1896
  apiKey,
1109
1897
  {
@@ -1115,7 +1903,7 @@ function paymentIntentsCommand() {
1115
1903
  "Amount",
1116
1904
  amountToCapture !== void 0 ? `${amountToCapture} in minor units (partial capture)` : "the full authorized amount"
1117
1905
  ],
1118
- ["Idempotency key", idempotencyKey]
1906
+ ["Idempotency key", displayIdempotencyKey(idempotencyKey)]
1119
1907
  ]
1120
1908
  },
1121
1909
  opts
@@ -1132,8 +1920,8 @@ function paymentIntentsCommand() {
1132
1920
  if (opts.json) {
1133
1921
  console.log(JSON.stringify(intent, null, 2));
1134
1922
  } else {
1135
- const captureLabel = amountToCapture !== void 0 ? ` (partial: ${formatMinorUnits2(amountToCapture, intent.currency)})` : "";
1136
- console.log(chalk8.green(`\u2713 Payment intent captured${captureLabel}`));
1923
+ const captureLabel = amountToCapture !== void 0 ? ` (partial: ${formatMinorUnits3(amountToCapture, intent.currency)})` : "";
1924
+ console.log(chalk9.green(`\u2713 Payment intent captured${captureLabel}`));
1137
1925
  printPaymentIntent(intent);
1138
1926
  }
1139
1927
  } catch (err) {
@@ -1144,12 +1932,12 @@ function paymentIntentsCommand() {
1144
1932
  );
1145
1933
  intents.command("void").description("Void an authorized (uncaptured) payment intent").argument("<id>", "Payment intent ID (vpi_*)").option(
1146
1934
  "--idempotency-key <key>",
1147
- "Idempotency key. Reuse the SAME key to retry safely. Generated (and printed) when omitted"
1935
+ "Idempotency key. Reuse the SAME key to retry safely. REQUIRED on a live key; on a sandbox key generated (and printed) when omitted"
1148
1936
  ).addOption(retiredConfirmLiveOption()).option("--json", "Output raw JSON").action(
1149
1937
  async (id, opts) => {
1150
1938
  const apiKey = requireApiKey();
1151
1939
  refuseUnsafeLiveDestination(apiKey);
1152
- const idempotencyKey = idempotencyKeyFor(opts.idempotencyKey, "void");
1940
+ const idempotencyKey = idempotencyKeyFor(opts.idempotencyKey, "void", apiKey);
1153
1941
  await confirmLiveAction(
1154
1942
  apiKey,
1155
1943
  {
@@ -1157,7 +1945,7 @@ function paymentIntentsCommand() {
1157
1945
  details: [
1158
1946
  ...apiHostDetail(),
1159
1947
  ["Payment intent", id],
1160
- ["Idempotency key", idempotencyKey]
1948
+ ["Idempotency key", displayIdempotencyKey(idempotencyKey)]
1161
1949
  ],
1162
1950
  consequence: "A voided payment cannot be captured afterwards; this cannot be undone from here."
1163
1951
  },
@@ -1171,7 +1959,7 @@ function paymentIntentsCommand() {
1171
1959
  if (opts.json) {
1172
1960
  console.log(JSON.stringify(intent, null, 2));
1173
1961
  } else {
1174
- console.log(chalk8.green("\u2713 Payment intent voided"));
1962
+ console.log(chalk9.green("\u2713 Payment intent voided"));
1175
1963
  printPaymentIntent(intent);
1176
1964
  }
1177
1965
  } catch (err) {
@@ -1180,13 +1968,14 @@ function paymentIntentsCommand() {
1180
1968
  }
1181
1969
  }
1182
1970
  );
1971
+ addPaymentReportCommands(intents);
1183
1972
  return intents;
1184
1973
  }
1185
1974
 
1186
1975
  // src/commands/refunds.ts
1187
1976
  import { Command as Command6 } from "commander";
1188
- import chalk9 from "chalk";
1189
- import { formatMinorUnits as formatMinorUnits3 } from "@vonpay/checkout-node";
1977
+ import chalk10 from "chalk";
1978
+ import { formatMinorUnits as formatMinorUnits4, VonPayError as VonPayError2 } from "@vonpay/checkout-node";
1190
1979
  function present(v) {
1191
1980
  return v !== void 0 && v.trim() !== "";
1192
1981
  }
@@ -1214,8 +2003,83 @@ function buildRefundParams(opts, metadata) {
1214
2003
  if (metadata !== void 0) params.metadata = metadata;
1215
2004
  return params;
1216
2005
  }
2006
+ var TRANSACTION_NEEDS_SESSION_MESSAGE = "Refusing a live-key refund by --transaction without --session-id: the transaction is checked against the session it belongs to (it must be that session's single payment) before you are asked to confirm. Prefer --payment-intent when the payment has one. Nothing was sent.";
2007
+ var SESSION_WITH_PAYMENT_INTENT_MESSAGE = "--session-id is only used with --transaction. Refund a payment intent with --payment-intent alone. Nothing was sent.";
2008
+ function refundSessionProblem(target, sessionId, live) {
2009
+ const hasSession = present(sessionId);
2010
+ if ("paymentIntent" in target && hasSession) return SESSION_WITH_PAYMENT_INTENT_MESSAGE;
2011
+ if ("transaction" in target && live && !hasSession) return TRANSACTION_NEEDS_SESSION_MESSAGE;
2012
+ return null;
2013
+ }
2014
+ function errorCodeSuffix(err) {
2015
+ const code = err instanceof VonPayError2 ? err.code : void 0;
2016
+ return typeof code === "string" && /^[a-z_]{1,64}$/.test(code) ? ` (error code: ${code})` : "";
2017
+ }
2018
+ function refuseLookup(message) {
2019
+ auditNote({ outcome: "lookup_refused", code: "lookup_failed" });
2020
+ return exitWithCliError(message, chalk10.red(message));
2021
+ }
2022
+ async function refundLookupDetails(client, target, sessionId, amount, namedCurrency) {
2023
+ const lines = [];
2024
+ let currency;
2025
+ if ("paymentIntent" in target) {
2026
+ let intent;
2027
+ try {
2028
+ intent = await client.paymentIntents.retrieve(target.paymentIntent);
2029
+ } catch (err) {
2030
+ refuseLookup(`Refusing the refund: the payment intent could not be looked up${errorCodeSuffix(err)}, so it cannot be shown to you before you confirm. Nothing was sent.`);
2031
+ }
2032
+ currency = intent.currency;
2033
+ lines.push(
2034
+ ["Original payment", formatMinorUnits4(intent.amount, intent.currency)],
2035
+ ["Status", String(intent.status)],
2036
+ ["Created", String(intent.createdAt)]
2037
+ );
2038
+ } else {
2039
+ let session;
2040
+ try {
2041
+ session = await client.sessions.get(sessionId);
2042
+ } catch (err) {
2043
+ refuseLookup(`Refusing the refund: the session could not be looked up${errorCodeSuffix(err)}, so the transaction cannot be checked against it. Nothing was sent.`);
2044
+ }
2045
+ if (session.vp_tx_count !== 1) {
2046
+ refuseLookup(
2047
+ typeof session.vp_tx_count === "number" && session.vp_tx_count >= 2 ? "Refusing the refund: this session has more than one payment \u2014 the buyer may have been charged more than once. Do not refund anything; have a person check the payments. Nothing was sent." : "Refusing the refund: the session does not show exactly one captured payment, so the transaction cannot be checked against it. Nothing was sent."
2048
+ );
2049
+ }
2050
+ if (session.vp_tx_id !== target.transaction) {
2051
+ refuseLookup("Refusing the refund: that transaction is not this session's payment. Check the order in your own records. Nothing was sent.");
2052
+ }
2053
+ currency = session.currency;
2054
+ lines.push(
2055
+ ["Session total", formatMinorUnits4(session.amount, session.currency)],
2056
+ ["Payment", String(session.paymentStatus ?? "not reported")],
2057
+ ["Created", String(session.createdAt)]
2058
+ );
2059
+ }
2060
+ let totals;
2061
+ try {
2062
+ totals = await client.refunds.list(target, { limit: 1 });
2063
+ } catch (err) {
2064
+ refuseLookup(`Refusing the refund: the payment's earlier refunds could not be looked up${errorCodeSuffix(err)}, so what was already refunded cannot be shown to you. Nothing was sent.`);
2065
+ }
2066
+ lines.push(
2067
+ ["Already refunded", formatMinorUnits4(totals.amountRefunded, currency)],
2068
+ [
2069
+ "Still refundable",
2070
+ `${formatMinorUnits4(totals.remainingRefundable, currency)}${totals.refundable ? "" : " \u2014 Von Payments says this payment cannot be refunded now"}`
2071
+ ]
2072
+ );
2073
+ if (amount !== void 0 && amount > totals.remainingRefundable) {
2074
+ lines.push(["\u26A0 Check", "the amount to refund is MORE than what is still refundable"]);
2075
+ }
2076
+ if (namedCurrency !== void 0 && typeof currency === "string" && namedCurrency.toUpperCase() !== currency.toUpperCase()) {
2077
+ lines.push(["\u26A0 Check", "the currency named for this refund is NOT the payment's currency looked up above"]);
2078
+ }
2079
+ return lines;
2080
+ }
1217
2081
  function printRefund(refund) {
1218
- const statusColor = refund.status === "succeeded" ? chalk9.green : refund.status === "failed" ? chalk9.red : chalk9.yellow;
2082
+ const statusColor = refund.status === "succeeded" ? chalk10.green : refund.status === "failed" ? chalk10.red : chalk10.yellow;
1219
2083
  console.log(` ID: ${refund.id}`);
1220
2084
  if (refund.transaction !== null) {
1221
2085
  console.log(` Transaction: ${refund.transaction}`);
@@ -1224,7 +2088,7 @@ function printRefund(refund) {
1224
2088
  }
1225
2089
  console.log(` Status: ${statusColor(refund.status)}`);
1226
2090
  console.log(
1227
- ` Amount: ${formatMinorUnits3(refund.amount, refund.currency)}`
2091
+ ` Amount: ${formatMinorUnits4(refund.amount, refund.currency)}`
1228
2092
  );
1229
2093
  if (refund.reason) {
1230
2094
  console.log(` Reason: ${safeDisplay(refund.reason)}`);
@@ -1248,15 +2112,18 @@ function refundsCommand() {
1248
2112
  ).option("--currency <code>", "ISO 4217 currency code").option("--reason <text>", "Free-form reason (mirrored back on the record)").option(
1249
2113
  "--metadata <pair...>",
1250
2114
  "Metadata key=value (repeat flag for multiple pairs)"
2115
+ ).option(
2116
+ "--session-id <id>",
2117
+ "With --transaction: the checkout session this payment belongs to (vp_cs_*). REQUIRED with --transaction on a live key: the transaction is checked against it (the session's single payment) before you are asked to confirm"
1251
2118
  ).option(
1252
2119
  "--idempotency-key <key>",
1253
- "Idempotency key. Reuse the SAME key to retry safely. Generated (and printed) when omitted"
2120
+ "Idempotency key. Reuse the SAME key to retry safely. REQUIRED on a live key; on a sandbox key generated (and printed) when omitted"
1254
2121
  ).addOption(retiredConfirmLiveOption()).option("--json", "Output raw JSON").action(async (raw) => {
1255
2122
  try {
1256
2123
  resolveRefundTarget(raw);
1257
2124
  } catch (err) {
1258
2125
  const message = err.message;
1259
- exitWithCliError(message, chalk9.red(`Error: ${message}`));
2126
+ exitWithCliError(message, chalk10.red(`Error: ${message}`));
1260
2127
  }
1261
2128
  const apiKey = requireApiKey();
1262
2129
  const opts = {
@@ -1270,8 +2137,11 @@ function refundsCommand() {
1270
2137
  handleError(err);
1271
2138
  }
1272
2139
  const target = resolveRefundTarget(opts);
2140
+ const sessionProblem = refundSessionProblem(target, opts.sessionId, requiresLiveConfirmation(apiKey));
2141
+ if (sessionProblem) exitWithCliError(sessionProblem, chalk10.red(sessionProblem));
1273
2142
  refuseUnsafeLiveDestination(apiKey);
1274
- const idempotencyKey = idempotencyKeyFor(opts.idempotencyKey, "refund");
2143
+ const idempotencyKey = idempotencyKeyFor(opts.idempotencyKey, "refund", apiKey);
2144
+ const lookedUp = requiresLiveConfirmation(apiKey) && terminal.isInteractive() ? await refundLookupDetails(makeCheckoutClient(), target, opts.sessionId?.trim(), params.amount, params.currency) : [];
1275
2145
  await confirmLiveAction(
1276
2146
  apiKey,
1277
2147
  {
@@ -1281,9 +2151,10 @@ function refundsCommand() {
1281
2151
  "transaction" in target ? ["Transaction", target.transaction] : ["Payment intent", target.paymentIntent],
1282
2152
  [
1283
2153
  "Amount",
1284
- params.amount === void 0 ? "the full remaining balance" : params.currency ? formatMinorUnits3(params.amount, params.currency) : `${params.amount} in minor units`
2154
+ params.amount === void 0 ? "the full remaining balance" : params.currency ? formatMinorUnits4(params.amount, params.currency) : `${params.amount} in minor units`
1285
2155
  ],
1286
- ["Idempotency key", idempotencyKey]
2156
+ ...lookedUp,
2157
+ ["Idempotency key", displayIdempotencyKey(idempotencyKey)]
1287
2158
  ],
1288
2159
  consequence: "This pays money back out of the merchant's balance and cannot be reversed once settled."
1289
2160
  },
@@ -1297,7 +2168,7 @@ function refundsCommand() {
1297
2168
  if (opts.json) {
1298
2169
  console.log(JSON.stringify(refund, null, 2));
1299
2170
  } else {
1300
- console.log(chalk9.green("\u2713 Refund created"));
2171
+ console.log(chalk10.green("\u2713 Refund created"));
1301
2172
  printRefund(refund);
1302
2173
  }
1303
2174
  } catch (err) {
@@ -1314,14 +2185,14 @@ function refundsCommand() {
1314
2185
  target = resolveRefundTarget(opts);
1315
2186
  } catch (err) {
1316
2187
  const message = err.message.replace("A refund target is required", "A payment is required");
1317
- exitWithCliError(message, chalk9.red(`Error: ${message}`));
2188
+ exitWithCliError(message, chalk10.red(`Error: ${message}`));
1318
2189
  }
1319
2190
  let limit;
1320
2191
  if (opts.limit !== void 0) {
1321
2192
  const parsed = parseMinorUnits(opts.limit, "--limit");
1322
2193
  if (!parsed.ok || parsed.value < 1) {
1323
2194
  const message = `--limit must be a whole number from 1 to 100; got ${JSON.stringify(opts.limit)}.`;
1324
- exitWithCliError(message, chalk9.red(`Error: ${message}`));
2195
+ exitWithCliError(message, chalk10.red(`Error: ${message}`));
1325
2196
  }
1326
2197
  limit = parsed.value;
1327
2198
  }
@@ -1348,7 +2219,7 @@ function refundsCommand() {
1348
2219
  }
1349
2220
  function refundListLines(list) {
1350
2221
  const currency = list.data[0]?.currency;
1351
- const money = (n) => currency ? formatMinorUnits3(n, currency) : `${n} in minor units`;
2222
+ const money = (n) => currency ? formatMinorUnits4(n, currency) : `${n} in minor units`;
1352
2223
  const lines = [
1353
2224
  ` Refunded so far: ${money(list.amountRefunded)} (succeeded + still in progress)`,
1354
2225
  ` Still refundable: ${money(list.remainingRefundable)}${list.refundable ? "" : " \u2014 this payment cannot be refunded now"}`
@@ -1357,13 +2228,13 @@ function refundListLines(list) {
1357
2228
  lines.push(" No refunds on this payment.");
1358
2229
  }
1359
2230
  for (const r of list.data) {
1360
- const status = r.status === "succeeded" ? chalk9.green(r.status) : r.status === "failed" ? chalk9.red(r.status) : chalk9.yellow(r.status);
2231
+ const status = r.status === "succeeded" ? chalk10.green(r.status) : r.status === "failed" ? chalk10.red(r.status) : chalk10.yellow(r.status);
1361
2232
  lines.push(
1362
- ` ${r.id} ${status} ${formatMinorUnits3(r.amount, r.currency)}` + (r.createdAt ? ` ${r.createdAt}` : "") + (r.idempotencyKey ? ` key ${safeDisplay(r.idempotencyKey)}` : "")
2233
+ ` ${r.id} ${status} ${formatMinorUnits4(r.amount, r.currency)}` + (r.createdAt ? ` ${r.createdAt}` : "") + (r.idempotencyKey ? ` key ${safeDisplay(r.idempotencyKey)}` : "")
1363
2234
  );
1364
2235
  }
1365
2236
  if (list.data.some((r) => r.status === "requested")) {
1366
- lines.push(chalk9.yellow(" A refund marked requested is still in progress and can still fail: do not refund again."));
2237
+ lines.push(chalk10.yellow(" A refund marked requested is still in progress and can still fail: do not refund again."));
1367
2238
  }
1368
2239
  if (list.hasMore && list.nextCursor) {
1369
2240
  lines.push(` More: re-run with --starting-after ${safeDisplay(list.nextCursor)}`);
@@ -1373,9 +2244,9 @@ function refundListLines(list) {
1373
2244
 
1374
2245
  // src/commands/tokens.ts
1375
2246
  import { Command as Command7 } from "commander";
1376
- import chalk10 from "chalk";
2247
+ import chalk11 from "chalk";
1377
2248
  function printToken(token) {
1378
- const statusColor = token.status === "active" ? chalk10.green : token.status === "expired" ? chalk10.yellow : chalk10.red;
2249
+ const statusColor = token.status === "active" ? chalk11.green : token.status === "expired" ? chalk11.yellow : chalk11.red;
1379
2250
  console.log(` ID: ${token.id}`);
1380
2251
  console.log(` Status: ${statusColor(token.status)}`);
1381
2252
  const card = formatTokenCard(token.card);
@@ -1397,7 +2268,7 @@ function tokensCommand() {
1397
2268
  const apiKey = requireApiKey();
1398
2269
  if (!opts.buyerId && !opts.providerReference) {
1399
2270
  console.warn(
1400
- chalk10.yellow(
2271
+ chalk11.yellow(
1401
2272
  "Warning: no --buyer-id or --provider-reference supplied. Providers with an iframe vault (sandbox and live alike) will fail with a 422 validation_error."
1402
2273
  )
1403
2274
  );
@@ -1423,7 +2294,7 @@ function tokensCommand() {
1423
2294
  if (opts.json) {
1424
2295
  console.log(JSON.stringify(token, null, 2));
1425
2296
  } else {
1426
- console.log(chalk10.green("\u2713 Token created"));
2297
+ console.log(chalk11.green("\u2713 Token created"));
1427
2298
  printToken(token);
1428
2299
  }
1429
2300
  } catch (err) {
@@ -1442,33 +2313,33 @@ function formatTokenCard(card) {
1442
2313
 
1443
2314
  // src/commands/capabilities.ts
1444
2315
  import { Command as Command8 } from "commander";
1445
- import chalk11 from "chalk";
2316
+ import chalk12 from "chalk";
1446
2317
  function fmtBool(v) {
1447
- return v ? chalk11.green("yes") : chalk11.dim("no");
2318
+ return v ? chalk12.green("yes") : chalk12.dim("no");
1448
2319
  }
1449
2320
  function fmtVoidPolicy(v) {
1450
2321
  switch (v) {
1451
2322
  case "supported":
1452
- return chalk11.green(v);
2323
+ return chalk12.green(v);
1453
2324
  case "rerouted_to_refund":
1454
- return chalk11.yellow(v);
2325
+ return chalk12.yellow(v);
1455
2326
  case "not_supported":
1456
- return chalk11.dim(v);
2327
+ return chalk12.dim(v);
1457
2328
  default:
1458
- return chalk11.dim(String(v));
2329
+ return chalk12.dim(String(v));
1459
2330
  }
1460
2331
  }
1461
2332
  function fmtDisputeReporting(v) {
1462
- return v === "tracked" ? chalk11.green(v) : chalk11.yellow(v);
2333
+ return v === "tracked" ? chalk12.green(v) : chalk12.yellow(v);
1463
2334
  }
1464
2335
  function fmtSubscriptionBilling(v) {
1465
- if (v === true) return chalk11.yellow("yes");
1466
- if (v === false) return chalk11.green("no");
1467
- return chalk11.yellow("unknown");
2336
+ if (v === true) return chalk12.yellow("yes");
2337
+ if (v === false) return chalk12.green("no");
2338
+ return chalk12.yellow("unknown");
1468
2339
  }
1469
2340
  function printCapabilities(caps) {
1470
2341
  const ops = caps.supportedOperations;
1471
- console.log(chalk11.bold("Supported operations"));
2342
+ console.log(chalk12.bold("Supported operations"));
1472
2343
  console.log(` auth/capture separation: ${fmtBool(ops.authCaptureSeparation)}`);
1473
2344
  console.log(` partial capture: ${fmtBool(ops.partialCapture)}`);
1474
2345
  console.log(` partial refund: ${fmtBool(ops.partialRefund)}`);
@@ -1483,17 +2354,17 @@ function printCapabilities(caps) {
1483
2354
  console.log();
1484
2355
  if (ops.disputeReporting !== "tracked") {
1485
2356
  console.log(
1486
- chalk11.yellow(
2357
+ chalk12.yellow(
1487
2358
  " \u26A0 Chargebacks on this connection do NOT reach Von Payments.\n A `succeeded` payment is not evidence it was never disputed.\n Monitor and answer disputes in your processor's own dashboard \u2014\n they carry a deadline we cannot see or report."
1488
2359
  )
1489
2360
  );
1490
2361
  console.log();
1491
2362
  }
1492
- console.log(chalk11.bold("Subscription billing"));
2363
+ console.log(chalk12.bold("Subscription billing"));
1493
2364
  console.log(` Von Pay renews for you: ${fmtSubscriptionBilling(caps.subscriptionBilling?.enabled)}`);
1494
2365
  if (caps.subscriptionBilling?.enabled !== false) {
1495
2366
  console.log(
1496
- chalk11.yellow(
2367
+ chalk12.yellow(
1497
2368
  ` \u26A0 Do not run renewals from your own system while this is not "no":
1498
2369
  Von Pay may be charging these customers' renewals itself, and both
1499
2370
  would charge them. Check again at the moment you renew.`
@@ -1501,12 +2372,12 @@ function printCapabilities(caps) {
1501
2372
  );
1502
2373
  }
1503
2374
  console.log();
1504
- console.log(chalk11.bold("Settlement currencies"));
2375
+ console.log(chalk12.bold("Settlement currencies"));
1505
2376
  console.log(
1506
- ` ${caps.settlementCurrencies.length ? caps.settlementCurrencies.join(", ") : chalk11.dim("(none reported)")}`
2377
+ ` ${caps.settlementCurrencies.length ? caps.settlementCurrencies.join(", ") : chalk12.dim("(none reported)")}`
1507
2378
  );
1508
2379
  console.log();
1509
- console.log(chalk11.bold("Rate limits"));
2380
+ console.log(chalk12.bold("Rate limits"));
1510
2381
  console.log(
1511
2382
  ` payment intents / minute: ${caps.rateLimits.paymentIntentsPerMinute}`
1512
2383
  );
@@ -1533,9 +2404,41 @@ function capabilitiesCommand() {
1533
2404
  // src/commands/trigger.ts
1534
2405
  import { Command as Command9 } from "commander";
1535
2406
  import { createHmac as createHmac2, randomUUID as randomUUID2 } from "crypto";
1536
- import chalk12 from "chalk";
2407
+ import chalk13 from "chalk";
1537
2408
  import ora2 from "ora";
1538
2409
 
2410
+ // src/utils/signing-secret.ts
2411
+ import { randomBytes } from "crypto";
2412
+ var SIGNING_SECRET_ENV = "VONPAY_CLI_SIGNING_SECRET";
2413
+ var SIGNING_SECRET_FLAG = "--signing-secret <secret>";
2414
+ function generateSigningSecret() {
2415
+ return `whsec_${randomBytes(32).toString("base64url")}`;
2416
+ }
2417
+ function pinnedSecretProblem(value, from, apiKey) {
2418
+ if (apiKey !== void 0 && apiKey !== "" && value === apiKey || /^(vp_sk_|vp_pk_|vp_key_|sk_|rk_)/.test(value)) {
2419
+ return `${from} looks like an API key. Local deliveries are never signed with an API key: use a separate secret (for example the one this command prints when you leave ${from} out).`;
2420
+ }
2421
+ if (value.length < 16 || /[\s\x00-\x1f\x7f]/.test(value)) {
2422
+ return `${from} must be at least 16 characters with no spaces or control characters.`;
2423
+ }
2424
+ return null;
2425
+ }
2426
+ function resolveSigningSecret(flag, env = process.env, apiKey) {
2427
+ if (flag !== void 0) {
2428
+ const problem = pinnedSecretProblem(flag, "--signing-secret", apiKey);
2429
+ return problem ? { error: problem } : { secret: flag, source: "flag" };
2430
+ }
2431
+ const fromEnv = env[SIGNING_SECRET_ENV];
2432
+ if (fromEnv !== void 0 && fromEnv !== "") {
2433
+ const problem = pinnedSecretProblem(fromEnv, SIGNING_SECRET_ENV, apiKey);
2434
+ return problem ? { error: problem } : { secret: fromEnv, source: "env" };
2435
+ }
2436
+ return { secret: generateSigningSecret(), source: "generated" };
2437
+ }
2438
+ function signingSecretNotice(s) {
2439
+ return s.source === "generated" ? `Signing secret for this run: ${s.secret} \u2014 verify the events with it (constructEvent(rawBody, signatureHeader, secret)). It changes every run; to keep one, pass --signing-secret or set ${SIGNING_SECRET_ENV}.` : `Signing with the secret from ${s.source === "flag" ? "--signing-secret" : SIGNING_SECRET_ENV} \u2014 verify the events with the same value.`;
2440
+ }
2441
+
1539
2442
  // src/utils/forward-to.ts
1540
2443
  var LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
1541
2444
  "localhost",
@@ -1719,7 +2622,7 @@ function warnIfUnsubscribable(event, write = (m) => console.warn(m)) {
1719
2622
  if (!UNSUBSCRIBABLE_EVENTS.includes(event)) return false;
1720
2623
  const suggestion = event === "session.succeeded" ? "charge.succeeded" : "charge.failed";
1721
2624
  write(
1722
- chalk12.yellow(
2625
+ chalk13.yellow(
1723
2626
  `\u26A0 ${event} is emitted internally but is NOT subscribable.
1724
2627
  The subscription API accepts the key, stores nothing and returns success, so a
1725
2628
  real endpoint receives this event NEVER \u2014 a handler tested only against it is
@@ -1917,10 +2820,13 @@ function triggerCommand() {
1917
2820
  return new Command9("trigger").description("Send a test webhook event to a local URL").argument("<event>", `Event type (${SUPPORTED_EVENTS.join(", ")})`).requiredOption("--url <url>", "Local webhook URL (e.g. http://localhost:3000/webhooks)").option("--session-id <id>", "Custom session ID").option("--amount <cents>", "Amount in minor units, a whole number").option("--currency <code>", "Currency code").option(
1918
2821
  "--url-tunnel",
1919
2822
  "Allow a non-loopback --url (ngrok / Cloudflare Tunnel). Without it, --url must be this machine"
2823
+ ).option(
2824
+ SIGNING_SECRET_FLAG,
2825
+ `Sign the event with this secret instead of a new random one for this run (or set ${SIGNING_SECRET_ENV}); your handler verifies with the same value`
1920
2826
  ).addOption(retiredConfirmLiveOption()).action(async (event, opts) => {
1921
2827
  if (!SUPPORTED_EVENTS.includes(event)) {
1922
2828
  console.error(
1923
- chalk12.red(
2829
+ chalk13.red(
1924
2830
  `Unknown event: ${event}
1925
2831
  Supported: ${SUPPORTED_EVENTS.join(", ")}`
1926
2832
  )
@@ -1934,15 +2840,20 @@ Supported: ${SUPPORTED_EVENTS.join(", ")}`
1934
2840
  tunnelFlag: "--url-tunnel"
1935
2841
  });
1936
2842
  if (!destination.ok) {
1937
- console.error(chalk12.red(destination.reason));
2843
+ console.error(chalk13.red(destination.reason));
1938
2844
  process.exit(1);
1939
2845
  }
1940
- if (destination.warning) console.warn(chalk12.yellow(destination.warning));
2846
+ if (destination.warning) console.warn(chalk13.yellow(destination.warning));
1941
2847
  const amount = optionalMinorUnitsOrExit(
1942
2848
  opts.amount === void 0 ? void 0 : String(opts.amount),
1943
2849
  "--amount"
1944
2850
  );
1945
2851
  const apiKey = requireApiKey();
2852
+ const signing = resolveSigningSecret(opts.signingSecret, process.env, apiKey);
2853
+ if ("error" in signing) {
2854
+ console.error(chalk13.red(signing.error));
2855
+ process.exit(1);
2856
+ }
1946
2857
  await confirmLiveAction(
1947
2858
  apiKey,
1948
2859
  {
@@ -1951,14 +2862,15 @@ Supported: ${SUPPORTED_EVENTS.join(", ")}`
1951
2862
  ["Event", event],
1952
2863
  ["Sent to", String(opts.url)]
1953
2864
  ],
1954
- consequence: "The event is signed with your LIVE key: a receiver that verifies with that key will accept it as a genuine event."
2865
+ consequence: "A synthetic event is sent to this URL, signed with this run's signing secret (never your API key)."
1955
2866
  },
1956
2867
  opts
1957
2868
  );
1958
2869
  const payload = buildPayload(event, { ...opts, amount });
1959
2870
  const body = JSON.stringify(payload);
2871
+ console.log(chalk13.dim(signingSecretNotice(signing)));
1960
2872
  const t = Math.floor(Date.now() / 1e3);
1961
- const v1 = createHmac2("sha256", apiKey).update(`${t}.${body}`).digest("hex");
2873
+ const v1 = createHmac2("sha256", signing.secret).update(`${t}.${body}`).digest("hex");
1962
2874
  const signatureHeader = `t=${t},v1=${v1}`;
1963
2875
  const spinner = ora2(`Sending ${event} to ${opts.url}...`).start();
1964
2876
  try {
@@ -1978,45 +2890,45 @@ Supported: ${SUPPORTED_EVENTS.join(", ")}`
1978
2890
  spinner.stop();
1979
2891
  if (isRedirect(response)) {
1980
2892
  console.error(
1981
- chalk12.red(
2893
+ chalk13.red(
1982
2894
  `\u2717 ${event} not delivered: ${safeDisplay(String(opts.url))} answered with a redirect (HTTP ${response.status}). It was not followed, and the signed event was not sent anywhere else. Point --url at the endpoint itself.`
1983
2895
  )
1984
2896
  );
1985
2897
  process.exit(1);
1986
2898
  }
1987
2899
  if (response.ok) {
1988
- console.log(chalk12.green(`\u2713 ${event} delivered \u2192 ${response.status}`));
2900
+ console.log(chalk13.green(`\u2713 ${event} delivered \u2192 ${response.status}`));
1989
2901
  } else {
1990
2902
  console.log(
1991
- chalk12.yellow(
2903
+ chalk13.yellow(
1992
2904
  `\u26A0 ${event} delivered but got ${response.status} response`
1993
2905
  )
1994
2906
  );
1995
2907
  }
1996
- console.log(chalk12.dim(` Event: ${payload.id} (${payload.type})`));
2908
+ console.log(chalk13.dim(` Event: ${payload.id} (${payload.type})`));
1997
2909
  console.log(
1998
- chalk12.dim(
2910
+ chalk13.dim(
1999
2911
  ` Amount: ${payload.data.amount} ${payload.data.currency}`
2000
2912
  )
2001
2913
  );
2002
2914
  console.log(
2003
- chalk12.dim(
2915
+ chalk13.dim(
2004
2916
  `
2005
2917
  Verify with: vonpay.webhooks.constructEvent(rawBody, signatureHeader, secret)`
2006
2918
  )
2007
2919
  );
2008
2920
  console.log(
2009
- chalk12.dim(
2010
- ` (local dev: 'secret' is your API key here \u2014 production webhooks are signed with your endpoint's whsec_\u2026 secret, NOT your API key)`
2921
+ chalk13.dim(
2922
+ ` ('secret' is this run's signing secret, printed above \u2014 production webhooks are signed with your endpoint's whsec_\u2026 secret, and never with your API key)`
2011
2923
  )
2012
2924
  );
2013
2925
  } catch (err) {
2014
2926
  spinner.stop();
2015
2927
  if (err instanceof Error && err.name === "TimeoutError") {
2016
- console.error(chalk12.red(`Timeout: ${opts.url} did not respond within 10s`));
2928
+ console.error(chalk13.red(`Timeout: ${opts.url} did not respond within 10s`));
2017
2929
  } else {
2018
2930
  console.error(
2019
- chalk12.red(
2931
+ chalk13.red(
2020
2932
  `Failed to deliver: ${err instanceof Error ? err.message : err}`
2021
2933
  )
2022
2934
  );
@@ -2128,9 +3040,9 @@ function resumeId(lastId) {
2128
3040
  async function* consumeSse(opts) {
2129
3041
  const initialBackoffMs = opts.initialBackoffMs ?? 1e3;
2130
3042
  const maxBackoffMs = opts.maxBackoffMs ?? 3e4;
2131
- const pending = [];
3043
+ const pending2 = [];
2132
3044
  const parser = new SseParser(
2133
- (type, data) => pending.push({ type, data, lastEventId: parser.lastId || void 0 }),
3045
+ (type, data) => pending2.push({ type, data, lastEventId: parser.lastId || void 0 }),
2134
3046
  { maxPending: MAX_PENDING_CHARS, maxIdLength: MAX_EVENT_ID_LENGTH }
2135
3047
  );
2136
3048
  let backoffMs = initialBackoffMs;
@@ -2153,7 +3065,10 @@ async function* consumeSse(opts) {
2153
3065
  response = await fetch(opts.url, {
2154
3066
  method: "GET",
2155
3067
  headers,
2156
- signal: opts.signal
3068
+ signal: opts.signal,
3069
+ // Never follow a redirect: the request carries the API key, and a 3xx
3070
+ // would re-send it to wherever the answer points.
3071
+ redirect: "manual"
2157
3072
  });
2158
3073
  } catch (err) {
2159
3074
  if (opts.signal.aborted) return;
@@ -2163,6 +3078,11 @@ async function* consumeSse(opts) {
2163
3078
  backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
2164
3079
  continue;
2165
3080
  }
3081
+ if (response.status >= 300 && response.status < 400) {
3082
+ const error = new SseRedirectError(response.status);
3083
+ onLifecycle({ kind: "fatal", error });
3084
+ throw error;
3085
+ }
2166
3086
  if (response.status === 401 || response.status === 403) {
2167
3087
  const body = await safeReadText(response);
2168
3088
  onLifecycle({ kind: "auth_failed", status: response.status, body });
@@ -2206,7 +3126,7 @@ async function* consumeSse(opts) {
2206
3126
  } catch (err) {
2207
3127
  feedError = err;
2208
3128
  }
2209
- for (const ev of pending.splice(0)) {
3129
+ for (const ev of pending2.splice(0)) {
2210
3130
  if (ev.type === "timeout") {
2211
3131
  serverInitiatedReconnect = true;
2212
3132
  } else {
@@ -2274,15 +3194,25 @@ var SseAuthError = class extends Error {
2274
3194
  status;
2275
3195
  body;
2276
3196
  };
3197
+ var SseRedirectError = class extends Error {
3198
+ constructor(status) {
3199
+ super(
3200
+ `The event stream answered with a redirect (HTTP ${status}). It was not followed, so your API key was not sent anywhere else. Check VONPAY_BASE_URL.`
3201
+ );
3202
+ this.status = status;
3203
+ this.name = "SseRedirectError";
3204
+ }
3205
+ status;
3206
+ };
2277
3207
  function sleep(ms, signal) {
2278
- return new Promise((resolve2) => {
3208
+ return new Promise((resolve3) => {
2279
3209
  const timer = setTimeout(() => {
2280
3210
  signal.removeEventListener("abort", onAbort);
2281
- resolve2();
3211
+ resolve3();
2282
3212
  }, ms);
2283
3213
  const onAbort = () => {
2284
3214
  clearTimeout(timer);
2285
- resolve2();
3215
+ resolve3();
2286
3216
  };
2287
3217
  signal.addEventListener("abort", onAbort, { once: true });
2288
3218
  });
@@ -2301,24 +3231,38 @@ function noop() {
2301
3231
  }
2302
3232
 
2303
3233
  // src/utils/listen-output.ts
2304
- import chalk13 from "chalk";
3234
+ import chalk14 from "chalk";
3235
+ function parseAttemptPayload(data) {
3236
+ let raw;
3237
+ try {
3238
+ raw = JSON.parse(data);
3239
+ } catch {
3240
+ return null;
3241
+ }
3242
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
3243
+ const r = raw;
3244
+ const str2 = (k) => typeof r[k] === "string";
3245
+ const num = (k) => typeof r[k] === "number" && Number.isFinite(r[k]);
3246
+ const ok = str2("event_id") && str2("event_type") && str2("subscription_id") && str2("timestamp") && num("attempt") && num("latency_ms") && typeof r["succeeded"] === "boolean" && typeof r["livemode"] === "boolean" && (r["response_status"] === null || num("response_status"));
3247
+ return ok ? r : null;
3248
+ }
2305
3249
  function formatAttemptHuman(payload, forward) {
2306
3250
  const ts = formatTimestamp(payload.timestamp);
2307
- const eventType = chalk13.cyan(payload.event_type.padEnd(24));
2308
- const eventId = chalk13.gray(payload.event_id.padEnd(28));
2309
- const attempt = chalk13.gray(`attempt ${payload.attempt}`);
2310
- const arrow = chalk13.gray("\u2192");
3251
+ const eventType = chalk14.cyan(safeInline(payload.event_type, 64).padEnd(24));
3252
+ const eventId = chalk14.gray(safeInline(payload.event_id, 64).padEnd(28));
3253
+ const attempt = chalk14.gray(`attempt ${payload.attempt}`);
3254
+ const arrow = chalk14.gray("\u2192");
2311
3255
  const upstream = formatUpstream(payload);
2312
- const latency = chalk13.gray(`${payload.latency_ms}ms`);
2313
- const liveBadge = payload.livemode ? chalk13.red(" [LIVE]") : "";
3256
+ const latency = chalk14.gray(`${payload.latency_ms}ms`);
3257
+ const liveBadge = payload.livemode ? chalk14.red(" [LIVE]") : "";
2314
3258
  let line = `[${ts}] ${eventType} ${eventId} ${attempt} ${arrow} ${upstream} ${latency}${liveBadge}`;
2315
3259
  if (forward && forward.attempted) {
2316
- const fwdArrow = chalk13.gray("\u2192 local");
3260
+ const fwdArrow = chalk14.gray("\u2192 local");
2317
3261
  if (forward.error) {
2318
- line += ` ${fwdArrow} ${chalk13.red(forward.error)}`;
3262
+ line += ` ${fwdArrow} ${chalk14.red(safeInline(forward.error))}`;
2319
3263
  } else if (forward.status !== null) {
2320
3264
  const fwdStatus = formatStatus(forward.status);
2321
- const fwdLatency = chalk13.gray(`${forward.latencyMs}ms`);
3265
+ const fwdLatency = chalk14.gray(`${forward.latencyMs}ms`);
2322
3266
  line += ` ${fwdArrow} ${fwdStatus} ${fwdLatency}`;
2323
3267
  }
2324
3268
  }
@@ -2327,47 +3271,47 @@ function formatAttemptHuman(payload, forward) {
2327
3271
  function formatAttemptJson(payload, forward) {
2328
3272
  const body = {
2329
3273
  type: "webhook.attempt",
2330
- event_id: payload.event_id,
2331
- event_type: payload.event_type,
2332
- subscription_id: payload.subscription_id,
3274
+ event_id: safeInline(payload.event_id, 200),
3275
+ event_type: safeInline(payload.event_type, 200),
3276
+ subscription_id: safeInline(payload.subscription_id, 200),
2333
3277
  attempt: payload.attempt,
2334
3278
  succeeded: payload.succeeded,
2335
3279
  response_status: payload.response_status,
2336
3280
  latency_ms: payload.latency_ms,
2337
- timestamp: payload.timestamp,
3281
+ timestamp: safeInline(payload.timestamp, 64),
2338
3282
  livemode: payload.livemode
2339
3283
  };
2340
3284
  if (forward && forward.attempted) {
2341
3285
  body.forward = {
2342
3286
  status: forward.status,
2343
3287
  latency_ms: forward.latencyMs,
2344
- error: forward.error
3288
+ error: forward.error === null ? null : safeInline(forward.error)
2345
3289
  };
2346
3290
  }
2347
3291
  return JSON.stringify(body);
2348
3292
  }
2349
3293
  function formatLifecycleHuman(message, kind) {
2350
- const tag = kind === "info" ? chalk13.cyan("[vonpay]") : kind === "warn" ? chalk13.yellow("[vonpay]") : chalk13.red("[vonpay]");
3294
+ const tag = kind === "info" ? chalk14.cyan("[vonpay]") : kind === "warn" ? chalk14.yellow("[vonpay]") : chalk14.red("[vonpay]");
2351
3295
  return `${tag} ${message}`;
2352
3296
  }
2353
3297
  function formatLifecycleJson(kind, extra = {}) {
2354
3298
  return JSON.stringify({ type: `cli.${kind}`, ...extra, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
2355
3299
  }
2356
3300
  function formatStatus(status) {
2357
- if (status >= 200 && status < 300) return chalk13.green(String(status));
2358
- if (status >= 400 && status < 500) return chalk13.yellow(String(status));
2359
- if (status >= 500) return chalk13.red(String(status));
2360
- return chalk13.gray(String(status));
3301
+ if (status >= 200 && status < 300) return chalk14.green(String(status));
3302
+ if (status >= 400 && status < 500) return chalk14.yellow(String(status));
3303
+ if (status >= 500) return chalk14.red(String(status));
3304
+ return chalk14.gray(String(status));
2361
3305
  }
2362
3306
  function formatUpstream(payload) {
2363
3307
  if (payload.response_status === null) {
2364
- return chalk13.red("timeout");
3308
+ return chalk14.red("timeout");
2365
3309
  }
2366
3310
  return formatStatus(payload.response_status);
2367
3311
  }
2368
3312
  function formatTimestamp(iso) {
2369
3313
  const date = new Date(iso);
2370
- if (isNaN(date.getTime())) return iso.slice(0, 19);
3314
+ if (isNaN(date.getTime())) return safeInline(iso, 19);
2371
3315
  const h = String(date.getHours()).padStart(2, "0");
2372
3316
  const m = String(date.getMinutes()).padStart(2, "0");
2373
3317
  const s = String(date.getSeconds()).padStart(2, "0");
@@ -2388,7 +3332,10 @@ function listenCommand() {
2388
3332
  "--events <type>",
2389
3333
  "Only stream attempts for the given event type. Repeat the flag for multiple types.",
2390
3334
  (value, prev) => prev ? [...prev, value] : [value]
2391
- ).option("--json", "Emit one JSON object per line on stdout instead of the colored transcript").option("--api-key <key>", "Override the API key from env / config").addOption(retiredConfirmLiveOption()).action(async (opts) => {
3335
+ ).option("--json", "Emit one JSON object per line on stdout instead of the colored transcript").option("--api-key <key>", "Override the API key from env / config").option(
3336
+ SIGNING_SECRET_FLAG,
3337
+ `With --forward-to: sign forwarded events with this secret instead of a new random one for this run (or set ${SIGNING_SECRET_ENV})`
3338
+ ).addOption(retiredConfirmLiveOption()).action(async (opts) => {
2392
3339
  const apiKey = opts.apiKey ?? requireApiKey();
2393
3340
  if (opts.apiKey !== void 0) warnLiveSecretFlag(opts.apiKey, "--api-key");
2394
3341
  const refuse = (message) => {
@@ -2411,6 +3358,12 @@ function listenCommand() {
2411
3358
  forwardUrl = verdict.url;
2412
3359
  forwardWarning = verdict.warning;
2413
3360
  }
3361
+ let signing;
3362
+ if (forwardUrl) {
3363
+ const resolved = resolveSigningSecret(opts.signingSecret, process.env, apiKey);
3364
+ if ("error" in resolved) refuse(resolved.error);
3365
+ signing = resolved;
3366
+ }
2414
3367
  const configuredBaseUrl = getBaseUrl();
2415
3368
  const destinationProblem = keyDestinationProblem(apiKey, configuredBaseUrl);
2416
3369
  if (destinationProblem) refuse(destinationProblem);
@@ -2426,7 +3379,7 @@ function listenCommand() {
2426
3379
  ["Stream from", baseUrl],
2427
3380
  ...forwardUrl ? [["Forward to", forwardUrl.toString()]] : []
2428
3381
  ],
2429
- consequence: forwardUrl ? "Live payment events, with buyer and order details, will be shown here, and each one is signed with your LIVE key and sent to the forward URL." : "Live payment events, with buyer and order details, will be shown here."
3382
+ consequence: forwardUrl ? "Live payment events, with buyer and order details, will be shown here, and each one is sent to the forward URL (signed with this run's signing secret, never your API key)." : "Live payment events, with buyer and order details, will be shown here."
2430
3383
  },
2431
3384
  opts
2432
3385
  );
@@ -2465,6 +3418,7 @@ function listenCommand() {
2465
3418
  }
2466
3419
  controller.abort();
2467
3420
  };
3421
+ auditSignalHandledByCommand();
2468
3422
  process.once("SIGINT", () => onSignal("SIGINT"));
2469
3423
  process.once("SIGTERM", () => onSignal("SIGTERM"));
2470
3424
  const onLifecycle = (event) => {
@@ -2487,12 +3441,16 @@ function listenCommand() {
2487
3441
  process.stderr.write(
2488
3442
  formatLifecycleHuman(`Ready! Listening for webhook delivery attempts${filterNote}${fwdNote}.`, "info") + "\n"
2489
3443
  );
3444
+ if (signing) process.stderr.write(formatLifecycleHuman(signingSecretNotice(signing), "info") + "\n");
2490
3445
  } else {
2491
3446
  process.stderr.write(
2492
3447
  formatLifecycleJson("ready", {
2493
3448
  base_url: baseUrl,
2494
3449
  filter: opts.events ?? null,
2495
- forward_to: forwardUrl?.toString() ?? null
3450
+ forward_to: forwardUrl?.toString() ?? null,
3451
+ // The generated secret in full (the local receiver needs it); a pinned one by source only.
3452
+ signing_secret: signing?.source === "generated" ? signing.secret : null,
3453
+ signing_secret_source: signing?.source ?? null
2496
3454
  }) + "\n"
2497
3455
  );
2498
3456
  }
@@ -2514,12 +3472,8 @@ function listenCommand() {
2514
3472
  if (sseEvent.type !== "webhook.attempt") {
2515
3473
  continue;
2516
3474
  }
2517
- let payload;
2518
- try {
2519
- payload = JSON.parse(sseEvent.data);
2520
- } catch {
2521
- continue;
2522
- }
3475
+ const payload = parseAttemptPayload(sseEvent.data);
3476
+ if (payload === null) continue;
2523
3477
  eventCount += 1;
2524
3478
  let forwardOutcome = null;
2525
3479
  if (forwardUrl && sdk) {
@@ -2527,7 +3481,7 @@ function listenCommand() {
2527
3481
  const eventRecord = await sdk.webhookEvents.retrieve(payload.event_id);
2528
3482
  forwardOutcome = await forwardPayload(eventRecord.payload, {
2529
3483
  url: forwardUrl,
2530
- signingSecret: apiKey,
3484
+ signingSecret: signing.secret,
2531
3485
  signal: controller.signal
2532
3486
  });
2533
3487
  } catch (err) {
@@ -2548,6 +3502,7 @@ function listenCommand() {
2548
3502
  }
2549
3503
  } catch (err) {
2550
3504
  if (err instanceof SseAuthError) {
3505
+ auditNote({ outcome: "error", code: "auth_failed" });
2551
3506
  if (mode === "human") {
2552
3507
  process.stderr.write(
2553
3508
  formatLifecycleHuman(
@@ -2562,6 +3517,10 @@ function listenCommand() {
2562
3517
  }
2563
3518
  process.exit(1);
2564
3519
  }
3520
+ if (err instanceof SseRedirectError) {
3521
+ if (jsonErrorsEnabled()) exitWithCliError(err.message, err.message);
3522
+ process.exit(1);
3523
+ }
2565
3524
  if (jsonErrorsEnabled()) handleError(err);
2566
3525
  throw err;
2567
3526
  }
@@ -2570,29 +3529,29 @@ function listenCommand() {
2570
3529
  function lifecyclePayload(event) {
2571
3530
  switch (event.kind) {
2572
3531
  case "connecting":
2573
- return { last_event_id: event.lastEventId ?? null };
3532
+ return { last_event_id: event.lastEventId === void 0 ? null : safeInline(event.lastEventId, 200) };
2574
3533
  case "connected":
2575
3534
  return {};
2576
3535
  case "disconnected":
2577
- return { reason: event.reason, will_retry_in_ms: event.willRetryInMs };
3536
+ return { reason: safeInline(event.reason, 400), will_retry_in_ms: event.willRetryInMs };
2578
3537
  case "auth_failed":
2579
3538
  return { status: event.status };
2580
3539
  case "fatal":
2581
- return { error: event.error.message };
3540
+ return { error: safeInline(event.error.message, 400) };
2582
3541
  }
2583
3542
  }
2584
3543
  function lifecycleHumanMessage(event) {
2585
3544
  switch (event.kind) {
2586
3545
  case "connecting":
2587
- return event.lastEventId ? `Reconnecting (resume from ${event.lastEventId})` : "Connecting...";
3546
+ return event.lastEventId ? `Reconnecting (resume from ${safeInline(event.lastEventId, 200)})` : "Connecting...";
2588
3547
  case "connected":
2589
3548
  return "Connected.";
2590
3549
  case "disconnected":
2591
- return `Disconnected: ${event.reason}. Retrying in ${event.willRetryInMs}ms.`;
3550
+ return `Disconnected: ${safeInline(event.reason, 400)}. Retrying in ${event.willRetryInMs}ms.`;
2592
3551
  case "auth_failed":
2593
3552
  return `Authentication failed (HTTP ${event.status}).`;
2594
3553
  case "fatal":
2595
- return `Fatal: ${event.error.message}`;
3554
+ return `Fatal: ${safeInline(event.error.message, 400)}`;
2596
3555
  }
2597
3556
  }
2598
3557
  function lifecycleSeverity(kind) {
@@ -2610,8 +3569,8 @@ function lifecycleSeverity(kind) {
2610
3569
 
2611
3570
  // src/commands/health.ts
2612
3571
  import { Command as Command11 } from "commander";
2613
- import chalk14 from "chalk";
2614
- import { VonPayError as VonPayError2 } from "@vonpay/checkout-node";
3572
+ import chalk15 from "chalk";
3573
+ import { VonPayError as VonPayError3 } from "@vonpay/checkout-node";
2615
3574
  function healthCommand() {
2616
3575
  return new Command11("health").description("Check checkout API health").option("--json", "Output raw JSON").action(async (opts) => {
2617
3576
  const client = makeCheckoutClient();
@@ -2622,23 +3581,24 @@ function healthCommand() {
2622
3581
  if (opts.json) {
2623
3582
  console.log(JSON.stringify(health, null, 2));
2624
3583
  } else {
2625
- const statusColor = health.status === "ok" ? chalk14.green : health.status === "degraded" ? chalk14.yellow : chalk14.red;
2626
- console.log(` Status: ${statusColor(health.status)}`);
2627
- console.log(` Latency: ${health.latencyMs}ms`);
3584
+ const statusColor = health.status === "ok" ? chalk15.green : health.status === "degraded" ? chalk15.yellow : chalk15.red;
3585
+ console.log(` Status: ${statusColor(safeInline(String(health.status)))}`);
3586
+ console.log(` Latency: ${safeInline(String(health.latencyMs))}ms`);
2628
3587
  if (health.version) {
2629
- console.log(` Version: ${health.version}`);
3588
+ console.log(` Version: ${safeInline(String(health.version))}`);
2630
3589
  }
2631
3590
  }
2632
3591
  } catch (err) {
2633
3592
  spinner.stop();
3593
+ auditError(err);
2634
3594
  if (jsonErrorsEnabled()) handleError(err);
2635
- if (err instanceof VonPayError2) {
2636
- console.error(chalk14.red(`API error: ${scrubSecrets(err.message)}`));
2637
- console.error(chalk14.dim(` Code: ${err.code}`));
3595
+ if (err instanceof VonPayError3) {
3596
+ console.error(chalk15.red(`API error: ${scrubSecrets(safeInline(err.message, 2e3))}`));
3597
+ console.error(chalk15.dim(` Code: ${safeInline(String(err.code))}`));
2638
3598
  } else {
2639
3599
  console.error(
2640
- chalk14.red(
2641
- `Cannot reach API: ${scrubSecrets(err instanceof Error ? err.message : String(err))}`
3600
+ chalk15.red(
3601
+ `Cannot reach API: ${scrubSecrets(safeInline(err instanceof Error ? err.message : String(err), 2e3))}`
2642
3602
  )
2643
3603
  );
2644
3604
  }
@@ -2649,13 +3609,13 @@ function healthCommand() {
2649
3609
 
2650
3610
  // src/commands/doctor.ts
2651
3611
  import { Command as Command12 } from "commander";
2652
- import chalk15 from "chalk";
3612
+ import chalk16 from "chalk";
2653
3613
  import ora3 from "ora";
2654
- import { homedir as homedir2 } from "os";
3614
+ import { homedir as homedir3 } from "os";
2655
3615
  import { existsSync as existsSync3 } from "fs";
2656
3616
  import {
2657
3617
  VonPayCheckout as VonPayCheckout3,
2658
- VonPayError as VonPayError3,
3618
+ VonPayError as VonPayError4,
2659
3619
  helpFor
2660
3620
  } from "@vonpay/checkout-node";
2661
3621
  var RELEVANT_ENV_VARS = [
@@ -2667,7 +3627,7 @@ var RELEVANT_ENV_VARS = [
2667
3627
  "NEXT_PUBLIC_BASE_URL"
2668
3628
  ];
2669
3629
  function redactHome(p) {
2670
- const home = homedir2();
3630
+ const home = homedir3();
2671
3631
  if (home && p.startsWith(home)) {
2672
3632
  return "~" + p.slice(home.length);
2673
3633
  }
@@ -2802,7 +3762,7 @@ async function gather() {
2802
3762
  );
2803
3763
  }
2804
3764
  } catch (err) {
2805
- if (err instanceof VonPayError3) {
3765
+ if (err instanceof VonPayError4) {
2806
3766
  const help = helpFor(err.code);
2807
3767
  report.validateProbe = {
2808
3768
  ok: false,
@@ -2833,47 +3793,47 @@ async function gather() {
2833
3793
  }
2834
3794
  function renderHuman(r) {
2835
3795
  const lines = [];
2836
- lines.push(chalk15.bold.blue("vonpay doctor \u2014 diagnostic report"));
2837
- lines.push(chalk15.dim(`generated ${r.generatedAt}`));
3796
+ lines.push(chalk16.bold.blue("vonpay doctor \u2014 diagnostic report"));
3797
+ lines.push(chalk16.dim(`generated ${r.generatedAt}`));
2838
3798
  lines.push("");
2839
- lines.push(chalk15.bold("Runtime"));
3799
+ lines.push(chalk16.bold("Runtime"));
2840
3800
  lines.push(` CLI: ${r.cli.version}`);
2841
3801
  lines.push(` Node: ${r.runtime.node}`);
2842
3802
  lines.push(` Platform: ${r.runtime.platform} (${r.runtime.arch})`);
2843
3803
  lines.push(` CWD: ${r.runtime.cwd}`);
2844
3804
  lines.push("");
2845
- lines.push(chalk15.bold("API key"));
3805
+ lines.push(chalk16.bold("API key"));
2846
3806
  if (r.apiKeyPrefix) {
2847
3807
  lines.push(` Prefix: ${r.apiKeyPrefix}`);
2848
3808
  lines.push(` Mode: ${r.apiKeyMode}`);
2849
3809
  } else {
2850
- lines.push(chalk15.dim(" (no key found in env or ~/.vonpay/config.json)"));
3810
+ lines.push(chalk16.dim(" (no key found in env or ~/.vonpay/config.json)"));
2851
3811
  }
2852
3812
  lines.push("");
2853
- lines.push(chalk15.bold("Env vars present (names only)"));
3813
+ lines.push(chalk16.bold("Env vars present (names only)"));
2854
3814
  if (r.envVarsPresent.length === 0) {
2855
- lines.push(chalk15.dim(" (none of the expected vonpay env vars are set)"));
3815
+ lines.push(chalk16.dim(" (none of the expected vonpay env vars are set)"));
2856
3816
  } else {
2857
3817
  for (const n of r.envVarsPresent) lines.push(` \u2713 ${n}`);
2858
3818
  }
2859
3819
  lines.push("");
2860
- lines.push(chalk15.bold("Health probe"));
3820
+ lines.push(chalk16.bold("Health probe"));
2861
3821
  if (r.health.reachable) {
2862
- const statusColor = r.health.status === "ok" ? chalk15.green : r.health.status === "degraded" ? chalk15.yellow : chalk15.red;
2863
- lines.push(` Reachable: ${chalk15.green("yes")}`);
3822
+ const statusColor = r.health.status === "ok" ? chalk16.green : r.health.status === "degraded" ? chalk16.yellow : chalk16.red;
3823
+ lines.push(` Reachable: ${chalk16.green("yes")}`);
2864
3824
  lines.push(` Status: ${statusColor(r.health.status ?? "?")}`);
2865
3825
  lines.push(` Latency: ${r.health.latencyMs}ms`);
2866
3826
  } else {
2867
- lines.push(` Reachable: ${chalk15.red("no")}`);
3827
+ lines.push(` Reachable: ${chalk16.red("no")}`);
2868
3828
  lines.push(` Error: ${r.health.error}`);
2869
3829
  }
2870
3830
  lines.push("");
2871
3831
  if (r.validateProbe) {
2872
- lines.push(chalk15.bold("sessions.validate probe ($14.99 USD dry-run)"));
3832
+ lines.push(chalk16.bold("sessions.validate probe ($14.99 USD dry-run)"));
2873
3833
  if (r.validateProbe.ok) {
2874
- lines.push(` Result: ${chalk15.green("valid")}`);
3834
+ lines.push(` Result: ${chalk16.green("valid")}`);
2875
3835
  } else {
2876
- lines.push(` Result: ${chalk15.red("invalid")}`);
3836
+ lines.push(` Result: ${chalk16.red("invalid")}`);
2877
3837
  if (r.validateProbe.error) {
2878
3838
  lines.push(` Code: ${r.validateProbe.error.code}`);
2879
3839
  lines.push(` Action: ${r.validateProbe.error.nextAction}`);
@@ -2886,7 +3846,7 @@ function renderHuman(r) {
2886
3846
  lines.push("");
2887
3847
  }
2888
3848
  if (r.hints.length > 0) {
2889
- lines.push(chalk15.bold("Hints"));
3849
+ lines.push(chalk16.bold("Hints"));
2890
3850
  for (const h of r.hints) lines.push(` \u2022 ${h}`);
2891
3851
  lines.push("");
2892
3852
  }
@@ -2988,8 +3948,8 @@ function doctorCommand() {
2988
3948
  }
2989
3949
 
2990
3950
  // src/commands/checkout.ts
2991
- function registerCheckout(program2) {
2992
- const checkout = program2.command("checkout").description("Checkout integration tools");
3951
+ function registerCheckout(program) {
3952
+ const checkout = program.command("checkout").description("Checkout integration tools");
2993
3953
  checkout.hook("preAction", (_checkout, actionCommand) => {
2994
3954
  setJsonErrors(actionCommand.opts().json === true);
2995
3955
  });
@@ -3007,12 +3967,32 @@ function registerCheckout(program2) {
3007
3967
  checkout.addCommand(doctorCommand());
3008
3968
  }
3009
3969
 
3970
+ // src/program.ts
3971
+ function commandPath(cmd) {
3972
+ const names = [];
3973
+ for (let c = cmd; c && c.parent; c = c.parent) names.unshift(c.name());
3974
+ return names.join(" ");
3975
+ }
3976
+ function buildProgram() {
3977
+ const program = new Command13();
3978
+ program.name("vonpay").description("Von Payments developer tools").version(CLI_VERSION).option(
3979
+ ALLOW_CUSTOM_BASE_URL_FLAG,
3980
+ "With VONPAY_ALLOW_CUSTOM_BASE_URL=1: send a live key to a base URL that is not a Von Payments host (a proxy you run)"
3981
+ ).hook("preAction", (_root, actionCommand) => {
3982
+ setAllowCustomBaseUrlFlag(program.opts().allowCustomBaseUrl === true);
3983
+ try {
3984
+ auditStart(commandPath(actionCommand), CLI_VERSION, actionCommand.opts(), actionCommand.args);
3985
+ } catch {
3986
+ }
3987
+ });
3988
+ registerCheckout(program);
3989
+ const withUsageAudit = (cmd) => {
3990
+ cmd.exitOverride((err) => auditUsageError(commandPath(cmd), CLI_VERSION, err.code, err.exitCode));
3991
+ cmd.commands.forEach(withUsageAudit);
3992
+ };
3993
+ withUsageAudit(program);
3994
+ return program;
3995
+ }
3996
+
3010
3997
  // src/index.ts
3011
- var __dirname = dirname2(fileURLToPath2(import.meta.url));
3012
- var pkg2 = JSON.parse(
3013
- readFileSync4(join3(__dirname, "..", "package.json"), "utf-8")
3014
- );
3015
- var program = new Command13();
3016
- program.name("vonpay").description("Von Payments developer tools").version(pkg2.version);
3017
- registerCheckout(program);
3018
- program.parse();
3998
+ buildProgram().parse();