@siglume/direct-request-payment 0.4.20 → 0.4.22

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/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.22 - 2026-06-20
4
+
5
+ - Fixed clean-checkout TypeScript resolution for template imports so CI and npm
6
+ release typechecks do not depend on a prebuilt `dist/` directory.
7
+
8
+ ## 0.4.21 - 2026-06-20
9
+
10
+ Complete the 10-minute integration path with durable adapters, sandbox, and E2E.
11
+
12
+ - Added a local `siglume-sdrp sandbox` server that creates fake Hosted Checkout
13
+ sessions, sends signed `direct_payment.confirmed` webhooks, records delivery
14
+ status, and never charges a wallet.
15
+ - Added `SIGLUME_ENV=sandbox`, `SIGLUME_SANDBOX_API_BASE`, and
16
+ `siglume-check readiness --sandbox` so sandbox and live checks are explicit.
17
+ - Added durable Express SQL/ORM order-store adapters for Prisma, TypeORM,
18
+ Sequelize, Drizzle, and generic SQL executors.
19
+ - Added a durable FastAPI SQLAlchemy order-store adapter and packaged it in the
20
+ Python templates.
21
+ - Added Express and FastAPI E2E tests covering checkout start, checkout URL
22
+ reuse, signed webhook success, duplicate webhook suppression, retry after
23
+ handler failure, and Standard-only Micro/Nano blocking.
24
+ - Updated the 10-minute guide, sandbox guide, template READMEs, API reference,
25
+ troubleshooting, and README so implementers can test locally before live
26
+ credentials.
27
+
3
28
  ## 0.4.20 - 2026-06-20
4
29
 
5
30
  Close the v0.4.19 public onboarding safety review.
package/README.md CHANGED
@@ -86,6 +86,8 @@ CLI-first:
86
86
 
87
87
  ```bash
88
88
  npm install @siglume/direct-request-payment
89
+ npx siglume-sdrp sandbox --webhook-url http://localhost:3000/payments/webhooks/siglume
90
+ npx siglume-check readiness --sandbox
89
91
  npx siglume-check readiness
90
92
  npx siglume-sdrp init express --target src/siglume
91
93
  ```
@@ -97,9 +99,12 @@ pip install siglume-direct-request-payment
97
99
  siglume-sdrp init fastapi --target app/siglume
98
100
  ```
99
101
 
100
- The readiness command checks account, billing, origin, webhook, and Hosted
101
- Checkout availability before you write checkout code. It also confirms the
102
- webhook subscription and signed test delivery when API probes are enabled.
102
+ The sandbox command starts a local Siglume-compatible API that creates fake
103
+ checkout sessions and sends signed webhooks to your product. It never charges a
104
+ wallet; see [SDRP Sandbox](./docs/sandbox.md). The readiness command checks
105
+ account, billing, origin, webhook, and Hosted Checkout availability before you
106
+ write checkout code. It also confirms the webhook subscription and signed test
107
+ delivery when API probes are enabled.
103
108
 
104
109
  Before implementation, confirm Hosted Checkout readiness in
105
110
  [Troubleshooting](./docs/troubleshooting.md#hosted-checkout-readiness). For
@@ -2,10 +2,12 @@
2
2
 
3
3
  import { readFileSync } from "node:fs";
4
4
  import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
5
+ import { createServer } from "node:http";
5
6
  import { dirname, join, resolve } from "node:path";
6
7
  import { fileURLToPath } from "node:url";
7
8
 
8
9
  import {
10
+ buildWebhookSignatureHeader,
9
11
  DirectRequestPaymentMerchantClient,
10
12
  HostedCheckoutNotAvailableError,
11
13
  SiglumeApiError,
@@ -29,6 +31,10 @@ async function main() {
29
31
  await readiness(parseArgs(args));
30
32
  return;
31
33
  }
34
+ if (command === "sandbox") {
35
+ await sandbox(parseArgs(args));
36
+ return;
37
+ }
32
38
  if (command === "init") {
33
39
  await init(args);
34
40
  return;
@@ -41,6 +47,7 @@ function printHelp() {
41
47
 
42
48
  Usage:
43
49
  siglume-check readiness --merchant <key> --origin <https://shop.example> --webhook-url <https://api.example/siglume/webhook>
50
+ siglume-sdrp sandbox --webhook-url <http://localhost:3000/payments/webhooks/siglume>
44
51
  siglume-sdrp init express --target src/siglume
45
52
  siglume-sdrp init fastapi --target app/siglume
46
53
 
@@ -51,9 +58,17 @@ Readiness options:
51
58
  --currency <JPY|USD> Probe currency. Defaults to SIGLUME_DIRECT_PAYMENT_TEST_CURRENCY or JPY.
52
59
  --amount-minor <amount> Standard-band probe amount. Defaults to 501 for JPY, 301 for USD.
53
60
  --base-url <url> Siglume API base URL. Defaults to SIGLUME_API_BASE or production.
61
+ --sandbox Use the local sandbox default API base (http://127.0.0.1:8787/v1).
54
62
  --no-api Validate local config only; do not call Siglume.
55
63
  --no-probe Partial API check only; readiness will not be reported as ready.
56
64
  --json Print machine-readable JSON.
65
+
66
+ Sandbox options:
67
+ --port <port> Local sandbox port. Defaults to 8787.
68
+ --merchant <key> Sandbox merchant key. Defaults to sandbox_merchant.
69
+ --origin <origin> Shop origin allowed by the sandbox. Defaults to http://localhost:3000.
70
+ --webhook-url <url> Your local product webhook URL.
71
+ --webhook-secret <secret> Sandbox webhook secret. Defaults to whsec_sandbox_local.
57
72
  `);
58
73
  }
59
74
 
@@ -67,6 +82,8 @@ function parseArgs(args) {
67
82
  out.probe = false;
68
83
  } else if (arg === "--json") {
69
84
  out.json = true;
85
+ } else if (arg === "--sandbox") {
86
+ out.sandbox = true;
70
87
  } else if (arg === "--force") {
71
88
  out.force = true;
72
89
  } else if (arg.startsWith("--")) {
@@ -86,6 +103,7 @@ function parseArgs(args) {
86
103
 
87
104
  async function readiness(options) {
88
105
  const checks = [];
106
+ const sandboxMode = Boolean(options.sandbox) || String(process.env.SIGLUME_ENV || "").toLowerCase() === "sandbox";
89
107
  const merchant = options.merchant || process.env.SIGLUME_DIRECT_PAYMENT_MERCHANT || "";
90
108
  const origin = options.origin || process.env.SHOP_PUBLIC_ORIGIN || "";
91
109
  const webhookUrl = options.webhookUrl || process.env.SHOP_WEBHOOK_URL || "";
@@ -93,18 +111,20 @@ async function readiness(options) {
93
111
  const token = process.env.SIGLUME_MERCHANT_AUTH_TOKEN || process.env.SIGLUME_AUTH_TOKEN || "";
94
112
  const currency = normalizeCurrency(options.currency || process.env.SIGLUME_DIRECT_PAYMENT_TEST_CURRENCY || "JPY");
95
113
  const amountMinor = Number(options.amountMinor || process.env.SIGLUME_DIRECT_PAYMENT_TEST_AMOUNT_MINOR || (currency === "USD" ? 301 : 501));
114
+ const baseUrl = options.baseUrl || process.env.SIGLUME_API_BASE || (sandboxMode ? process.env.SIGLUME_SANDBOX_API_BASE || "http://127.0.0.1:8787/v1" : undefined);
96
115
 
116
+ check(checks, "target_environment", true, sandboxMode ? "sandbox" : "live");
97
117
  check(checks, "merchant_key", Boolean(merchant), "Set SIGLUME_DIRECT_PAYMENT_MERCHANT or pass --merchant.");
98
- check(checks, "merchant_token", Boolean(token) && !token.startsWith("cli_"), "Set SIGLUME_MERCHANT_AUTH_TOKEN to a merchant Siglume bearer token, not a cli_ key.");
99
- check(checks, "shop_origin", isHttpsOrigin(origin), "Set SHOP_PUBLIC_ORIGIN to an https origin, for example https://www.example.com.");
100
- check(checks, "webhook_url", isHttpsUrl(webhookUrl), "Set SHOP_WEBHOOK_URL to a public https webhook URL.");
118
+ check(checks, "merchant_token", Boolean(token) && (sandboxMode || !token.startsWith("cli_")), "Set SIGLUME_MERCHANT_AUTH_TOKEN to a merchant Siglume bearer token, not a cli_ key.");
119
+ check(checks, "shop_origin", isAllowedOrigin(origin, sandboxMode), sandboxMode ? "Set SHOP_PUBLIC_ORIGIN to your local product origin, for example http://localhost:3000." : "Set SHOP_PUBLIC_ORIGIN to an https origin, for example https://www.example.com.");
120
+ check(checks, "webhook_url", isAllowedWebhookUrl(webhookUrl, sandboxMode), sandboxMode ? "Set SHOP_WEBHOOK_URL to your local webhook URL, for example http://localhost:3000/payments/webhooks/siglume." : "Set SHOP_WEBHOOK_URL to a public https webhook URL.");
101
121
  check(checks, "webhook_secret_present", Boolean(webhookSecret) && webhookSecret.startsWith("whsec_"), "Set SIGLUME_WEBHOOK_SECRET to the webhook signing secret returned by setupCheckout/setup_checkout.");
102
122
  check(checks, "standard_probe_amount", isStandardAmount(currency, amountMinor), "Use a Standard-band probe amount: JPY 501+ or USD 301+ minor units.");
103
123
 
104
124
  if (options.api && !hasFailures(checks)) {
105
125
  const merchantClient = new DirectRequestPaymentMerchantClient({
106
126
  auth_token: token,
107
- base_url: options.baseUrl || process.env.SIGLUME_API_BASE,
127
+ base_url: baseUrl,
108
128
  });
109
129
  let matchingWebhookSubscription = null;
110
130
  try {
@@ -185,7 +205,7 @@ async function readiness(options) {
185
205
  if (ok && !options.api) {
186
206
  console.log("Local config checks passed. API, Hosted Checkout, and webhook delivery readiness were not verified.");
187
207
  } else {
188
- console.log(ok ? "Ready for 10-minute SDRP integration." : "Not ready. Fix the FAIL items before coding checkout.");
208
+ console.log(ok ? `Ready for 10-minute SDRP integration (${sandboxMode ? "sandbox" : "live"}).` : "Not ready. Fix the FAIL items before coding checkout.");
189
209
  }
190
210
  }
191
211
  if (!ok) {
@@ -216,6 +236,370 @@ async function init(args) {
216
236
  console.log("Wire the exported router into your app, then run siglume-check readiness before opening checkout.");
217
237
  }
218
238
 
239
+ async function sandbox(options) {
240
+ const port = Number(options.port || process.env.SIGLUME_SANDBOX_PORT || 8787);
241
+ const merchant = options.merchant || process.env.SIGLUME_DIRECT_PAYMENT_MERCHANT || "sandbox_merchant";
242
+ const origin = options.origin || process.env.SHOP_PUBLIC_ORIGIN || "http://localhost:3000";
243
+ const webhookUrl = options.webhookUrl || process.env.SHOP_WEBHOOK_URL || "";
244
+ const webhookSecret = options.webhookSecret || process.env.SIGLUME_WEBHOOK_SECRET || "whsec_sandbox_local";
245
+ if (!Number.isSafeInteger(port) || port <= 0) {
246
+ throw new Error("--port must be a positive integer.");
247
+ }
248
+ if (!webhookUrl) {
249
+ throw new Error("sandbox requires --webhook-url <your local product webhook URL>.");
250
+ }
251
+ if (!isAllowedWebhookUrl(webhookUrl, true)) {
252
+ throw new Error("--webhook-url must be https or local http.");
253
+ }
254
+
255
+ const state = {
256
+ merchant,
257
+ origin,
258
+ webhookUrl,
259
+ webhookSecret,
260
+ subscriptionId: "whsub_sandbox_local",
261
+ sessions: new Map(),
262
+ deliveries: [],
263
+ };
264
+
265
+ const server = createServer(async (req, res) => {
266
+ try {
267
+ await handleSandboxRequest(req, res, state, port);
268
+ } catch (error) {
269
+ sendJson(res, 500, {
270
+ error: {
271
+ code: "SANDBOX_INTERNAL_ERROR",
272
+ message: error instanceof Error ? error.message : String(error),
273
+ },
274
+ });
275
+ }
276
+ });
277
+
278
+ await new Promise((resolveServer) => server.listen(port, "127.0.0.1", resolveServer));
279
+ const apiBase = `http://127.0.0.1:${port}/v1`;
280
+ if (options.json) {
281
+ console.log(JSON.stringify({
282
+ api_base: apiBase,
283
+ merchant,
284
+ webhook_url: webhookUrl,
285
+ webhook_secret: webhookSecret,
286
+ }, null, 2));
287
+ } else {
288
+ console.log("Siglume SDRP sandbox is running.");
289
+ console.log(`SIGLUME_ENV=sandbox`);
290
+ console.log(`SIGLUME_API_BASE=${apiBase}`);
291
+ console.log(`SIGLUME_DIRECT_PAYMENT_MERCHANT=${merchant}`);
292
+ console.log(`SIGLUME_MERCHANT_AUTH_TOKEN=sandbox_merchant_token`);
293
+ console.log(`SIGLUME_WEBHOOK_SECRET=${webhookSecret}`);
294
+ console.log(`SHOP_PUBLIC_ORIGIN=${origin}`);
295
+ console.log(`SHOP_WEBHOOK_URL=${webhookUrl}`);
296
+ console.log("");
297
+ console.log(`Then run: siglume-check readiness --sandbox`);
298
+ }
299
+ }
300
+
301
+ async function handleSandboxRequest(req, res, state, port) {
302
+ const url = new URL(req.url || "/", `http://127.0.0.1:${port}`);
303
+ if (req.method === "GET" && url.pathname === "/favicon.ico") {
304
+ res.writeHead(204);
305
+ res.end();
306
+ return;
307
+ }
308
+
309
+ if (req.method === "GET" && url.pathname === `/v1/sdrp/direct-payments/merchants/${state.merchant}`) {
310
+ sendEnvelope(res, 200, {
311
+ merchant_account: {
312
+ merchant_account_id: "macc_sandbox_local",
313
+ merchant: state.merchant,
314
+ merchant_user_id: "usr_sandbox_merchant",
315
+ billing_mandate_id: "mandate_sandbox_active",
316
+ status: "active",
317
+ billing_status: "active",
318
+ billing_plan: "launch",
319
+ billing_currency: "JPY",
320
+ token_symbol: "JPYC",
321
+ metadata_jsonb: {
322
+ environment: "sandbox",
323
+ checkout_allowed_origins: [state.origin],
324
+ webhook_callback_url: state.webhookUrl,
325
+ },
326
+ },
327
+ challenge_secret_created: true,
328
+ mandate: { mandate_id: "mandate_sandbox_active", status: "active" },
329
+ });
330
+ return;
331
+ }
332
+
333
+ if (req.method === "GET" && url.pathname === "/v1/market/webhooks/subscriptions") {
334
+ sendEnvelope(res, 200, [sandboxSubscription(state)]);
335
+ return;
336
+ }
337
+
338
+ if (req.method === "POST" && url.pathname === "/v1/sdrp/direct-payments/checkout-sessions") {
339
+ const body = await readJson(req);
340
+ if (String(body.merchant || "") !== state.merchant) {
341
+ sendJson(res, 404, { error: { code: "EXTERNAL_402_MERCHANT_NOT_FOUND", message: "sandbox merchant not found" } });
342
+ return;
343
+ }
344
+ const sessionId = `chk_sandbox_${state.sessions.size + 1}`;
345
+ const challengeHash = `sha256:sandbox_${hashString(`${sessionId}:${body.nonce || ""}`).slice(0, 32)}`;
346
+ const session = {
347
+ session_id: sessionId,
348
+ merchant: state.merchant,
349
+ amount_minor: Number(body.amount_minor),
350
+ currency: String(body.currency || "JPY").toUpperCase(),
351
+ token_symbol: String(body.currency || "JPY").toUpperCase() === "USD" ? "USDC" : "JPYC",
352
+ status: "open",
353
+ challenge_hash: challengeHash,
354
+ success_url: String(body.success_url || ""),
355
+ cancel_url: String(body.cancel_url || ""),
356
+ metadata_jsonb: body.metadata && typeof body.metadata === "object" ? body.metadata : {},
357
+ checkout_url: `http://127.0.0.1:${port}/pay/${sessionId}`,
358
+ expires_at: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
359
+ };
360
+ state.sessions.set(sessionId, session);
361
+ sendEnvelope(res, 201, {
362
+ checkout_url: session.checkout_url,
363
+ session_id: sessionId,
364
+ challenge_hash: challengeHash,
365
+ status: "open",
366
+ expires_at: session.expires_at,
367
+ });
368
+ return;
369
+ }
370
+
371
+ if (req.method === "GET" && url.pathname.startsWith("/v1/sdrp/direct-payments/checkout-sessions/")) {
372
+ const sessionId = decodeURIComponent(url.pathname.split("/").pop() || "");
373
+ const session = state.sessions.get(sessionId);
374
+ if (!session) {
375
+ sendJson(res, 404, { error: { code: "CHECKOUT_SESSION_NOT_FOUND", message: "sandbox session not found" } });
376
+ return;
377
+ }
378
+ sendEnvelope(res, 200, session);
379
+ return;
380
+ }
381
+
382
+ if (req.method === "POST" && url.pathname === "/v1/market/webhooks/test-deliveries") {
383
+ const body = await readJson(req);
384
+ const event = sandboxEvent({
385
+ event_type: String(body.event_type || "direct_payment.confirmed"),
386
+ data: body.data && typeof body.data === "object" ? body.data : {},
387
+ });
388
+ await deliverSandboxWebhook(state, event);
389
+ sendEnvelope(res, 201, { queued: true, event: { id: event.id, type: event.type } });
390
+ return;
391
+ }
392
+
393
+ if (req.method === "GET" && url.pathname === "/v1/market/webhooks/deliveries") {
394
+ let deliveries = [...state.deliveries];
395
+ const eventType = url.searchParams.get("event_type");
396
+ if (eventType) deliveries = deliveries.filter((delivery) => delivery.event_type === eventType);
397
+ const limit = Number(url.searchParams.get("limit") || 50);
398
+ sendEnvelope(res, 200, deliveries.slice(0, Math.max(1, Math.min(limit, 100))));
399
+ return;
400
+ }
401
+
402
+ if (req.method === "GET" && url.pathname.startsWith("/pay/")) {
403
+ const sessionId = decodeURIComponent(url.pathname.split("/").pop() || "");
404
+ const session = state.sessions.get(sessionId);
405
+ if (!session) {
406
+ sendHtml(res, 404, "<h1>Sandbox checkout session not found</h1>");
407
+ return;
408
+ }
409
+ sendHtml(res, 200, sandboxCheckoutHtml(session));
410
+ return;
411
+ }
412
+
413
+ if (req.method === "POST" && url.pathname.startsWith("/v1/sandbox/checkout-sessions/") && url.pathname.endsWith("/confirm")) {
414
+ const parts = url.pathname.split("/");
415
+ const sessionId = decodeURIComponent(parts[4] || "");
416
+ const session = state.sessions.get(sessionId);
417
+ if (!session) {
418
+ sendJson(res, 404, { error: { code: "CHECKOUT_SESSION_NOT_FOUND", message: "sandbox session not found" } });
419
+ return;
420
+ }
421
+ session.status = "paid";
422
+ session.requirement_id = `dpr_sandbox_${sessionId}`;
423
+ const event = sandboxPaymentConfirmedEvent(session);
424
+ await deliverSandboxWebhook(state, event);
425
+ sendEnvelope(res, 200, {
426
+ status: "paid",
427
+ redirect_url: `${session.success_url}${session.success_url.includes("?") ? "&" : "?"}session_id=${encodeURIComponent(sessionId)}`,
428
+ event: { id: event.id, type: event.type },
429
+ });
430
+ return;
431
+ }
432
+
433
+ sendJson(res, 404, { error: { code: "SANDBOX_ROUTE_NOT_FOUND", message: "sandbox route not found" } });
434
+ }
435
+
436
+ function sandboxSubscription(state) {
437
+ return {
438
+ id: state.subscriptionId,
439
+ webhook_subscription_id: state.subscriptionId,
440
+ callback_url: state.webhookUrl,
441
+ status: "active",
442
+ event_types: ["direct_payment.confirmed"],
443
+ signing_secret_hint: state.webhookSecret.slice(-4),
444
+ metadata: { environment: "sandbox" },
445
+ };
446
+ }
447
+
448
+ function sandboxEvent({ event_type, data }) {
449
+ return {
450
+ id: `evt_sandbox_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`,
451
+ type: event_type,
452
+ api_version: "2026-06-20",
453
+ occurred_at: new Date().toISOString(),
454
+ data: {
455
+ mode: "external_402",
456
+ pricing_band: "standard",
457
+ finality: "per_payment_onchain",
458
+ settlement_status: "settled",
459
+ requirement_id: `dpr_sandbox_${Date.now()}`,
460
+ challenge_hash: "sha256:sandbox_readiness",
461
+ chain_receipt_id: `chain_sandbox_${Date.now()}`,
462
+ environment: "sandbox",
463
+ ...data,
464
+ },
465
+ };
466
+ }
467
+
468
+ function sandboxPaymentConfirmedEvent(session) {
469
+ const pricingBand = classifySandboxAmount(session.currency, Number(session.amount_minor));
470
+ const metered = pricingBand === "micro" || pricingBand === "nano";
471
+ return sandboxEvent({
472
+ event_type: "direct_payment.confirmed",
473
+ data: {
474
+ merchant: session.merchant,
475
+ requirement_id: session.requirement_id,
476
+ direct_payment_requirement_id: session.requirement_id,
477
+ challenge_hash: session.challenge_hash,
478
+ amount_minor: session.amount_minor,
479
+ currency: session.currency,
480
+ token_symbol: session.token_symbol,
481
+ pricing_band: pricingBand,
482
+ settlement_cadence: pricingBand === "micro" ? "weekly" : pricingBand === "nano" ? "monthly" : "per_payment",
483
+ finality: metered ? "aggregated_onchain_settlement" : "per_payment_onchain",
484
+ settlement_status: metered ? "pending_settlement" : "settled",
485
+ chain_receipt_id: metered ? undefined : `chain_sandbox_${session.session_id}`,
486
+ environment: "sandbox",
487
+ },
488
+ });
489
+ }
490
+
491
+ async function deliverSandboxWebhook(state, event) {
492
+ const rawBody = JSON.stringify(event);
493
+ const signature = await buildWebhookSignatureHeader(state.webhookSecret, rawBody);
494
+ let status = "failed";
495
+ let responseStatus = null;
496
+ try {
497
+ const response = await fetch(state.webhookUrl, {
498
+ method: "POST",
499
+ headers: {
500
+ "content-type": "application/json",
501
+ "siglume-signature": signature,
502
+ "x-siglume-environment": "sandbox",
503
+ },
504
+ body: rawBody,
505
+ });
506
+ responseStatus = response.status;
507
+ status = response.ok ? "delivered" : "failed";
508
+ } catch {
509
+ status = "failed";
510
+ }
511
+ state.deliveries.unshift({
512
+ id: `whdel_sandbox_${state.deliveries.length + 1}`,
513
+ subscription_id: state.subscriptionId,
514
+ event_id: event.id,
515
+ event_type: event.type,
516
+ delivery_status: status,
517
+ response_status: responseStatus,
518
+ delivered_at: status === "delivered" ? new Date().toISOString() : null,
519
+ });
520
+ }
521
+
522
+ function sandboxCheckoutHtml(session) {
523
+ return `<!doctype html>
524
+ <meta charset="utf-8">
525
+ <title>Siglume SDRP Sandbox Checkout</title>
526
+ <body style="font-family: system-ui, sans-serif; max-width: 680px; margin: 48px auto; line-height: 1.5;">
527
+ <h1>Siglume SDRP Sandbox Checkout</h1>
528
+ <p>This is a local sandbox page. No real wallet, token, or on-chain settlement is used.</p>
529
+ <dl>
530
+ <dt>Session</dt><dd>${escapeHtml(session.session_id)}</dd>
531
+ <dt>Merchant</dt><dd>${escapeHtml(session.merchant)}</dd>
532
+ <dt>Amount</dt><dd>${escapeHtml(String(session.amount_minor))} ${escapeHtml(session.currency)}</dd>
533
+ <dt>Status</dt><dd id="status">${escapeHtml(session.status)}</dd>
534
+ </dl>
535
+ <button id="confirm" style="font: inherit; padding: 10px 14px;">Confirm sandbox payment</button>
536
+ <pre id="output"></pre>
537
+ <script>
538
+ document.getElementById("confirm").addEventListener("click", async () => {
539
+ const response = await fetch("/v1/sandbox/checkout-sessions/${encodeURIComponent(session.session_id)}/confirm", { method: "POST" });
540
+ const body = await response.json();
541
+ document.getElementById("status").textContent = body.data?.status || "failed";
542
+ document.getElementById("output").textContent = JSON.stringify(body, null, 2);
543
+ });
544
+ </script>
545
+ </body>`;
546
+ }
547
+
548
+ async function readJson(req) {
549
+ const chunks = [];
550
+ for await (const chunk of req) chunks.push(chunk);
551
+ const text = Buffer.concat(chunks).toString("utf8");
552
+ if (!text) return {};
553
+ return JSON.parse(text);
554
+ }
555
+
556
+ function sendEnvelope(res, status, data) {
557
+ sendJson(res, status, { data, meta: { request_id: "req_sandbox", trace_id: "trc_sandbox" } });
558
+ }
559
+
560
+ function sendJson(res, status, body) {
561
+ res.writeHead(status, { "content-type": "application/json" });
562
+ res.end(JSON.stringify(body));
563
+ }
564
+
565
+ function sendHtml(res, status, body) {
566
+ res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
567
+ res.end(body);
568
+ }
569
+
570
+ function hashString(value) {
571
+ let hash = 0;
572
+ for (let index = 0; index < value.length; index += 1) {
573
+ hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0;
574
+ }
575
+ return Math.abs(hash).toString(16).padStart(8, "0");
576
+ }
577
+
578
+ function classifySandboxAmount(currency, amountMinor) {
579
+ const normalizedCurrency = String(currency || "").toUpperCase();
580
+ if (normalizedCurrency === "JPY") {
581
+ if (amountMinor >= 501) return "standard";
582
+ if (amountMinor >= 50) return "micro";
583
+ return "nano";
584
+ }
585
+ if (normalizedCurrency === "USD") {
586
+ if (amountMinor >= 301) return "standard";
587
+ if (amountMinor >= 31) return "micro";
588
+ return "nano";
589
+ }
590
+ return "standard";
591
+ }
592
+
593
+ function escapeHtml(value) {
594
+ return String(value).replace(/[&<>"']/g, (char) => ({
595
+ "&": "&amp;",
596
+ "<": "&lt;",
597
+ ">": "&gt;",
598
+ "\"": "&quot;",
599
+ "'": "&#39;",
600
+ }[char]));
601
+ }
602
+
219
603
  async function findCopyConflicts(from, to) {
220
604
  const conflicts = [];
221
605
  for (const entry of await readdir(from)) {
@@ -297,6 +681,17 @@ function isHttpsOrigin(value) {
297
681
  }
298
682
  }
299
683
 
684
+ function isAllowedOrigin(value, sandboxMode) {
685
+ if (isHttpsOrigin(value)) return true;
686
+ if (!sandboxMode) return false;
687
+ try {
688
+ const url = new URL(value);
689
+ return url.protocol === "http:" && isLocalhost(url.hostname) && url.origin === value.replace(/\/$/, "");
690
+ } catch {
691
+ return false;
692
+ }
693
+ }
694
+
300
695
  function isHttpsUrl(value) {
301
696
  try {
302
697
  const url = new URL(value);
@@ -306,6 +701,22 @@ function isHttpsUrl(value) {
306
701
  }
307
702
  }
308
703
 
704
+ function isAllowedWebhookUrl(value, sandboxMode) {
705
+ if (isHttpsUrl(value)) return true;
706
+ if (!sandboxMode) return false;
707
+ try {
708
+ const url = new URL(value);
709
+ return url.protocol === "http:" && isLocalhost(url.hostname);
710
+ } catch {
711
+ return false;
712
+ }
713
+ }
714
+
715
+ function isLocalhost(hostname) {
716
+ const host = String(hostname || "").toLowerCase();
717
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
718
+ }
719
+
309
720
  function normalizeCurrency(value) {
310
721
  const currency = String(value || "").toUpperCase();
311
722
  if (currency !== "JPY" && currency !== "USD") {
package/dist/index.cjs CHANGED
@@ -31,6 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var src_exports = {};
32
32
  __export(src_exports, {
33
33
  DEFAULT_SIGLUME_API_BASE: () => DEFAULT_SIGLUME_API_BASE,
34
+ DEFAULT_SIGLUME_SANDBOX_API_BASE: () => DEFAULT_SIGLUME_SANDBOX_API_BASE,
34
35
  DEFAULT_WEBHOOK_TOLERANCE_SECONDS: () => DEFAULT_WEBHOOK_TOLERANCE_SECONDS,
35
36
  DIRECT_REQUEST_PAYMENT_ALLOWANCE_RECEIPT_KIND: () => DIRECT_REQUEST_PAYMENT_ALLOWANCE_RECEIPT_KIND,
36
37
  DIRECT_REQUEST_PAYMENT_CHALLENGE_SCHEME: () => DIRECT_REQUEST_PAYMENT_CHALLENGE_SCHEME,
@@ -76,6 +77,7 @@ __export(src_exports, {
76
77
  });
77
78
  module.exports = __toCommonJS(src_exports);
78
79
  var DEFAULT_SIGLUME_API_BASE = "https://siglume.com/v1";
80
+ var DEFAULT_SIGLUME_SANDBOX_API_BASE = "http://127.0.0.1:8787/v1";
79
81
  var DIRECT_REQUEST_PAYMENT_CHALLENGE_SCHEME = "siglume-external-402-v1";
80
82
  var DIRECT_REQUEST_PAYMENT_RECURRING_CHALLENGE_SCHEME = "siglume-external-402-recurring-v1";
81
83
  var DIRECT_REQUEST_PAYMENT_MODE = "external_402";
@@ -83,7 +85,7 @@ var DIRECT_REQUEST_PAYMENT_RECEIPT_KIND = "sdrp_direct_payment";
83
85
  var DIRECT_REQUEST_PAYMENT_ALLOWANCE_RECEIPT_KIND = "sdrp_direct_payment_allowance";
84
86
  var DIRECT_REQUEST_PAYMENT_REFERENCE_TYPE = "sdrp_direct_payment_requirement";
85
87
  var DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300;
86
- var DIRECT_REQUEST_PAYMENT_SDK_VERSION = "0.4.20";
88
+ var DIRECT_REQUEST_PAYMENT_SDK_VERSION = "0.4.22";
87
89
  var DIRECT_REQUEST_PAYMENT_STANDARD_SETTLED_STATUS = "settled";
88
90
  var DIRECT_REQUEST_PAYMENT_METERED_ACCEPTED_STATUS = "pending_settlement";
89
91
  var DIRECT_REQUEST_PAYMENT_STANDARD_FINALITY = "per_payment_onchain";
@@ -143,7 +145,7 @@ var DirectRequestPaymentClient = class {
143
145
  throw new SiglumeDirectRequestPaymentError("A fetch implementation is required in this runtime.");
144
146
  }
145
147
  this.#authToken = authToken;
146
- this.base_url = normalizeApiBaseUrl(options.base_url ?? envValue("SIGLUME_API_BASE") ?? DEFAULT_SIGLUME_API_BASE);
148
+ this.base_url = normalizeApiBaseUrl(options.base_url ?? defaultApiBaseUrl());
147
149
  this.timeout_ms = Math.max(1, Math.trunc(options.timeout_ms ?? 15e3));
148
150
  this.user_agent = options.user_agent ?? `@siglume/direct-request-payment/${DIRECT_REQUEST_PAYMENT_SDK_VERSION}`;
149
151
  this.fetch_impl = fetchImpl;
@@ -295,7 +297,7 @@ var DirectRequestPaymentMerchantClient = class {
295
297
  throw new SiglumeDirectRequestPaymentError("A fetch implementation is required in this runtime.");
296
298
  }
297
299
  this.#authToken = authToken;
298
- this.base_url = normalizeApiBaseUrl(options.base_url ?? envValue("SIGLUME_API_BASE") ?? DEFAULT_SIGLUME_API_BASE);
300
+ this.base_url = normalizeApiBaseUrl(options.base_url ?? defaultApiBaseUrl());
299
301
  this.timeout_ms = Math.max(1, Math.trunc(options.timeout_ms ?? 15e3));
300
302
  this.user_agent = options.user_agent ?? `@siglume/direct-request-payment/${DIRECT_REQUEST_PAYMENT_SDK_VERSION}`;
301
303
  this.fetch_impl = fetchImpl;
@@ -1096,6 +1098,14 @@ function envValue(name) {
1096
1098
  const value = process.env[name];
1097
1099
  return value && value.trim() ? value.trim() : void 0;
1098
1100
  }
1101
+ function defaultApiBaseUrl() {
1102
+ const explicit = envValue("SIGLUME_API_BASE");
1103
+ if (explicit) return explicit;
1104
+ if ((envValue("SIGLUME_ENV") || "").toLowerCase() === "sandbox") {
1105
+ return envValue("SIGLUME_SANDBOX_API_BASE") || DEFAULT_SIGLUME_SANDBOX_API_BASE;
1106
+ }
1107
+ return DEFAULT_SIGLUME_API_BASE;
1108
+ }
1099
1109
  function bodyBytes(body) {
1100
1110
  if (body instanceof Uint8Array) {
1101
1111
  return body;