@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 +37 -0
- package/README.md +8 -3
- package/bin/siglume-sdrp.mjs +464 -5
- package/dist/index.cjs +13 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/docs/api-reference.md +3 -0
- package/docs/pricing.md +1 -1
- package/docs/quickstart-10-minutes.md +90 -9
- package/docs/sandbox.md +67 -0
- package/docs/troubleshooting.md +11 -3
- package/examples/hosted-checkout-python/pyproject.toml +1 -1
- package/package.json +11 -3
- package/templates/express/README.md +16 -2
- package/templates/express/siglume-order-store.sql.ts +635 -0
- package/templates/fastapi/README.md +18 -3
- package/templates/fastapi/siglume_order_store_sqlalchemy.py +313 -0
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
|
|
101
|
-
|
|
102
|
-
|
|
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
|
package/bin/siglume-sdrp.mjs
CHANGED
|
@@ -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",
|
|
100
|
-
check(checks, "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:
|
|
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 ?
|
|
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
|
+
"&": "&",
|
|
623
|
+
"<": "<",
|
|
624
|
+
">": ">",
|
|
625
|
+
"\"": """,
|
|
626
|
+
"'": "'",
|
|
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
|
}
|