@siglume/direct-request-payment 0.4.19 → 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.
Files changed (33) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +18 -10
  3. package/bin/siglume-sdrp.mjs +550 -8
  4. package/dist/index.cjs +37 -3
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +27 -2
  7. package/dist/index.d.ts +27 -2
  8. package/dist/index.js +37 -3
  9. package/dist/index.js.map +1 -1
  10. package/docs/announcement-ja.md +17 -3
  11. package/docs/api-reference.md +60 -13
  12. package/docs/merchant-quickstart.md +6 -20
  13. package/docs/metered-statements.md +15 -13
  14. package/docs/payment-lifecycle.md +12 -9
  15. package/docs/pricing.md +7 -4
  16. package/docs/quickstart-10-minutes.md +134 -24
  17. package/docs/sandbox.md +60 -0
  18. package/docs/troubleshooting.md +23 -8
  19. package/examples/express-checkout.ts +37 -13
  20. package/examples/hosted-checkout-python/app.py +46 -31
  21. package/examples/hosted-checkout-python/order_store.py +13 -3
  22. package/examples/hosted-checkout-python/pyproject.toml +1 -1
  23. package/examples/hosted-checkout-typescript/src/order-store.ts +14 -3
  24. package/examples/hosted-checkout-typescript/src/server.ts +49 -37
  25. package/package.json +10 -2
  26. package/templates/express/README.md +40 -6
  27. package/templates/express/siglume-order-store.example.ts +22 -6
  28. package/templates/express/siglume-order-store.sql.ts +585 -0
  29. package/templates/express/siglume-sdrp-routes.ts +138 -64
  30. package/templates/fastapi/README.md +22 -3
  31. package/templates/fastapi/siglume_order_store_example.py +29 -6
  32. package/templates/fastapi/siglume_order_store_sqlalchemy.py +313 -0
  33. package/templates/fastapi/siglume_sdrp_routes.py +112 -49
@@ -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
- --no-probe Call getMerchant only; do not create an unpaid checkout session.
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,35 +103,69 @@ 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 || "";
110
+ const webhookSecret = process.env.SIGLUME_WEBHOOK_SECRET || "";
92
111
  const token = process.env.SIGLUME_MERCHANT_AUTH_TOKEN || process.env.SIGLUME_AUTH_TOKEN || "";
93
112
  const currency = normalizeCurrency(options.currency || process.env.SIGLUME_DIRECT_PAYMENT_TEST_CURRENCY || "JPY");
94
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);
95
115
 
116
+ check(checks, "target_environment", true, sandboxMode ? "sandbox" : "live");
96
117
  check(checks, "merchant_key", Boolean(merchant), "Set SIGLUME_DIRECT_PAYMENT_MERCHANT or pass --merchant.");
97
- check(checks, "merchant_token", Boolean(token) && !token.startsWith("cli_"), "Set SIGLUME_MERCHANT_AUTH_TOKEN to a merchant Siglume bearer token, not a cli_ key.");
98
- check(checks, "shop_origin", isHttpsOrigin(origin), "Set SHOP_PUBLIC_ORIGIN to an https origin, for example https://www.example.com.");
99
- 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.");
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.");
100
122
  check(checks, "standard_probe_amount", isStandardAmount(currency, amountMinor), "Use a Standard-band probe amount: JPY 501+ or USD 301+ minor units.");
101
123
 
102
124
  if (options.api && !hasFailures(checks)) {
103
125
  const merchantClient = new DirectRequestPaymentMerchantClient({
104
126
  auth_token: token,
105
- base_url: options.baseUrl || process.env.SIGLUME_API_BASE,
127
+ base_url: baseUrl,
106
128
  });
129
+ let matchingWebhookSubscription = null;
107
130
  try {
108
131
  const merchantResponse = await merchantClient.getMerchant(merchant);
109
132
  const account = merchantResponse.merchant_account || {};
110
133
  check(checks, "merchant_exists", Boolean(account.merchant), "Run merchant setup before checkout.");
111
- check(checks, "billing_mandate", Boolean(account.billing_mandate_id) || activeLike(account.billing_status), "Complete the merchant billing mandate wallet approval.");
134
+ check(checks, "billing_mandate", Boolean(account.billing_mandate_id), "Complete the merchant billing mandate wallet approval.");
135
+ check(checks, "billing_status_active", activeLike(account.billing_status), `Billing status is ${account.billing_status || "unknown"}; it must be active before accepting payments.`);
112
136
  warnIf(checks, "merchant_status", account.status && !activeLike(account.status), `Merchant status is ${account.status}; confirm it is allowed to accept payments.`);
113
- warnIf(checks, "billing_status", account.billing_status && !activeLike(account.billing_status), `Billing status is ${account.billing_status}; confirm it is active before accepting payments.`);
114
137
  } catch (error) {
115
138
  check(checks, "merchant_api", false, apiErrorMessage(error, "Could not read the merchant account."));
116
139
  }
117
140
 
141
+ if (!hasFailures(checks)) {
142
+ try {
143
+ const subscriptions = await merchantClient.listWebhookSubscriptions();
144
+ const activeSubscriptions = subscriptions.filter((subscription) => activeLike(subscription.status));
145
+ matchingWebhookSubscription = activeSubscriptions.find((subscription) => urlsEqual(subscription.callback_url, webhookUrl)) || null;
146
+ check(checks, "webhook_subscription_exists", activeSubscriptions.length > 0, "Create an active webhook subscription before checkout.");
147
+ check(checks, "webhook_callback_matches", Boolean(matchingWebhookSubscription), `No active webhook subscription points at ${webhookUrl}.`);
148
+ check(
149
+ checks,
150
+ "direct_payment_confirmed_subscribed",
151
+ Boolean(matchingWebhookSubscription) && includesEventType(matchingWebhookSubscription.event_types, "direct_payment.confirmed"),
152
+ "The matching webhook subscription must include direct_payment.confirmed.",
153
+ );
154
+ check(
155
+ checks,
156
+ "webhook_secret_matches_subscription_hint",
157
+ Boolean(matchingWebhookSubscription?.signing_secret_hint) && webhookSecret.endsWith(String(matchingWebhookSubscription.signing_secret_hint)),
158
+ "SIGLUME_WEBHOOK_SECRET does not match the signing_secret_hint for the matching subscription. Rotate or re-save the webhook secret.",
159
+ );
160
+ } catch (error) {
161
+ check(checks, "webhook_subscription_api", false, apiErrorMessage(error, "Could not read webhook subscriptions."));
162
+ }
163
+ }
164
+
165
+ if (!options.probe && !hasFailures(checks)) {
166
+ check(checks, "hosted_checkout_probe", false, "--no-probe skips Hosted Checkout and webhook delivery probes. Remove --no-probe for readiness.");
167
+ }
168
+
118
169
  if (options.probe && !hasFailures(checks)) {
119
170
  try {
120
171
  const session = await merchantClient.createCheckoutSession({
@@ -134,6 +185,13 @@ async function readiness(options) {
134
185
  check(checks, "hosted_checkout", false, message);
135
186
  }
136
187
  }
188
+
189
+ if (options.probe && !hasFailures(checks)) {
190
+ await checkWebhookDeliveryProbe(checks, merchantClient, {
191
+ merchant,
192
+ subscription: matchingWebhookSubscription,
193
+ });
194
+ }
137
195
  }
138
196
 
139
197
  const ok = !hasFailures(checks);
@@ -144,7 +202,11 @@ async function readiness(options) {
144
202
  const mark = item.status === "pass" ? "OK" : item.status === "warn" ? "WARN" : "FAIL";
145
203
  console.log(`${mark} ${item.name}: ${item.message}`);
146
204
  }
147
- console.log(ok ? "Ready for 10-minute SDRP integration." : "Not ready. Fix the FAIL items before coding checkout.");
205
+ if (ok && !options.api) {
206
+ console.log("Local config checks passed. API, Hosted Checkout, and webhook delivery readiness were not verified.");
207
+ } else {
208
+ console.log(ok ? `Ready for 10-minute SDRP integration (${sandboxMode ? "sandbox" : "live"}).` : "Not ready. Fix the FAIL items before coding checkout.");
209
+ }
148
210
  }
149
211
  if (!ok) {
150
212
  process.exitCode = 1;
@@ -163,11 +225,396 @@ async function init(args) {
163
225
  }
164
226
  const from = join(rootDir, "templates", framework);
165
227
  const to = resolve(process.cwd(), target);
228
+ if (!Boolean(parsed.force)) {
229
+ const conflicts = await findCopyConflicts(from, to);
230
+ if (conflicts.length) {
231
+ throw new Error(`Refusing to overwrite existing files. Re-run with --force to overwrite:\n${conflicts.join("\n")}`);
232
+ }
233
+ }
166
234
  await copyDir(from, to, Boolean(parsed.force));
167
235
  console.log(`Copied ${framework} SDRP integration files to ${to}`);
168
236
  console.log("Wire the exported router into your app, then run siglume-check readiness before opening checkout.");
169
237
  }
170
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
+
603
+ async function findCopyConflicts(from, to) {
604
+ const conflicts = [];
605
+ for (const entry of await readdir(from)) {
606
+ const src = join(from, entry);
607
+ const dst = join(to, entry);
608
+ const info = await stat(src);
609
+ if (info.isDirectory()) {
610
+ conflicts.push(...await findCopyConflicts(src, dst));
611
+ } else if (await exists(dst)) {
612
+ conflicts.push(dst);
613
+ }
614
+ }
615
+ return conflicts;
616
+ }
617
+
171
618
  async function copyDir(from, to, force) {
172
619
  await mkdir(to, { recursive: true });
173
620
  for (const entry of await readdir(from)) {
@@ -234,6 +681,17 @@ function isHttpsOrigin(value) {
234
681
  }
235
682
  }
236
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
+
237
695
  function isHttpsUrl(value) {
238
696
  try {
239
697
  const url = new URL(value);
@@ -243,6 +701,22 @@ function isHttpsUrl(value) {
243
701
  }
244
702
  }
245
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
+
246
720
  function normalizeCurrency(value) {
247
721
  const currency = String(value || "").toUpperCase();
248
722
  if (currency !== "JPY" && currency !== "USD") {
@@ -259,6 +733,74 @@ function activeLike(value) {
259
733
  return /^(active|ready|current|ok|enabled|paid|complete|completed)$/i.test(String(value || ""));
260
734
  }
261
735
 
736
+ function includesEventType(eventTypes, eventType) {
737
+ if (!Array.isArray(eventTypes) || eventTypes.length === 0) return true;
738
+ return eventTypes.map((item) => String(item)).includes(eventType);
739
+ }
740
+
741
+ function urlsEqual(left, right) {
742
+ try {
743
+ const leftUrl = new URL(String(left || ""));
744
+ const rightUrl = new URL(String(right || ""));
745
+ return leftUrl.href === rightUrl.href;
746
+ } catch {
747
+ return false;
748
+ }
749
+ }
750
+
751
+ function subscriptionId(subscription) {
752
+ return String(subscription?.id || subscription?.webhook_subscription_id || subscription?.subscription_id || "");
753
+ }
754
+
755
+ async function checkWebhookDeliveryProbe(checks, merchantClient, { merchant, subscription }) {
756
+ const id = subscriptionId(subscription);
757
+ if (!id) {
758
+ check(checks, "webhook_delivery_probe_passed", false, "Cannot run webhook delivery probe without a matching subscription id.");
759
+ return;
760
+ }
761
+ try {
762
+ const queued = await merchantClient.queueWebhookTestDelivery({
763
+ event_type: "direct_payment.confirmed",
764
+ subscription_ids: [id],
765
+ data: {
766
+ mode: "readiness_probe",
767
+ merchant,
768
+ direct_payment_requirement_id: `dpr_readiness_${Date.now()}`,
769
+ requirement_id: `dpr_readiness_${Date.now()}`,
770
+ challenge_hash: "sha256:readiness_probe",
771
+ pricing_band: "standard",
772
+ settlement_status: "readiness_probe",
773
+ },
774
+ });
775
+ const eventId = String(queued?.event?.id || "");
776
+ const deadline = Date.now() + 10000;
777
+ while (eventId && Date.now() < deadline) {
778
+ const deliveries = await merchantClient.listWebhookDeliveries({
779
+ subscription_id: id,
780
+ event_type: "direct_payment.confirmed",
781
+ limit: 10,
782
+ });
783
+ const delivery = deliveries.find((item) => String(item.event_id || "") === eventId);
784
+ if (delivery?.delivery_status === "delivered") {
785
+ check(checks, "webhook_delivery_probe_passed", true, "ready");
786
+ return;
787
+ }
788
+ if (delivery?.delivery_status === "failed") {
789
+ check(checks, "webhook_delivery_probe_passed", false, `Webhook delivery failed with response_status=${delivery.response_status ?? "unknown"}.`);
790
+ return;
791
+ }
792
+ await sleep(1000);
793
+ }
794
+ check(checks, "webhook_delivery_probe_passed", false, "Webhook test delivery was queued but did not report delivered before timeout. Check callback reachability and delivery logs.");
795
+ } catch (error) {
796
+ check(checks, "webhook_delivery_probe_passed", false, apiErrorMessage(error, "Webhook delivery probe failed."));
797
+ }
798
+ }
799
+
800
+ function sleep(ms) {
801
+ return new Promise((resolve) => setTimeout(resolve, ms));
802
+ }
803
+
262
804
  function apiErrorMessage(error, fallback) {
263
805
  if (error instanceof SiglumeApiError) {
264
806
  return `${fallback} ${error.code} (${error.status}).`;