@rozoai/checkout 0.1.17 → 0.1.18

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.
package/README.md CHANGED
@@ -388,6 +388,14 @@ keypair or an encrypted keystore, and supports EVM chains and Solana only —
388
388
  Stellar and Lightning are Mode A only.
389
389
  </details>
390
390
 
391
+ ## Bulk and automated use
392
+
393
+ Buying credits regularly, reselling them, or building an agent that tops up
394
+ OpenRouter for its users? The [Bulk Purchases and Agents](https://docs.rozo.ai/products/checkout/bulk-and-agents)
395
+ guide covers the CLI, the MCP server and the raw HTTP flow side by side, plus
396
+ fees, rate limits, idempotency, error codes and what each final status means.
397
+ The batch script below is the CLI half of it.
398
+
391
399
  ## Paying many invoices (batch / resellers)
392
400
 
393
401
  Each OpenRouter top-up is its own Coinbase link, so paying many is a loop: one
@@ -418,7 +426,8 @@ done < links.txt
418
426
  - `--yes` is required when stdin is not a terminal, `--json` prints exactly one
419
427
  JSON object, and `--no-watch` returns as soon as the deposit details exist.
420
428
  Without `--no-watch`, `pay --json` first waits for settlement (up to
421
- `--timeout`, default 900 s) and prints nothing until then, invoice included.
429
+ `--timeout`, default 900 s; for Lightning, the invoice's remaining validity,
430
+ at most 60 minutes) and prints nothing until then, invoice included.
422
431
  - Without `--send` (the loop above), a successful `pay --no-watch` means the
423
432
  order and its deposit details exist. **No money has moved yet**, which is
424
433
  why the log says `pending`. With `--send`, the CLI may already have
@@ -511,7 +520,11 @@ a link again, run `status` on its `rozoPaymentId`:
511
520
  - `RATE_LIMITED`: order creation is capped per IP per hour (currently 30
512
521
  requests, and each `pay` makes two), so plan on about 15 links per hour from
513
522
  one IP. Wait for the next hour and resume with the links not yet `settled`.
514
- Need more? Email us.
523
+ Need more? Email us. The CLI retries a 429 by itself, up to 3 times, when
524
+ the server's `Retry-After` (or `retryAfterSeconds`) fits in 2 minutes of
525
+ total waiting. A longer wait fails at once: read
526
+ `error.details.retryAfterSeconds`, and `error.details.rateLimit` (limit,
527
+ remaining, tier, scope) when the server sends it.
515
528
 
516
529
  Questions about a batch? Email hi@rozo.ai, or reach us on
517
530
  [X](https://x.com/ROZOai) or [Discord](https://discord.gg/EfWejgTbuU).
package/llms.txt CHANGED
@@ -86,7 +86,8 @@ override; larger invoices go through the keyless path.
86
86
 
87
87
  Other flags: --yes (skip the interactive confirm; required when stdin is not a
88
88
  TTY), --dry-run, --json (exactly one JSON object on stdout), --no-watch,
89
- --timeout <s>. Exit codes: 0 success, 1 refused/failed (read error.code),
89
+ --timeout <s> (default 900; Lightning: the invoice's remaining validity, max 60
90
+ min). Exit codes: 0 success, 1 refused/failed (read error.code),
90
91
  2 usage, 3 submitted but unconfirmed.
91
92
 
92
93
  `pay` withholds the full deposit address until you confirm, then prints it as a
@@ -176,7 +177,9 @@ private key (EVM and Solana only); every other path is keyless.
176
177
 
177
178
  409 = link already used · 410 = link expired · UNSUPPORTED_SOURCE = the response's
178
179
  "supported" object lists valid (chainId, tokenSymbol) pairs (Lightning is supported
179
- but omitted from that list) · RATE_LIMITED = per-IP creation cap, retry later ·
180
+ but omitted from that list) · RATE_LIMITED = per-IP creation cap, retry later (the CLI itself retries a 429 up
181
+ to 3 times within 2 minutes of total waiting, per Retry-After; a longer wait
182
+ fails at once with error.details.retryAfterSeconds and rateLimit) ·
180
183
  INVALID_EMAIL = the optional email is malformed; fix it or omit it.
181
184
 
182
185
  ## Support
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rozoai/checkout",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "type": "module",
5
5
  "description": "Pay OpenRouter, or any Coinbase invoice, with the crypto you already hold: USDT/USDC on Solana, BNB Chain, Ethereum or Polygon, USDC on Base or Stellar, or BTC over Lightning. No Coinbase account.",
6
6
  "license": "MIT",
@@ -41455,6 +41455,8 @@ var SUPPORT = Object.freeze({
41455
41455
  discord: "https://discord.gg/EfWejgTbuU"
41456
41456
  });
41457
41457
  var SUPPORT_TEXT = `Need help? Email ${SUPPORT.email}, or reach ROZO on X ${SUPPORT.x} or Discord ${SUPPORT.discord}.`;
41458
+ var BULK_DOCS_URL = "https://docs.rozo.ai/products/checkout/bulk-and-agents";
41459
+ var BULK_HINT_TEXT = `Buying regularly or for others? ${BULK_DOCS_URL}`;
41458
41460
  var CONTACT_EMAIL_MAX_LENGTH = 254;
41459
41461
  var EMAIL_RE = /^[a-z0-9][^\s@]*@[^\s@.]+(\.[^\s@.]+)+$/;
41460
41462
  function normalizeContactEmail(raw) {
@@ -42095,7 +42097,9 @@ function parseCliArgs(argv) {
42095
42097
  json,
42096
42098
  provider: flags.provider,
42097
42099
  watch: flags.watch === true,
42098
- timeout: flags.timeout === void 0 ? 600 : Number(flags.timeout)
42100
+ // Left undefined when absent so status can size the window itself:
42101
+ // 600s, or a Lightning invoice's remaining validity (max 60 min).
42102
+ timeout: flags.timeout === void 0 ? void 0 : Number(flags.timeout)
42099
42103
  };
42100
42104
  }
42101
42105
  const bitrefillFlags = ["bitrefill-invoice", "to", "amount", "expires-at"].filter(
@@ -42178,6 +42182,7 @@ function parseCliArgs(argv) {
42178
42182
  dryRun: flags["dry-run"] === true,
42179
42183
  watch: flags["no-watch"] !== true,
42180
42184
  timeout,
42185
+ timeoutExplicit: flags.timeout !== void 0,
42181
42186
  rpc: flags.rpc,
42182
42187
  payer: flags.payer,
42183
42188
  fresh: flags.fresh === true,
@@ -42225,7 +42230,8 @@ OPTIONS
42225
42230
  --dry-run with --send, show what would be signed and sign nothing
42226
42231
  --json, -j machine-readable output
42227
42232
  --no-watch stop after showing the deposit instructions
42228
- --timeout <s> how long to poll for settlement (default 900)
42233
+ --timeout <s> how long to poll for settlement (default 900; for Lightning,
42234
+ the invoice's remaining validity, at most 60 minutes)
42229
42235
  --bitrefill-invoice <id> pay a Bitrefill invoice created with "USDC on Base"
42230
42236
  instead of a Coinbase link. Needs --to <0x\u2026 address Bitrefill
42231
42237
  shows>, --amount <exact USDC amount> and --expires-at <ISO>
@@ -42912,6 +42918,25 @@ var MARGINS_MS = {
42912
42918
  };
42913
42919
  var BOLT11_MIN_VALIDITY_MS = 10 * MINUTE;
42914
42920
  var DEFAULT_MARGIN_MS = 10 * MINUTE;
42921
+ var DEFAULT_WATCH_MS = 10 * MINUTE;
42922
+ var LIGHTNING_WATCH_CAP_MS = 60 * MINUTE;
42923
+ function lightningWatchMs(expiresAt, { now = Date.now(), fallbackMs = DEFAULT_WATCH_MS } = {}) {
42924
+ const at = parseDeadline(expiresAt);
42925
+ if (at === null) return fallbackMs;
42926
+ const remaining = at - now;
42927
+ if (!(remaining > 0)) return fallbackMs;
42928
+ return Math.min(remaining, LIGHTNING_WATCH_CAP_MS);
42929
+ }
42930
+ function settlementWatchSeconds(opts, deposit, now = Date.now()) {
42931
+ const fallbackMs = Number(opts.timeout ?? 900) * 1e3;
42932
+ if (opts.timeoutExplicit || !deposit?.lnInvoice) return Math.round(fallbackMs / 1e3);
42933
+ return Math.ceil(lightningWatchMs(deposit.expiresAt, { now, fallbackMs }) / 1e3);
42934
+ }
42935
+ function minutesRemaining(expiresAt, now = Date.now()) {
42936
+ const at = parseDeadline(expiresAt);
42937
+ if (at === null) return null;
42938
+ return Math.max(0, Math.floor((at - now) / MINUTE));
42939
+ }
42915
42940
  function formatRemaining(ms) {
42916
42941
  if (!Number.isFinite(ms)) return "unknown";
42917
42942
  if (ms <= 0) return "expired";
@@ -54615,18 +54640,66 @@ function displayPath(p) {
54615
54640
  return s.startsWith(home + path6.sep) ? `~${s.slice(home.length)}` : s;
54616
54641
  }
54617
54642
 
54618
- // scripts/src/lib/api.mjs
54643
+ // scripts/src/lib/version.mjs
54619
54644
  import { createRequire } from "node:module";
54645
+ var PKG_VERSION = (() => {
54646
+ const requireFrom = createRequire(import.meta.url);
54647
+ for (const candidate of ["../../package.json", "../../../package.json"]) {
54648
+ try {
54649
+ const pkg = requireFrom(candidate);
54650
+ if (pkg?.name === "@rozoai/checkout" && pkg.version) return pkg.version;
54651
+ } catch {
54652
+ }
54653
+ }
54654
+ return "0.0.0";
54655
+ })();
54620
54656
 
54621
54657
  // scripts/src/lib/http.mjs
54622
54658
  var DEFAULT_TIMEOUT_MS = 2e4;
54623
- var USER_AGENT = "rozo-checkout-skill/1.0";
54624
- async function request(method, url, { body, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
54659
+ var USER_AGENT = `rozo-checkout-skill/${PKG_VERSION}`;
54660
+ var MAX_RETRIES = 3;
54661
+ var MAX_TOTAL_WAIT_MS = 12e4;
54662
+ var FALLBACK_BACKOFF_MS = [2e3, 4e3, 8e3];
54663
+ var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
54664
+ function parseRetryAfter(value, now = Date.now()) {
54665
+ if (value === null || value === void 0) return null;
54666
+ const s = String(value).trim();
54667
+ if (!s) return null;
54668
+ if (/^\d+(\.\d+)?$/.test(s)) return Math.round(Number(s) * 1e3);
54669
+ const at = Date.parse(s);
54670
+ if (!Number.isFinite(at)) return null;
54671
+ return Math.max(0, at - now);
54672
+ }
54673
+ function bodyRetryAfterMs(json) {
54674
+ const raw = json?.retryAfterSeconds ?? json?.error?.retryAfterSeconds;
54675
+ if (raw === null || raw === void 0 || raw === "") return null;
54676
+ const n = Number(raw);
54677
+ return Number.isFinite(n) && n >= 0 ? Math.round(n * 1e3) : null;
54678
+ }
54679
+ function rateLimitInfo(res) {
54680
+ const pick = (name) => res.headers?.get?.(name) ?? null;
54681
+ const info = {
54682
+ limit: pick("x-ratelimit-limit"),
54683
+ remaining: pick("x-ratelimit-remaining"),
54684
+ tier: pick("x-ratelimit-tier"),
54685
+ scope: pick("x-ratelimit-scope")
54686
+ };
54687
+ const present = Object.fromEntries(Object.entries(info).filter(([, v]) => v !== null));
54688
+ return Object.keys(present).length ? present : null;
54689
+ }
54690
+ function parseBody(text) {
54691
+ if (!text) return null;
54692
+ try {
54693
+ return JSON.parse(text);
54694
+ } catch {
54695
+ return null;
54696
+ }
54697
+ }
54698
+ async function fetchOnce(method, url, body, timeoutMs) {
54625
54699
  const controller = new AbortController();
54626
54700
  const timer = setTimeout(() => controller.abort(), timeoutMs);
54627
- let res;
54628
54701
  try {
54629
- res = await fetch(url, {
54702
+ const res = await fetch(url, {
54630
54703
  method,
54631
54704
  headers: {
54632
54705
  accept: "application/json",
@@ -54636,6 +54709,8 @@ async function request(method, url, { body, timeoutMs = DEFAULT_TIMEOUT_MS } = {
54636
54709
  body: body !== void 0 ? JSON.stringify(body) : void 0,
54637
54710
  signal: controller.signal
54638
54711
  });
54712
+ const text = await res.text();
54713
+ return { res, text };
54639
54714
  } catch (err) {
54640
54715
  throw new SkillError(
54641
54716
  err?.name === "AbortError" ? "HTTP_TIMEOUT" : "HTTP_UNREACHABLE",
@@ -54645,31 +54720,54 @@ async function request(method, url, { body, timeoutMs = DEFAULT_TIMEOUT_MS } = {
54645
54720
  } finally {
54646
54721
  clearTimeout(timer);
54647
54722
  }
54648
- const text = await res.text();
54649
- let json = null;
54650
- if (text) {
54651
- try {
54652
- json = JSON.parse(text);
54653
- } catch {
54654
- json = null;
54723
+ }
54724
+ async function request(method, url, {
54725
+ body,
54726
+ timeoutMs = DEFAULT_TIMEOUT_MS,
54727
+ sleep: sleep2 = defaultSleep,
54728
+ now = Date.now,
54729
+ maxTotalWaitMs = MAX_TOTAL_WAIT_MS
54730
+ } = {}) {
54731
+ let waitedMs = 0;
54732
+ let retries = 0;
54733
+ for (; ; ) {
54734
+ const { res, text } = await fetchOnce(method, url, body, timeoutMs);
54735
+ const json = parseBody(text);
54736
+ if (res.ok) {
54737
+ if (json === null) {
54738
+ throw new SkillError("HTTP_BAD_JSON", "Endpoint returned a non-JSON body.", {
54739
+ url: redactUrl(url),
54740
+ snippet: redact(text).slice(0, 400)
54741
+ });
54742
+ }
54743
+ return json;
54744
+ }
54745
+ const headerWaitMs = parseRetryAfter(res.headers?.get?.("retry-after"), now());
54746
+ const hintMs = headerWaitMs ?? bodyRetryAfterMs(json);
54747
+ const retryable = res.status === 429 || res.status === 503 && hintMs !== null;
54748
+ if (retryable && retries < MAX_RETRIES) {
54749
+ const waitMs = hintMs ?? FALLBACK_BACKOFF_MS[retries];
54750
+ if (waitedMs + waitMs <= Math.min(maxTotalWaitMs, MAX_TOTAL_WAIT_MS)) {
54751
+ retries += 1;
54752
+ waitedMs += waitMs;
54753
+ await sleep2(waitMs);
54754
+ continue;
54755
+ }
54655
54756
  }
54656
- }
54657
- if (!res.ok) {
54658
54757
  const code = json?.code || json?.error?.code || (typeof json?.error === "string" && /^[A-Z][A-Z0-9_]+$/.test(json.error) ? json.error : null) || `HTTP_${res.status}`;
54659
54758
  const message = json?.message || (typeof json?.error === "string" ? json.error : json?.error?.message) || `HTTP ${res.status}`;
54759
+ const rateLimit = rateLimitInfo(res);
54660
54760
  throw new SkillError(code, redact(String(message)), {
54661
54761
  httpStatus: res.status,
54662
54762
  url: redactUrl(url),
54663
- body: json ?? redact(text).slice(0, 800)
54763
+ body: json ?? redact(text).slice(0, 800),
54764
+ ...retryable ? {
54765
+ retries,
54766
+ retryAfterSeconds: hintMs === null ? null : Math.ceil(hintMs / 1e3)
54767
+ } : {},
54768
+ ...rateLimit ? { rateLimit } : {}
54664
54769
  });
54665
54770
  }
54666
- if (json === null) {
54667
- throw new SkillError("HTTP_BAD_JSON", "Endpoint returned a non-JSON body.", {
54668
- url: redactUrl(url),
54669
- snippet: redact(text).slice(0, 400)
54670
- });
54671
- }
54672
- return json;
54673
54771
  }
54674
54772
  function redactUrl(url) {
54675
54773
  try {
@@ -54725,17 +54823,6 @@ function attributionIdentity(env = process.env) {
54725
54823
  }
54726
54824
 
54727
54825
  // scripts/src/lib/api.mjs
54728
- var PKG_VERSION = (() => {
54729
- const requireFrom = createRequire(import.meta.url);
54730
- for (const candidate of ["../../package.json", "../../../package.json"]) {
54731
- try {
54732
- const pkg = requireFrom(candidate);
54733
- if (pkg?.name === "@rozoai/checkout" && pkg.version) return pkg.version;
54734
- } catch {
54735
- }
54736
- }
54737
- return "0.0.0";
54738
- })();
54739
54826
  var MPP_BASE = process.env.ROZO_CHECKOUT_MPP_BASE || "https://apiserver.mpprouter.dev/v1/services/rozo-agent-api";
54740
54827
  var CLIENT_LABEL = `rozo-checkout-cli/${PKG_VERSION}`;
54741
54828
  var ATTRIBUTION_CLIENT = `rozo-checkout-skill/${PKG_VERSION}`;
@@ -54755,6 +54842,7 @@ async function quoteInvoice({ url, linkId }) {
54755
54842
  const body = url ? { url } : { payment_id: linkId };
54756
54843
  return postJson(`${MPP_BASE}/quote-invoice`, body);
54757
54844
  }
54845
+ var QUOTE_RECEIPT_RETRY_BUDGET_MS = 2e4;
54758
54846
  async function createInvoice({ url, linkId, source, quoteReceipt, utmSource, email }) {
54759
54847
  const body = {
54760
54848
  ...url ? { url } : { payment_id: linkId },
@@ -54766,7 +54854,11 @@ async function createInvoice({ url, linkId, source, quoteReceipt, utmSource, ema
54766
54854
  // sent empty, when the user gave none.
54767
54855
  ...email ? { email } : {}
54768
54856
  };
54769
- return postJson(`${MPP_BASE}/create-invoice`, body);
54857
+ return postJson(
54858
+ `${MPP_BASE}/create-invoice`,
54859
+ body,
54860
+ quoteReceipt ? { maxTotalWaitMs: QUOTE_RECEIPT_RETRY_BUDGET_MS } : void 0
54861
+ );
54770
54862
  }
54771
54863
  async function createBitrefillInvoice({ invoice, source, utmSource, email }) {
54772
54864
  const body = {
@@ -56451,6 +56543,16 @@ function statusGuidance(result) {
56451
56543
  if (result.state === "expired_unfunded") return EXPIRED_UNFUNDED_GUIDANCE;
56452
56544
  return result.terminal ? "Done." : "Still in flight. Poll again in ~10s.";
56453
56545
  }
56546
+ function watchTimeoutMs(timeoutArg, result, now = Date.now()) {
56547
+ if (timeoutArg !== void 0 && timeoutArg !== null && timeoutArg !== true) {
56548
+ const n = Number(timeoutArg);
56549
+ if (Number.isFinite(n)) return Math.max(0, n * 1e3);
56550
+ }
56551
+ if (result?.payin?.chain === chainName("lightning")) {
56552
+ return lightningWatchMs(result?.expiry?.expiresAt, { now, fallbackMs: DEFAULT_WATCH_MS });
56553
+ }
56554
+ return DEFAULT_WATCH_MS;
56555
+ }
56454
56556
  async function main4(argv) {
56455
56557
  const args = parseArgs(argv);
56456
56558
  const rozoPaymentId = args["rozo-payment-id"] || (isRozoPaymentId(args._[0]) ? args._[0] : null);
@@ -56466,9 +56568,9 @@ async function main4(argv) {
56466
56568
  usage("Required: --rozo-payment-id <uuid> and/or --link-id <pl_* | paymentSession_*>");
56467
56569
  }
56468
56570
  const watch = Boolean(args.watch);
56469
- const timeoutMs = Math.max(0, Number(args.timeout ?? 600) * 1e3);
56470
- const deadline = Date.now() + timeoutMs;
56571
+ const started = Date.now();
56471
56572
  let result = await snapshot({ rozoPaymentId, linkId, provider });
56573
+ const deadline = started + watchTimeoutMs(args.timeout, result, started);
56472
56574
  const history = [{ at: (/* @__PURE__ */ new Date()).toISOString(), state: result.state }];
56473
56575
  while (watch && !result.terminal && !result.escalate && !result.unknown && Date.now() < deadline) {
56474
56576
  await sleep(POLL_INTERVAL_MS);
@@ -58142,6 +58244,9 @@ function printError(payload) {
58142
58244
  function printSupport() {
58143
58245
  out(` ${dim(SUPPORT_TEXT)}`);
58144
58246
  }
58247
+ function printBulkHint() {
58248
+ out(` ${dim(BULK_HINT_TEXT)}`);
58249
+ }
58145
58250
  function printMoneyWarning(payload) {
58146
58251
  if (!payload?.moneyDetected) return;
58147
58252
  out();
@@ -58279,7 +58384,8 @@ async function cmdQuote(opts) {
58279
58384
  return exitCode;
58280
58385
  }
58281
58386
  async function cmdStatus(opts) {
58282
- const argv = [...targetToArgs(opts.target), "--timeout", String(opts.timeout ?? 600)];
58387
+ const argv = [...targetToArgs(opts.target)];
58388
+ if (opts.timeout !== void 0) argv.push("--timeout", String(opts.timeout));
58283
58389
  if (opts.provider) argv.push("--provider", opts.provider);
58284
58390
  if (opts.watch) argv.push("--watch");
58285
58391
  if (!opts.json) out(dim(" Checking payment status\u2026 (no money moves)"));
@@ -58340,6 +58446,7 @@ async function cmdReceipt(opts) {
58340
58446
  out();
58341
58447
  out(` Next: ${r.nextAction.type === "contact_support" ? red(r.nextAction.message) : r.nextAction.message}`);
58342
58448
  if (payload.support) printSupport();
58449
+ if (r.paymentOutcome === "settled") printBulkHint();
58343
58450
  out();
58344
58451
  return exitCode;
58345
58452
  }
@@ -58650,7 +58757,7 @@ async function cmdPay(opts) {
58650
58757
  rozoPaymentId,
58651
58758
  "--watch",
58652
58759
  "--timeout",
58653
- String(opts.timeout)
58760
+ String(settlementWatchSeconds(opts, deposit))
58654
58761
  ]);
58655
58762
  if (opts.json) {
58656
58763
  printJson({
@@ -58670,6 +58777,7 @@ async function cmdPay(opts) {
58670
58777
  }
58671
58778
  printMoneyWarning(watched.payload);
58672
58779
  if (watched.payload.state !== "settled") printSupport();
58780
+ else printBulkHint();
58673
58781
  out();
58674
58782
  return watched.exitCode;
58675
58783
  }
@@ -58680,6 +58788,10 @@ function printDeposit(deposit, opts) {
58680
58788
  if (deposit.lnInvoice) {
58681
58789
  out(` Amount ${bold(`${deposit.amount} ${isSatsUnit(deposit.amountUnit) ? "sats" : deposit.tokenSymbol}`)}`);
58682
58790
  out(` BOLT11 ${deposit.lnInvoice}`);
58791
+ const validMin = minutesRemaining(deposit.expiresAt);
58792
+ if (validMin !== null) {
58793
+ out(` Valid ${bold(validMin < 1 ? "under 1 more minute" : `${validMin} more minutes`)}`);
58794
+ }
58683
58795
  } else {
58684
58796
  out(` Amount ${bold(`${deposit.amount} ${deposit.tokenSymbol}`)}`);
58685
58797
  out(` Chain ${bold(deposit.chain)}`);
@@ -13,6 +13,8 @@ var SUPPORT = Object.freeze({
13
13
  discord: "https://discord.gg/EfWejgTbuU"
14
14
  });
15
15
  var SUPPORT_TEXT = `Need help? Email ${SUPPORT.email}, or reach ROZO on X ${SUPPORT.x} or Discord ${SUPPORT.discord}.`;
16
+ var BULK_DOCS_URL = "https://docs.rozo.ai/products/checkout/bulk-and-agents";
17
+ var BULK_HINT_TEXT = `Buying regularly or for others? ${BULK_DOCS_URL}`;
16
18
  var CONTACT_EMAIL_MAX_LENGTH = 254;
17
19
  var EMAIL_RE = /^[a-z0-9][^\s@]*@[^\s@.]+(\.[^\s@.]+)+$/;
18
20
  function normalizeContactEmail(raw) {
@@ -198,18 +200,66 @@ function maskMemo(memo) {
198
200
  return `${s.slice(0, 4)}...${s.slice(-3)}`;
199
201
  }
200
202
 
201
- // scripts/src/lib/api.mjs
203
+ // scripts/src/lib/version.mjs
202
204
  import { createRequire } from "node:module";
205
+ var PKG_VERSION = (() => {
206
+ const requireFrom = createRequire(import.meta.url);
207
+ for (const candidate of ["../../package.json", "../../../package.json"]) {
208
+ try {
209
+ const pkg = requireFrom(candidate);
210
+ if (pkg?.name === "@rozoai/checkout" && pkg.version) return pkg.version;
211
+ } catch {
212
+ }
213
+ }
214
+ return "0.0.0";
215
+ })();
203
216
 
204
217
  // scripts/src/lib/http.mjs
205
218
  var DEFAULT_TIMEOUT_MS = 2e4;
206
- var USER_AGENT = "rozo-checkout-skill/1.0";
207
- async function request(method, url, { body, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
219
+ var USER_AGENT = `rozo-checkout-skill/${PKG_VERSION}`;
220
+ var MAX_RETRIES = 3;
221
+ var MAX_TOTAL_WAIT_MS = 12e4;
222
+ var FALLBACK_BACKOFF_MS = [2e3, 4e3, 8e3];
223
+ var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
224
+ function parseRetryAfter(value, now = Date.now()) {
225
+ if (value === null || value === void 0) return null;
226
+ const s = String(value).trim();
227
+ if (!s) return null;
228
+ if (/^\d+(\.\d+)?$/.test(s)) return Math.round(Number(s) * 1e3);
229
+ const at = Date.parse(s);
230
+ if (!Number.isFinite(at)) return null;
231
+ return Math.max(0, at - now);
232
+ }
233
+ function bodyRetryAfterMs(json) {
234
+ const raw = json?.retryAfterSeconds ?? json?.error?.retryAfterSeconds;
235
+ if (raw === null || raw === void 0 || raw === "") return null;
236
+ const n = Number(raw);
237
+ return Number.isFinite(n) && n >= 0 ? Math.round(n * 1e3) : null;
238
+ }
239
+ function rateLimitInfo(res) {
240
+ const pick = (name) => res.headers?.get?.(name) ?? null;
241
+ const info = {
242
+ limit: pick("x-ratelimit-limit"),
243
+ remaining: pick("x-ratelimit-remaining"),
244
+ tier: pick("x-ratelimit-tier"),
245
+ scope: pick("x-ratelimit-scope")
246
+ };
247
+ const present = Object.fromEntries(Object.entries(info).filter(([, v]) => v !== null));
248
+ return Object.keys(present).length ? present : null;
249
+ }
250
+ function parseBody(text) {
251
+ if (!text) return null;
252
+ try {
253
+ return JSON.parse(text);
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+ async function fetchOnce(method, url, body, timeoutMs) {
208
259
  const controller = new AbortController();
209
260
  const timer = setTimeout(() => controller.abort(), timeoutMs);
210
- let res;
211
261
  try {
212
- res = await fetch(url, {
262
+ const res = await fetch(url, {
213
263
  method,
214
264
  headers: {
215
265
  accept: "application/json",
@@ -219,6 +269,8 @@ async function request(method, url, { body, timeoutMs = DEFAULT_TIMEOUT_MS } = {
219
269
  body: body !== void 0 ? JSON.stringify(body) : void 0,
220
270
  signal: controller.signal
221
271
  });
272
+ const text = await res.text();
273
+ return { res, text };
222
274
  } catch (err) {
223
275
  throw new SkillError(
224
276
  err?.name === "AbortError" ? "HTTP_TIMEOUT" : "HTTP_UNREACHABLE",
@@ -228,31 +280,54 @@ async function request(method, url, { body, timeoutMs = DEFAULT_TIMEOUT_MS } = {
228
280
  } finally {
229
281
  clearTimeout(timer);
230
282
  }
231
- const text = await res.text();
232
- let json = null;
233
- if (text) {
234
- try {
235
- json = JSON.parse(text);
236
- } catch {
237
- json = null;
283
+ }
284
+ async function request(method, url, {
285
+ body,
286
+ timeoutMs = DEFAULT_TIMEOUT_MS,
287
+ sleep = defaultSleep,
288
+ now = Date.now,
289
+ maxTotalWaitMs = MAX_TOTAL_WAIT_MS
290
+ } = {}) {
291
+ let waitedMs = 0;
292
+ let retries = 0;
293
+ for (; ; ) {
294
+ const { res, text } = await fetchOnce(method, url, body, timeoutMs);
295
+ const json = parseBody(text);
296
+ if (res.ok) {
297
+ if (json === null) {
298
+ throw new SkillError("HTTP_BAD_JSON", "Endpoint returned a non-JSON body.", {
299
+ url: redactUrl(url),
300
+ snippet: redact(text).slice(0, 400)
301
+ });
302
+ }
303
+ return json;
304
+ }
305
+ const headerWaitMs = parseRetryAfter(res.headers?.get?.("retry-after"), now());
306
+ const hintMs = headerWaitMs ?? bodyRetryAfterMs(json);
307
+ const retryable = res.status === 429 || res.status === 503 && hintMs !== null;
308
+ if (retryable && retries < MAX_RETRIES) {
309
+ const waitMs = hintMs ?? FALLBACK_BACKOFF_MS[retries];
310
+ if (waitedMs + waitMs <= Math.min(maxTotalWaitMs, MAX_TOTAL_WAIT_MS)) {
311
+ retries += 1;
312
+ waitedMs += waitMs;
313
+ await sleep(waitMs);
314
+ continue;
315
+ }
238
316
  }
239
- }
240
- if (!res.ok) {
241
317
  const code = json?.code || json?.error?.code || (typeof json?.error === "string" && /^[A-Z][A-Z0-9_]+$/.test(json.error) ? json.error : null) || `HTTP_${res.status}`;
242
318
  const message = json?.message || (typeof json?.error === "string" ? json.error : json?.error?.message) || `HTTP ${res.status}`;
319
+ const rateLimit = rateLimitInfo(res);
243
320
  throw new SkillError(code, redact(String(message)), {
244
321
  httpStatus: res.status,
245
322
  url: redactUrl(url),
246
- body: json ?? redact(text).slice(0, 800)
247
- });
248
- }
249
- if (json === null) {
250
- throw new SkillError("HTTP_BAD_JSON", "Endpoint returned a non-JSON body.", {
251
- url: redactUrl(url),
252
- snippet: redact(text).slice(0, 400)
323
+ body: json ?? redact(text).slice(0, 800),
324
+ ...retryable ? {
325
+ retries,
326
+ retryAfterSeconds: hintMs === null ? null : Math.ceil(hintMs / 1e3)
327
+ } : {},
328
+ ...rateLimit ? { rateLimit } : {}
253
329
  });
254
330
  }
255
- return json;
256
331
  }
257
332
  function redactUrl(url) {
258
333
  try {
@@ -582,17 +657,6 @@ function attributionIdentity(env = process.env) {
582
657
  }
583
658
 
584
659
  // scripts/src/lib/api.mjs
585
- var PKG_VERSION = (() => {
586
- const requireFrom = createRequire(import.meta.url);
587
- for (const candidate of ["../../package.json", "../../../package.json"]) {
588
- try {
589
- const pkg = requireFrom(candidate);
590
- if (pkg?.name === "@rozoai/checkout" && pkg.version) return pkg.version;
591
- } catch {
592
- }
593
- }
594
- return "0.0.0";
595
- })();
596
660
  var MPP_BASE = process.env.ROZO_CHECKOUT_MPP_BASE || "https://apiserver.mpprouter.dev/v1/services/rozo-agent-api";
597
661
  var CLIENT_LABEL = `rozo-checkout-cli/${PKG_VERSION}`;
598
662
  var ATTRIBUTION_CLIENT = `rozo-checkout-skill/${PKG_VERSION}`;
@@ -612,6 +676,7 @@ async function quoteInvoice({ url, linkId }) {
612
676
  const body = url ? { url } : { payment_id: linkId };
613
677
  return postJson(`${MPP_BASE}/quote-invoice`, body);
614
678
  }
679
+ var QUOTE_RECEIPT_RETRY_BUDGET_MS = 2e4;
615
680
  async function createInvoice({ url, linkId, source, quoteReceipt, utmSource, email }) {
616
681
  const body = {
617
682
  ...url ? { url } : { payment_id: linkId },
@@ -623,7 +688,11 @@ async function createInvoice({ url, linkId, source, quoteReceipt, utmSource, ema
623
688
  // sent empty, when the user gave none.
624
689
  ...email ? { email } : {}
625
690
  };
626
- return postJson(`${MPP_BASE}/create-invoice`, body);
691
+ return postJson(
692
+ `${MPP_BASE}/create-invoice`,
693
+ body,
694
+ quoteReceipt ? { maxTotalWaitMs: QUOTE_RECEIPT_RETRY_BUDGET_MS } : void 0
695
+ );
627
696
  }
628
697
  async function invoiceStatus({ linkId, rozoPaymentId }) {
629
698
  const qs = new URLSearchParams();
@@ -812,6 +881,8 @@ var MARGINS_MS = {
812
881
  };
813
882
  var BOLT11_MIN_VALIDITY_MS = 10 * MINUTE;
814
883
  var DEFAULT_MARGIN_MS = 10 * MINUTE;
884
+ var DEFAULT_WATCH_MS = 10 * MINUTE;
885
+ var LIGHTNING_WATCH_CAP_MS = 60 * MINUTE;
815
886
  function formatRemaining(ms) {
816
887
  if (!Number.isFinite(ms)) return "unknown";
817
888
  if (ms <= 0) return "expired";