@rozoai/checkout 0.1.16 → 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.
@@ -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
 
17
19
  // scripts/src/lib/output.mjs
18
20
  var PUBLIC_SUPPORT_URLS = /* @__PURE__ */ new Set([SUPPORT.x, SUPPORT.discord]);
@@ -139,18 +141,66 @@ function maskAddress(address) {
139
141
  return `${s.slice(0, 6)}...${s.slice(-4)}`;
140
142
  }
141
143
 
142
- // scripts/src/lib/api.mjs
144
+ // scripts/src/lib/version.mjs
143
145
  import { createRequire } from "node:module";
146
+ var PKG_VERSION = (() => {
147
+ const requireFrom = createRequire(import.meta.url);
148
+ for (const candidate of ["../../package.json", "../../../package.json"]) {
149
+ try {
150
+ const pkg = requireFrom(candidate);
151
+ if (pkg?.name === "@rozoai/checkout" && pkg.version) return pkg.version;
152
+ } catch {
153
+ }
154
+ }
155
+ return "0.0.0";
156
+ })();
144
157
 
145
158
  // scripts/src/lib/http.mjs
146
159
  var DEFAULT_TIMEOUT_MS = 2e4;
147
- var USER_AGENT = "rozo-checkout-skill/1.0";
148
- async function request(method, url, { body, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
160
+ var USER_AGENT = `rozo-checkout-skill/${PKG_VERSION}`;
161
+ var MAX_RETRIES = 3;
162
+ var MAX_TOTAL_WAIT_MS = 12e4;
163
+ var FALLBACK_BACKOFF_MS = [2e3, 4e3, 8e3];
164
+ var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
165
+ function parseRetryAfter(value, now = Date.now()) {
166
+ if (value === null || value === void 0) return null;
167
+ const s = String(value).trim();
168
+ if (!s) return null;
169
+ if (/^\d+(\.\d+)?$/.test(s)) return Math.round(Number(s) * 1e3);
170
+ const at = Date.parse(s);
171
+ if (!Number.isFinite(at)) return null;
172
+ return Math.max(0, at - now);
173
+ }
174
+ function bodyRetryAfterMs(json) {
175
+ const raw = json?.retryAfterSeconds ?? json?.error?.retryAfterSeconds;
176
+ if (raw === null || raw === void 0 || raw === "") return null;
177
+ const n = Number(raw);
178
+ return Number.isFinite(n) && n >= 0 ? Math.round(n * 1e3) : null;
179
+ }
180
+ function rateLimitInfo(res) {
181
+ const pick = (name) => res.headers?.get?.(name) ?? null;
182
+ const info = {
183
+ limit: pick("x-ratelimit-limit"),
184
+ remaining: pick("x-ratelimit-remaining"),
185
+ tier: pick("x-ratelimit-tier"),
186
+ scope: pick("x-ratelimit-scope")
187
+ };
188
+ const present = Object.fromEntries(Object.entries(info).filter(([, v]) => v !== null));
189
+ return Object.keys(present).length ? present : null;
190
+ }
191
+ function parseBody(text) {
192
+ if (!text) return null;
193
+ try {
194
+ return JSON.parse(text);
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+ async function fetchOnce(method, url, body, timeoutMs) {
149
200
  const controller = new AbortController();
150
201
  const timer = setTimeout(() => controller.abort(), timeoutMs);
151
- let res;
152
202
  try {
153
- res = await fetch(url, {
203
+ const res = await fetch(url, {
154
204
  method,
155
205
  headers: {
156
206
  accept: "application/json",
@@ -160,6 +210,8 @@ async function request(method, url, { body, timeoutMs = DEFAULT_TIMEOUT_MS } = {
160
210
  body: body !== void 0 ? JSON.stringify(body) : void 0,
161
211
  signal: controller.signal
162
212
  });
213
+ const text = await res.text();
214
+ return { res, text };
163
215
  } catch (err) {
164
216
  throw new SkillError(
165
217
  err?.name === "AbortError" ? "HTTP_TIMEOUT" : "HTTP_UNREACHABLE",
@@ -169,31 +221,54 @@ async function request(method, url, { body, timeoutMs = DEFAULT_TIMEOUT_MS } = {
169
221
  } finally {
170
222
  clearTimeout(timer);
171
223
  }
172
- const text = await res.text();
173
- let json = null;
174
- if (text) {
175
- try {
176
- json = JSON.parse(text);
177
- } catch {
178
- json = null;
224
+ }
225
+ async function request(method, url, {
226
+ body,
227
+ timeoutMs = DEFAULT_TIMEOUT_MS,
228
+ sleep: sleep2 = defaultSleep,
229
+ now = Date.now,
230
+ maxTotalWaitMs = MAX_TOTAL_WAIT_MS
231
+ } = {}) {
232
+ let waitedMs = 0;
233
+ let retries = 0;
234
+ for (; ; ) {
235
+ const { res, text } = await fetchOnce(method, url, body, timeoutMs);
236
+ const json = parseBody(text);
237
+ if (res.ok) {
238
+ if (json === null) {
239
+ throw new SkillError("HTTP_BAD_JSON", "Endpoint returned a non-JSON body.", {
240
+ url: redactUrl(url),
241
+ snippet: redact(text).slice(0, 400)
242
+ });
243
+ }
244
+ return json;
245
+ }
246
+ const headerWaitMs = parseRetryAfter(res.headers?.get?.("retry-after"), now());
247
+ const hintMs = headerWaitMs ?? bodyRetryAfterMs(json);
248
+ const retryable = res.status === 429 || res.status === 503 && hintMs !== null;
249
+ if (retryable && retries < MAX_RETRIES) {
250
+ const waitMs = hintMs ?? FALLBACK_BACKOFF_MS[retries];
251
+ if (waitedMs + waitMs <= Math.min(maxTotalWaitMs, MAX_TOTAL_WAIT_MS)) {
252
+ retries += 1;
253
+ waitedMs += waitMs;
254
+ await sleep2(waitMs);
255
+ continue;
256
+ }
179
257
  }
180
- }
181
- if (!res.ok) {
182
258
  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}`;
183
259
  const message = json?.message || (typeof json?.error === "string" ? json.error : json?.error?.message) || `HTTP ${res.status}`;
260
+ const rateLimit = rateLimitInfo(res);
184
261
  throw new SkillError(code, redact(String(message)), {
185
262
  httpStatus: res.status,
186
263
  url: redactUrl(url),
187
- body: json ?? redact(text).slice(0, 800)
264
+ body: json ?? redact(text).slice(0, 800),
265
+ ...retryable ? {
266
+ retries,
267
+ retryAfterSeconds: hintMs === null ? null : Math.ceil(hintMs / 1e3)
268
+ } : {},
269
+ ...rateLimit ? { rateLimit } : {}
188
270
  });
189
271
  }
190
- if (json === null) {
191
- throw new SkillError("HTTP_BAD_JSON", "Endpoint returned a non-JSON body.", {
192
- url: redactUrl(url),
193
- snippet: redact(text).slice(0, 400)
194
- });
195
- }
196
- return json;
197
272
  }
198
273
  function redactUrl(url) {
199
274
  try {
@@ -207,18 +282,58 @@ function getJson(url, opts) {
207
282
  return request("GET", url, opts);
208
283
  }
209
284
 
210
- // scripts/src/lib/api.mjs
211
- var PKG_VERSION = (() => {
212
- const requireFrom = createRequire(import.meta.url);
213
- for (const candidate of ["../../package.json", "../../../package.json"]) {
285
+ // scripts/src/lib/state.mjs
286
+ import fs from "node:fs";
287
+ import path from "node:path";
288
+ import os from "node:os";
289
+ function stateRoot() {
290
+ return process.env.ROZO_CHECKOUT_STATE_DIR || path.join(os.homedir(), ".rozo-checkout", "state");
291
+ }
292
+ function statePath(rozoPaymentId) {
293
+ if (!/^[A-Za-z0-9-]{8,64}$/.test(String(rozoPaymentId || ""))) {
294
+ throw new SkillError("BAD_ROZO_PAYMENT_ID", "Refusing to build a state path from that id.");
295
+ }
296
+ return path.join(stateRoot(), `${rozoPaymentId}.json`);
297
+ }
298
+ function readState(rozoPaymentId) {
299
+ const file = statePath(rozoPaymentId);
300
+ let raw;
301
+ try {
302
+ raw = fs.readFileSync(file, "utf8");
303
+ } catch (err) {
304
+ if (err.code === "ENOENT") return null;
305
+ throw new SkillError("STATE_UNREADABLE", `Cannot read local state: ${err.code}`);
306
+ }
307
+ try {
308
+ return JSON.parse(raw);
309
+ } catch {
310
+ throw new SkillError(
311
+ "STATE_CORRUPT",
312
+ "The local state file for this order is corrupt. Refusing to act; inspect it manually."
313
+ );
314
+ }
315
+ }
316
+ function findByLinkId(linkId) {
317
+ const dir = stateRoot();
318
+ let files = [];
319
+ try {
320
+ files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
321
+ } catch {
322
+ return null;
323
+ }
324
+ let best = null;
325
+ for (const f of files) {
214
326
  try {
215
- const pkg = requireFrom(candidate);
216
- if (pkg?.name === "@rozoai/checkout" && pkg.version) return pkg.version;
327
+ const s = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
328
+ if (s?.linkId !== linkId) continue;
329
+ if (!best || String(s.createdAt) > String(best.createdAt)) best = s;
217
330
  } catch {
218
331
  }
219
332
  }
220
- return "0.0.0";
221
- })();
333
+ return best;
334
+ }
335
+
336
+ // scripts/src/lib/api.mjs
222
337
  var MPP_BASE = process.env.ROZO_CHECKOUT_MPP_BASE || "https://apiserver.mpprouter.dev/v1/services/rozo-agent-api";
223
338
  var CLIENT_LABEL = `rozo-checkout-cli/${PKG_VERSION}`;
224
339
  var ATTRIBUTION_CLIENT = `rozo-checkout-skill/${PKG_VERSION}`;
@@ -569,6 +684,15 @@ var MARGINS_MS = {
569
684
  };
570
685
  var BOLT11_MIN_VALIDITY_MS = 10 * MINUTE;
571
686
  var DEFAULT_MARGIN_MS = 10 * MINUTE;
687
+ var DEFAULT_WATCH_MS = 10 * MINUTE;
688
+ var LIGHTNING_WATCH_CAP_MS = 60 * MINUTE;
689
+ function lightningWatchMs(expiresAt, { now = Date.now(), fallbackMs = DEFAULT_WATCH_MS } = {}) {
690
+ const at = parseDeadline(expiresAt);
691
+ if (at === null) return fallbackMs;
692
+ const remaining = at - now;
693
+ if (!(remaining > 0)) return fallbackMs;
694
+ return Math.min(remaining, LIGHTNING_WATCH_CAP_MS);
695
+ }
572
696
  function formatRemaining(ms) {
573
697
  if (!Number.isFinite(ms)) return "unknown";
574
698
  if (ms <= 0) return "expired";
@@ -678,57 +802,6 @@ function checkExpiry({
678
802
  return { ...withDeadline, ok: true, code: null, reason: null };
679
803
  }
680
804
 
681
- // scripts/src/lib/state.mjs
682
- import fs from "node:fs";
683
- import path from "node:path";
684
- import os from "node:os";
685
- function stateRoot() {
686
- return process.env.ROZO_CHECKOUT_STATE_DIR || path.join(os.homedir(), ".rozo-checkout", "state");
687
- }
688
- function statePath(rozoPaymentId) {
689
- if (!/^[A-Za-z0-9-]{8,64}$/.test(String(rozoPaymentId || ""))) {
690
- throw new SkillError("BAD_ROZO_PAYMENT_ID", "Refusing to build a state path from that id.");
691
- }
692
- return path.join(stateRoot(), `${rozoPaymentId}.json`);
693
- }
694
- function readState(rozoPaymentId) {
695
- const file = statePath(rozoPaymentId);
696
- let raw;
697
- try {
698
- raw = fs.readFileSync(file, "utf8");
699
- } catch (err) {
700
- if (err.code === "ENOENT") return null;
701
- throw new SkillError("STATE_UNREADABLE", `Cannot read local state: ${err.code}`);
702
- }
703
- try {
704
- return JSON.parse(raw);
705
- } catch {
706
- throw new SkillError(
707
- "STATE_CORRUPT",
708
- "The local state file for this order is corrupt. Refusing to act; inspect it manually."
709
- );
710
- }
711
- }
712
- function findByLinkId(linkId) {
713
- const dir = stateRoot();
714
- let files = [];
715
- try {
716
- files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
717
- } catch {
718
- return null;
719
- }
720
- let best = null;
721
- for (const f of files) {
722
- try {
723
- const s = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
724
- if (s?.linkId !== linkId) continue;
725
- if (!best || String(s.createdAt) > String(best.createdAt)) best = s;
726
- } catch {
727
- }
728
- }
729
- return best;
730
- }
731
-
732
805
  // scripts/src/lib/bitrefill.mjs
733
806
  var PROVIDER_BITREFILL = "bitrefill";
734
807
  var BITREFILL_DESTINATION = Object.freeze({ chainId: "8453", tokenSymbol: "USDC" });
@@ -932,7 +1005,7 @@ function sendWindowFor({ provider, chainId, payment, status, bitrefillExpiry, no
932
1005
  intentExpiresAt: payment?.expiresAt ?? deadline,
933
1006
  coinbaseExpiry: deadline
934
1007
  });
935
- return expiry2.ok ? { ok: true, code: null, minutesOfSlack: Math.floor(expiry2.msOfSlack / 6e4) } : { ok: false, code: expiry2.code, reason: expiry2.reason };
1008
+ return expiry2.ok ? { ok: true, code: null, minutesOfSlack: Math.floor(expiry2.msOfSlack / 6e4), deadlineMs: expiry2.effectiveDeadlineMs } : { ok: false, code: expiry2.code, reason: expiry2.reason };
936
1009
  }
937
1010
  if (!status) {
938
1011
  return { ok: false, code: "LINK_PAYABILITY_UNKNOWN", reason: "The Coinbase link state could not be read." };
@@ -945,7 +1018,7 @@ function sendWindowFor({ provider, chainId, payment, status, bitrefillExpiry, no
945
1018
  intentExpiresAt: payment?.expiresAt ?? status?.rozoPayment?.expiresAt,
946
1019
  coinbaseExpiry: status?.coinbase?.preApprovalExpiry
947
1020
  });
948
- return expiry.ok ? { ok: true, code: null, minutesOfSlack: Math.floor(expiry.msOfSlack / 6e4) } : { ok: false, code: expiry.code, reason: expiry.reason };
1021
+ return expiry.ok ? { ok: true, code: null, minutesOfSlack: Math.floor(expiry.msOfSlack / 6e4), deadlineMs: expiry.effectiveDeadlineMs } : { ok: false, code: expiry.code, reason: expiry.reason };
949
1022
  }
950
1023
  async function snapshot({ rozoPaymentId, linkId, provider: explicitProvider }) {
951
1024
  let provider = resolveProvider(explicitProvider, rozoPaymentId);
@@ -1088,6 +1161,16 @@ function statusGuidance(result) {
1088
1161
  if (result.state === "expired_unfunded") return EXPIRED_UNFUNDED_GUIDANCE;
1089
1162
  return result.terminal ? "Done." : "Still in flight. Poll again in ~10s.";
1090
1163
  }
1164
+ function watchTimeoutMs(timeoutArg, result, now = Date.now()) {
1165
+ if (timeoutArg !== void 0 && timeoutArg !== null && timeoutArg !== true) {
1166
+ const n = Number(timeoutArg);
1167
+ if (Number.isFinite(n)) return Math.max(0, n * 1e3);
1168
+ }
1169
+ if (result?.payin?.chain === chainName("lightning")) {
1170
+ return lightningWatchMs(result?.expiry?.expiresAt, { now, fallbackMs: DEFAULT_WATCH_MS });
1171
+ }
1172
+ return DEFAULT_WATCH_MS;
1173
+ }
1091
1174
  async function main(argv) {
1092
1175
  const args = parseArgs(argv);
1093
1176
  const rozoPaymentId = args["rozo-payment-id"] || (isRozoPaymentId(args._[0]) ? args._[0] : null);
@@ -1103,9 +1186,9 @@ async function main(argv) {
1103
1186
  usage("Required: --rozo-payment-id <uuid> and/or --link-id <pl_* | paymentSession_*>");
1104
1187
  }
1105
1188
  const watch = Boolean(args.watch);
1106
- const timeoutMs = Math.max(0, Number(args.timeout ?? 600) * 1e3);
1107
- const deadline = Date.now() + timeoutMs;
1189
+ const started = Date.now();
1108
1190
  let result = await snapshot({ rozoPaymentId, linkId, provider });
1191
+ const deadline = started + watchTimeoutMs(args.timeout, result, started);
1109
1192
  const history = [{ at: (/* @__PURE__ */ new Date()).toISOString(), state: result.state }];
1110
1193
  while (watch && !result.terminal && !result.escalate && !result.unknown && Date.now() < deadline) {
1111
1194
  await sleep(POLL_INTERVAL_MS);