@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.
- package/CHANGELOG.md +50 -0
- package/README.md +18 -10
- package/bin/siglume-sdrp.mjs +550 -8
- package/dist/index.cjs +37 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -2
- package/dist/index.d.ts +27 -2
- package/dist/index.js +37 -3
- package/dist/index.js.map +1 -1
- package/docs/announcement-ja.md +17 -3
- package/docs/api-reference.md +60 -13
- package/docs/merchant-quickstart.md +6 -20
- package/docs/metered-statements.md +15 -13
- package/docs/payment-lifecycle.md +12 -9
- package/docs/pricing.md +7 -4
- package/docs/quickstart-10-minutes.md +134 -24
- package/docs/sandbox.md +60 -0
- package/docs/troubleshooting.md +23 -8
- package/examples/express-checkout.ts +37 -13
- package/examples/hosted-checkout-python/app.py +46 -31
- package/examples/hosted-checkout-python/order_store.py +13 -3
- package/examples/hosted-checkout-python/pyproject.toml +1 -1
- package/examples/hosted-checkout-typescript/src/order-store.ts +14 -3
- package/examples/hosted-checkout-typescript/src/server.ts +49 -37
- package/package.json +10 -2
- package/templates/express/README.md +40 -6
- package/templates/express/siglume-order-store.example.ts +22 -6
- package/templates/express/siglume-order-store.sql.ts +585 -0
- package/templates/express/siglume-sdrp-routes.ts +138 -64
- package/templates/fastapi/README.md +22 -3
- package/templates/fastapi/siglume_order_store_example.py +29 -6
- package/templates/fastapi/siglume_order_store_sqlalchemy.py +313 -0
- package/templates/fastapi/siglume_sdrp_routes.py +112 -49
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
|
-
--no-probe
|
|
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",
|
|
99
|
-
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.");
|
|
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:
|
|
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)
|
|
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
|
-
|
|
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
|
+
"&": "&",
|
|
596
|
+
"<": "<",
|
|
597
|
+
">": ">",
|
|
598
|
+
"\"": """,
|
|
599
|
+
"'": "'",
|
|
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}).`;
|