@siglume/direct-request-payment 0.4.20 → 0.4.23

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,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.23 - 2026-06-20
4
+
5
+ - Made the local SDRP sandbox reject invalid checkout input early, including
6
+ non-positive `amount_minor`, unsupported currencies, and unsafe return URLs.
7
+ - Made sandbox checkout confirmation idempotent so repeated confirmation calls
8
+ return the original event and do not send duplicate webhooks.
9
+ - Made the Express SQL order-store adapter recoverable for custom SQL executors
10
+ without a transaction hook by marking failed webhook handling as retryable
11
+ instead of permanently treating it as a duplicate.
12
+ - Added E2E coverage for sandbox idempotency, invalid sandbox input, and
13
+ non-transactional webhook retry recovery.
14
+
15
+ ## 0.4.22 - 2026-06-20
16
+
17
+ - Fixed clean-checkout TypeScript resolution for template imports so CI and npm
18
+ release typechecks do not depend on a prebuilt `dist/` directory.
19
+
20
+ ## 0.4.21 - 2026-06-20
21
+
22
+ Complete the 10-minute integration path with durable adapters, sandbox, and E2E.
23
+
24
+ - Added a local `siglume-sdrp sandbox` server that creates fake Hosted Checkout
25
+ sessions, sends signed `direct_payment.confirmed` webhooks, records delivery
26
+ status, and never charges a wallet.
27
+ - Added `SIGLUME_ENV=sandbox`, `SIGLUME_SANDBOX_API_BASE`, and
28
+ `siglume-check readiness --sandbox` so sandbox and live checks are explicit.
29
+ - Added durable Express SQL/ORM order-store adapters for Prisma, TypeORM,
30
+ Sequelize, Drizzle, and generic SQL executors.
31
+ - Added a durable FastAPI SQLAlchemy order-store adapter and packaged it in the
32
+ Python templates.
33
+ - Added Express and FastAPI E2E tests covering checkout start, checkout URL
34
+ reuse, signed webhook success, duplicate webhook suppression, retry after
35
+ handler failure, and Standard-only Micro/Nano blocking.
36
+ - Updated the 10-minute guide, sandbox guide, template READMEs, API reference,
37
+ troubleshooting, and README so implementers can test locally before live
38
+ credentials.
39
+
3
40
  ## 0.4.20 - 2026-06-20
4
41
 
5
42
  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,397 @@ 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
+ let currency;
345
+ let amountMinor;
346
+ let successUrl;
347
+ let cancelUrl;
348
+ try {
349
+ currency = normalizeCurrency(body.currency || "JPY");
350
+ amountMinor = normalizePositiveAmountMinor(body.amount_minor);
351
+ successUrl = normalizeSandboxReturnUrl(body.success_url, "success_url");
352
+ cancelUrl = normalizeSandboxReturnUrl(body.cancel_url, "cancel_url");
353
+ } catch (error) {
354
+ sendJson(res, 400, {
355
+ error: {
356
+ code: "INVALID_CHECKOUT_SESSION_REQUEST",
357
+ message: error instanceof Error ? error.message : "invalid checkout session request",
358
+ },
359
+ });
360
+ return;
361
+ }
362
+ const sessionId = `chk_sandbox_${state.sessions.size + 1}`;
363
+ const challengeHash = `sha256:sandbox_${hashString(`${sessionId}:${body.nonce || ""}`).slice(0, 32)}`;
364
+ const session = {
365
+ session_id: sessionId,
366
+ merchant: state.merchant,
367
+ amount_minor: amountMinor,
368
+ currency,
369
+ token_symbol: currency === "USD" ? "USDC" : "JPYC",
370
+ status: "open",
371
+ challenge_hash: challengeHash,
372
+ success_url: successUrl,
373
+ cancel_url: cancelUrl,
374
+ metadata_jsonb: body.metadata && typeof body.metadata === "object" ? body.metadata : {},
375
+ checkout_url: `http://127.0.0.1:${port}/pay/${sessionId}`,
376
+ expires_at: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
377
+ };
378
+ state.sessions.set(sessionId, session);
379
+ sendEnvelope(res, 201, {
380
+ checkout_url: session.checkout_url,
381
+ session_id: sessionId,
382
+ challenge_hash: challengeHash,
383
+ status: "open",
384
+ expires_at: session.expires_at,
385
+ });
386
+ return;
387
+ }
388
+
389
+ if (req.method === "GET" && url.pathname.startsWith("/v1/sdrp/direct-payments/checkout-sessions/")) {
390
+ const sessionId = decodeURIComponent(url.pathname.split("/").pop() || "");
391
+ const session = state.sessions.get(sessionId);
392
+ if (!session) {
393
+ sendJson(res, 404, { error: { code: "CHECKOUT_SESSION_NOT_FOUND", message: "sandbox session not found" } });
394
+ return;
395
+ }
396
+ sendEnvelope(res, 200, session);
397
+ return;
398
+ }
399
+
400
+ if (req.method === "POST" && url.pathname === "/v1/market/webhooks/test-deliveries") {
401
+ const body = await readJson(req);
402
+ const event = sandboxEvent({
403
+ event_type: String(body.event_type || "direct_payment.confirmed"),
404
+ data: body.data && typeof body.data === "object" ? body.data : {},
405
+ });
406
+ await deliverSandboxWebhook(state, event);
407
+ sendEnvelope(res, 201, { queued: true, event: { id: event.id, type: event.type } });
408
+ return;
409
+ }
410
+
411
+ if (req.method === "GET" && url.pathname === "/v1/market/webhooks/deliveries") {
412
+ let deliveries = [...state.deliveries];
413
+ const eventType = url.searchParams.get("event_type");
414
+ if (eventType) deliveries = deliveries.filter((delivery) => delivery.event_type === eventType);
415
+ const limit = Number(url.searchParams.get("limit") || 50);
416
+ sendEnvelope(res, 200, deliveries.slice(0, Math.max(1, Math.min(limit, 100))));
417
+ return;
418
+ }
419
+
420
+ if (req.method === "GET" && url.pathname.startsWith("/pay/")) {
421
+ const sessionId = decodeURIComponent(url.pathname.split("/").pop() || "");
422
+ const session = state.sessions.get(sessionId);
423
+ if (!session) {
424
+ sendHtml(res, 404, "<h1>Sandbox checkout session not found</h1>");
425
+ return;
426
+ }
427
+ sendHtml(res, 200, sandboxCheckoutHtml(session));
428
+ return;
429
+ }
430
+
431
+ if (req.method === "POST" && url.pathname.startsWith("/v1/sandbox/checkout-sessions/") && url.pathname.endsWith("/confirm")) {
432
+ const parts = url.pathname.split("/");
433
+ const sessionId = decodeURIComponent(parts[4] || "");
434
+ const session = state.sessions.get(sessionId);
435
+ if (!session) {
436
+ sendJson(res, 404, { error: { code: "CHECKOUT_SESSION_NOT_FOUND", message: "sandbox session not found" } });
437
+ return;
438
+ }
439
+ if (session.status === "paid" && session.confirmation_event) {
440
+ sendEnvelope(res, 200, sandboxConfirmResponse(session, session.confirmation_event));
441
+ return;
442
+ }
443
+ session.status = "paid";
444
+ session.requirement_id = `dpr_sandbox_${sessionId}`;
445
+ const event = sandboxPaymentConfirmedEvent(session);
446
+ session.confirmation_event = event;
447
+ await deliverSandboxWebhook(state, event);
448
+ sendEnvelope(res, 200, sandboxConfirmResponse(session, event));
449
+ return;
450
+ }
451
+
452
+ sendJson(res, 404, { error: { code: "SANDBOX_ROUTE_NOT_FOUND", message: "sandbox route not found" } });
453
+ }
454
+
455
+ function sandboxSubscription(state) {
456
+ return {
457
+ id: state.subscriptionId,
458
+ webhook_subscription_id: state.subscriptionId,
459
+ callback_url: state.webhookUrl,
460
+ status: "active",
461
+ event_types: ["direct_payment.confirmed"],
462
+ signing_secret_hint: state.webhookSecret.slice(-4),
463
+ metadata: { environment: "sandbox" },
464
+ };
465
+ }
466
+
467
+ function sandboxEvent({ event_type, data }) {
468
+ return {
469
+ id: `evt_sandbox_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`,
470
+ type: event_type,
471
+ api_version: "2026-06-20",
472
+ occurred_at: new Date().toISOString(),
473
+ data: {
474
+ mode: "external_402",
475
+ pricing_band: "standard",
476
+ finality: "per_payment_onchain",
477
+ settlement_status: "settled",
478
+ requirement_id: `dpr_sandbox_${Date.now()}`,
479
+ challenge_hash: "sha256:sandbox_readiness",
480
+ chain_receipt_id: `chain_sandbox_${Date.now()}`,
481
+ environment: "sandbox",
482
+ ...data,
483
+ },
484
+ };
485
+ }
486
+
487
+ function sandboxPaymentConfirmedEvent(session) {
488
+ const pricingBand = classifySandboxAmount(session.currency, Number(session.amount_minor));
489
+ const metered = pricingBand === "micro" || pricingBand === "nano";
490
+ return sandboxEvent({
491
+ event_type: "direct_payment.confirmed",
492
+ data: {
493
+ merchant: session.merchant,
494
+ requirement_id: session.requirement_id,
495
+ direct_payment_requirement_id: session.requirement_id,
496
+ challenge_hash: session.challenge_hash,
497
+ amount_minor: session.amount_minor,
498
+ currency: session.currency,
499
+ token_symbol: session.token_symbol,
500
+ pricing_band: pricingBand,
501
+ settlement_cadence: pricingBand === "micro" ? "weekly" : pricingBand === "nano" ? "monthly" : "per_payment",
502
+ finality: metered ? "aggregated_onchain_settlement" : "per_payment_onchain",
503
+ settlement_status: metered ? "pending_settlement" : "settled",
504
+ chain_receipt_id: metered ? undefined : `chain_sandbox_${session.session_id}`,
505
+ environment: "sandbox",
506
+ },
507
+ });
508
+ }
509
+
510
+ function sandboxConfirmResponse(session, event) {
511
+ return {
512
+ status: "paid",
513
+ redirect_url: `${session.success_url}${session.success_url.includes("?") ? "&" : "?"}session_id=${encodeURIComponent(session.session_id)}`,
514
+ event: { id: event.id, type: event.type },
515
+ };
516
+ }
517
+
518
+ async function deliverSandboxWebhook(state, event) {
519
+ const rawBody = JSON.stringify(event);
520
+ const signature = await buildWebhookSignatureHeader(state.webhookSecret, rawBody);
521
+ let status = "failed";
522
+ let responseStatus = null;
523
+ try {
524
+ const response = await fetch(state.webhookUrl, {
525
+ method: "POST",
526
+ headers: {
527
+ "content-type": "application/json",
528
+ "siglume-signature": signature,
529
+ "x-siglume-environment": "sandbox",
530
+ },
531
+ body: rawBody,
532
+ });
533
+ responseStatus = response.status;
534
+ status = response.ok ? "delivered" : "failed";
535
+ } catch {
536
+ status = "failed";
537
+ }
538
+ state.deliveries.unshift({
539
+ id: `whdel_sandbox_${state.deliveries.length + 1}`,
540
+ subscription_id: state.subscriptionId,
541
+ event_id: event.id,
542
+ event_type: event.type,
543
+ delivery_status: status,
544
+ response_status: responseStatus,
545
+ delivered_at: status === "delivered" ? new Date().toISOString() : null,
546
+ });
547
+ }
548
+
549
+ function sandboxCheckoutHtml(session) {
550
+ return `<!doctype html>
551
+ <meta charset="utf-8">
552
+ <title>Siglume SDRP Sandbox Checkout</title>
553
+ <body style="font-family: system-ui, sans-serif; max-width: 680px; margin: 48px auto; line-height: 1.5;">
554
+ <h1>Siglume SDRP Sandbox Checkout</h1>
555
+ <p>This is a local sandbox page. No real wallet, token, or on-chain settlement is used.</p>
556
+ <dl>
557
+ <dt>Session</dt><dd>${escapeHtml(session.session_id)}</dd>
558
+ <dt>Merchant</dt><dd>${escapeHtml(session.merchant)}</dd>
559
+ <dt>Amount</dt><dd>${escapeHtml(String(session.amount_minor))} ${escapeHtml(session.currency)}</dd>
560
+ <dt>Status</dt><dd id="status">${escapeHtml(session.status)}</dd>
561
+ </dl>
562
+ <button id="confirm" style="font: inherit; padding: 10px 14px;">Confirm sandbox payment</button>
563
+ <pre id="output"></pre>
564
+ <script>
565
+ document.getElementById("confirm").addEventListener("click", async () => {
566
+ const response = await fetch("/v1/sandbox/checkout-sessions/${encodeURIComponent(session.session_id)}/confirm", { method: "POST" });
567
+ const body = await response.json();
568
+ document.getElementById("status").textContent = body.data?.status || "failed";
569
+ document.getElementById("output").textContent = JSON.stringify(body, null, 2);
570
+ });
571
+ </script>
572
+ </body>`;
573
+ }
574
+
575
+ async function readJson(req) {
576
+ const chunks = [];
577
+ for await (const chunk of req) chunks.push(chunk);
578
+ const text = Buffer.concat(chunks).toString("utf8");
579
+ if (!text) return {};
580
+ return JSON.parse(text);
581
+ }
582
+
583
+ function sendEnvelope(res, status, data) {
584
+ sendJson(res, status, { data, meta: { request_id: "req_sandbox", trace_id: "trc_sandbox" } });
585
+ }
586
+
587
+ function sendJson(res, status, body) {
588
+ res.writeHead(status, { "content-type": "application/json" });
589
+ res.end(JSON.stringify(body));
590
+ }
591
+
592
+ function sendHtml(res, status, body) {
593
+ res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
594
+ res.end(body);
595
+ }
596
+
597
+ function hashString(value) {
598
+ let hash = 0;
599
+ for (let index = 0; index < value.length; index += 1) {
600
+ hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0;
601
+ }
602
+ return Math.abs(hash).toString(16).padStart(8, "0");
603
+ }
604
+
605
+ function classifySandboxAmount(currency, amountMinor) {
606
+ const normalizedCurrency = String(currency || "").toUpperCase();
607
+ if (normalizedCurrency === "JPY") {
608
+ if (amountMinor >= 501) return "standard";
609
+ if (amountMinor >= 50) return "micro";
610
+ return "nano";
611
+ }
612
+ if (normalizedCurrency === "USD") {
613
+ if (amountMinor >= 301) return "standard";
614
+ if (amountMinor >= 31) return "micro";
615
+ return "nano";
616
+ }
617
+ return "standard";
618
+ }
619
+
620
+ function escapeHtml(value) {
621
+ return String(value).replace(/[&<>"']/g, (char) => ({
622
+ "&": "&amp;",
623
+ "<": "&lt;",
624
+ ">": "&gt;",
625
+ "\"": "&quot;",
626
+ "'": "&#39;",
627
+ }[char]));
628
+ }
629
+
219
630
  async function findCopyConflicts(from, to) {
220
631
  const conflicts = [];
221
632
  for (const entry of await readdir(from)) {
@@ -297,6 +708,17 @@ function isHttpsOrigin(value) {
297
708
  }
298
709
  }
299
710
 
711
+ function isAllowedOrigin(value, sandboxMode) {
712
+ if (isHttpsOrigin(value)) return true;
713
+ if (!sandboxMode) return false;
714
+ try {
715
+ const url = new URL(value);
716
+ return url.protocol === "http:" && isLocalhost(url.hostname) && url.origin === value.replace(/\/$/, "");
717
+ } catch {
718
+ return false;
719
+ }
720
+ }
721
+
300
722
  function isHttpsUrl(value) {
301
723
  try {
302
724
  const url = new URL(value);
@@ -306,6 +728,22 @@ function isHttpsUrl(value) {
306
728
  }
307
729
  }
308
730
 
731
+ function isAllowedWebhookUrl(value, sandboxMode) {
732
+ if (isHttpsUrl(value)) return true;
733
+ if (!sandboxMode) return false;
734
+ try {
735
+ const url = new URL(value);
736
+ return url.protocol === "http:" && isLocalhost(url.hostname);
737
+ } catch {
738
+ return false;
739
+ }
740
+ }
741
+
742
+ function isLocalhost(hostname) {
743
+ const host = String(hostname || "").toLowerCase();
744
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
745
+ }
746
+
309
747
  function normalizeCurrency(value) {
310
748
  const currency = String(value || "").toUpperCase();
311
749
  if (currency !== "JPY" && currency !== "USD") {
@@ -314,6 +752,27 @@ function normalizeCurrency(value) {
314
752
  return currency;
315
753
  }
316
754
 
755
+ function normalizePositiveAmountMinor(value) {
756
+ const amountMinor = Number(value);
757
+ if (!Number.isSafeInteger(amountMinor) || amountMinor <= 0) {
758
+ throw new Error("amount_minor must be a positive integer.");
759
+ }
760
+ return amountMinor;
761
+ }
762
+
763
+ function normalizeSandboxReturnUrl(value, name) {
764
+ const text = String(value || "").trim();
765
+ try {
766
+ const url = new URL(text);
767
+ if (url.protocol === "https:" || (url.protocol === "http:" && isLocalhost(url.hostname))) {
768
+ return url.href;
769
+ }
770
+ } catch {
771
+ // Fall through to a consistent validation error.
772
+ }
773
+ throw new Error(`${name} must be an https URL, or a local http URL in sandbox.`);
774
+ }
775
+
317
776
  function isStandardAmount(currency, amountMinor) {
318
777
  return Number.isSafeInteger(amountMinor) && amountMinor >= (currency === "USD" ? 301 : 501);
319
778
  }