@railbridgeai/merchant-sdk 0.1.0

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/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # @railbridgeai/merchant-sdk
2
+
3
+ Merchant-first server SDK for RailBridge.
4
+
5
+ Goal:
6
+ 1. Protect paid routes with one middleware.
7
+ 2. Keep merchant business logic unchanged.
8
+ 3. Verify webhook signatures with one helper.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @railbridgeai/merchant-sdk
14
+ ```
15
+
16
+ Repo contributor note:
17
+ 1. In this repository, `facilitator` consumes the SDK through a local `file:` dependency.
18
+ 2. Running `npm --prefix facilitator install` also creates `packages/node_modules -> facilitator/node_modules` for local dependency resolution.
19
+ 3. Use the facilitator npm scripts for local smoke tests; you do not need a separate `npm install` inside `packages/server-sdk` for the linked-package path.
20
+
21
+ Release workflow:
22
+ 1. Bump the version in `packages/server-sdk/package.json`.
23
+ 2. Push a tag like `sdk-v0.1.0-beta.1`.
24
+ 3. GitHub Actions publishes the package to the npm registry as `public`.
25
+
26
+ ## Quick Start (Express)
27
+
28
+ ```ts
29
+ import express from "express";
30
+ import { createRailbridgeFromEnv } from "@railbridgeai/merchant-sdk";
31
+
32
+ const app = express();
33
+ const rb = createRailbridgeFromEnv(process.env);
34
+
35
+ const start = async () => {
36
+ await rb.protectExpress(
37
+ app,
38
+ {
39
+ apiId: "premium_api",
40
+ method: "GET",
41
+ path: "/api/premium",
42
+ },
43
+ async (req, res) => {
44
+ // Keep business logic here.
45
+ const payload = await buildPremiumPayload(req.user);
46
+ res.json(payload);
47
+ },
48
+ );
49
+
50
+ app.listen(4021);
51
+ };
52
+
53
+ start().catch(console.error);
54
+ ```
55
+
56
+ Alternative env-first bootstrap:
57
+
58
+ ```ts
59
+ import { createRailbridge } from "@railbridgeai/merchant-sdk";
60
+
61
+ const rb = createRailbridge({
62
+ apiKey: process.env.RB_API_KEY,
63
+ environment: "testnet",
64
+ });
65
+ ```
66
+
67
+ In the normal hosted RailBridge flow, merchants should only need:
68
+ 1. `RB_API_KEY`
69
+ 2. `RB_ENV` (`local`, `testnet`, or `live`)
70
+
71
+ The SDK chooses RailBridge-managed URLs automatically from the environment.
72
+
73
+ ## Webhook Verification
74
+
75
+ ```ts
76
+ import express from "express";
77
+ import { createRailbridgeFromEnv } from "@railbridgeai/merchant-sdk";
78
+
79
+ const app = express();
80
+ const rb = createRailbridgeFromEnv(process.env);
81
+
82
+ // Keep raw payload for signature verification.
83
+ app.use("/webhooks/railbridge", express.text({ type: "application/json" }));
84
+
85
+ app.post(
86
+ "/webhooks/railbridge",
87
+ rb.webhooks.express({
88
+ secret: process.env.RB_WEBHOOK_SECRET,
89
+ onEvent: async (event) => {
90
+ // idempotent async processing
91
+ console.log("railbridge webhook", event.type, event.id);
92
+ },
93
+ }),
94
+ );
95
+ ```
96
+
97
+ ## Merchant Env Vars
98
+
99
+ ```env
100
+ RB_API_KEY=rb_...
101
+ RB_WEBHOOK_SECRET=whsec_...
102
+ RB_ENV=testnet
103
+ ```
104
+
105
+ Advanced overrides:
106
+
107
+ ```env
108
+ RB_MERCHANT_OS_URL=https://api.testnet.railbridge.ai
109
+ RB_FACILITATOR_URL=https://facilitator.testnet.railbridge.ai
110
+ ```
111
+
112
+ Only use these overrides if:
113
+ 1. you are developing against local/self-hosted RailBridge services
114
+ 2. RailBridge support asked you to target a non-standard endpoint
115
+
116
+ ## API Summary
117
+
118
+ 1. `createRailbridge(config)`
119
+ 2. `createRailbridgeFromEnv(process.env)`
120
+ 3. `client.protect(routeConfig)` -> Express middleware
121
+ 4. `client.protectExpress(app, routeConfig, handler)` -> lowest-friction Express helper
122
+ 5. `client.resolveRequirements(...)`
123
+ 6. `client.webhooks.verify(...)`
124
+ 7. `client.webhooks.express(...)`
125
+
126
+ ## Notes
127
+
128
+ 1. This SDK abstracts payment requirement resolution and verify/settle wiring.
129
+ 2. Merchant handlers should only contain business logic.
130
+ 3. Do not call RailBridge internal endpoints (`/v1/internal/*`) from merchant apps.
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@railbridgeai/merchant-sdk",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "RailBridge SDK for merchant API protection and webhook verification",
7
+ "scripts": {
8
+ "check": "node --check src/index.js && node --check src/paymentGuard.js && node --check src/webhooks.js",
9
+ "test": "node --test \"tests/**/*.test.js\"",
10
+ "pack:dry-run": "npm pack --dry-run",
11
+ "publish:public:dry-run": "npm publish --access public --dry-run"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "README.md"
16
+ ],
17
+ "main": "./src/index.js",
18
+ "types": "./src/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./src/index.d.ts",
22
+ "default": "./src/index.js"
23
+ }
24
+ },
25
+ "publishConfig": {
26
+ "access": "public",
27
+ "registry": "https://registry.npmjs.org/"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/MarcoBrian/RailBridge.git",
32
+ "directory": "packages/server-sdk"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/MarcoBrian/RailBridge/issues"
36
+ },
37
+ "homepage": "https://github.com/MarcoBrian/RailBridge/tree/testnet/packages/server-sdk",
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "dependencies": {
42
+ "@x402/core": "^2.12.0",
43
+ "@x402/evm": "^2.12.0",
44
+ "@x402/express": "^2.12.0",
45
+ "@x402/paywall": "^2.12.0"
46
+ },
47
+ "keywords": [
48
+ "railbridge",
49
+ "x402",
50
+ "merchant",
51
+ "sdk"
52
+ ],
53
+ "license": "MIT"
54
+ }
@@ -0,0 +1,32 @@
1
+ export const CROSS_CHAIN = "cross-chain";
2
+
3
+ export const declareCrossChainExtension = ({
4
+ destinationNetwork,
5
+ destinationAsset,
6
+ destinationPayTo,
7
+ }) => ({
8
+ info: {
9
+ destinationNetwork,
10
+ destinationAsset,
11
+ destinationPayTo,
12
+ },
13
+ schema: {
14
+ type: "object",
15
+ properties: {
16
+ destinationNetwork: {
17
+ type: "string",
18
+ pattern: "^eip155:\\d+$",
19
+ },
20
+ destinationAsset: {
21
+ type: "string",
22
+ pattern: "^0x[a-fA-F0-9]{40}$",
23
+ },
24
+ destinationPayTo: {
25
+ type: "string",
26
+ pattern: "^0x[a-fA-F0-9]{40}$",
27
+ },
28
+ },
29
+ required: ["destinationNetwork", "destinationAsset", "destinationPayTo"],
30
+ additionalProperties: false,
31
+ },
32
+ });
package/src/index.d.ts ADDED
@@ -0,0 +1,110 @@
1
+ type RequestHandler = (...args: any[]) => any;
2
+ type ExpressLikeApp = Record<string, any>;
3
+
4
+ export type RailbridgeEnvironment = "local" | "testnet" | "live" | "mainnet" | "production" | "prod";
5
+ export type RailbridgeLogLevel = "silent" | "error" | "warn" | "info" | "debug";
6
+
7
+ export type RailbridgeLogger = {
8
+ debug?: (...args: any[]) => void;
9
+ info?: (...args: any[]) => void;
10
+ warn?: (...args: any[]) => void;
11
+ error?: (...args: any[]) => void;
12
+ };
13
+
14
+ export type RailbridgeAdvancedConfig = {
15
+ merchantOsUrl?: string;
16
+ facilitatorUrl?: string;
17
+ paywallTestnet?: boolean;
18
+ sourceNetworkFilter?: "all" | "testnet_only" | "mainnet_only";
19
+ maxRequirementOptions?: number;
20
+ autoRefreshMs?: number;
21
+ logPrefix?: string;
22
+ paywallAppName?: string;
23
+ supportedSourceNetworks?: string[];
24
+ logger?: RailbridgeLogger;
25
+ logLevel?: RailbridgeLogLevel;
26
+ };
27
+
28
+ export type RailbridgeBaseConfig = {
29
+ apiKey: string;
30
+ environment?: RailbridgeEnvironment;
31
+ advanced?: RailbridgeAdvancedConfig;
32
+ } & Partial<RailbridgeAdvancedConfig>;
33
+
34
+ export type RailbridgeFromEnvOverrides = Partial<RailbridgeBaseConfig>;
35
+
36
+ export type RailbridgeProtectConfig = {
37
+ method?: string;
38
+ path: string;
39
+ apiId?: string;
40
+ apiProductId?: string;
41
+ settlementModeOverride?: "same_chain" | "cross_chain";
42
+ advanced?: RailbridgeAdvancedConfig;
43
+ } & Partial<RailbridgeAdvancedConfig>;
44
+
45
+ export type RailbridgeGuardMiddleware = RequestHandler & {
46
+ routeMethod?: string;
47
+ routePath?: string;
48
+ refreshRequirements?: () => Promise<unknown>;
49
+ getCurrentRouteInfo?: () => unknown;
50
+ startAutoRefresh?: () => void;
51
+ stopAutoRefresh?: () => void;
52
+ };
53
+
54
+ export type ResolveRequirementsInput = {
55
+ apiId?: string;
56
+ apiProductId?: string;
57
+ method?: string;
58
+ path: string;
59
+ settlementModeOverride?: "same_chain" | "cross_chain";
60
+ };
61
+
62
+ export type GetOnboardingStatusInput = {
63
+ merchantOsUrl?: string;
64
+ token: string;
65
+ };
66
+
67
+ export type WebhookVerifyInput = {
68
+ secret: string;
69
+ timestamp: string;
70
+ payload: string | Record<string, unknown>;
71
+ signature: string;
72
+ };
73
+
74
+ export type ExpressWebhookHandlerInput = {
75
+ secret?: string;
76
+ requireSignature?: boolean;
77
+ onEvent?: (event: any, context: { headers: Record<string, unknown>; eventType: string | null; eventId: string | null }) => Promise<void> | void;
78
+ };
79
+
80
+ export type RailbridgeClient = {
81
+ environment: "local" | "testnet" | "live";
82
+ merchantOsUrl: string;
83
+ facilitatorUrl: string;
84
+ resolveRequirements: (input: ResolveRequirementsInput) => Promise<any>;
85
+ getOnboardingStatus: (input: GetOnboardingStatusInput) => Promise<any>;
86
+ protect: (routeConfig: RailbridgeProtectConfig) => Promise<RailbridgeGuardMiddleware>;
87
+ protectExpress: (
88
+ app: ExpressLikeApp,
89
+ routeConfig: RailbridgeProtectConfig,
90
+ ...handlers: RequestHandler[]
91
+ ) => Promise<RailbridgeGuardMiddleware>;
92
+ webhooks: {
93
+ verify: (input: WebhookVerifyInput) => boolean;
94
+ express: (input?: ExpressWebhookHandlerInput) => RequestHandler;
95
+ };
96
+ };
97
+
98
+ export function createRailbridge(config: RailbridgeBaseConfig): RailbridgeClient;
99
+ export function createRailbridgeFromEnv(
100
+ env?: Record<string, string | undefined>,
101
+ overrides?: RailbridgeFromEnvOverrides,
102
+ ): RailbridgeClient;
103
+ export function getOnboardingStatus(input: {
104
+ merchantOsUrl: string;
105
+ token: string;
106
+ }): Promise<any>;
107
+
108
+ export function createRailbridgePaymentGuard(config: any): Promise<any>;
109
+ export function createExpressWebhookHandler(input?: ExpressWebhookHandlerInput): RequestHandler;
110
+ export function verifyWebhook(input: WebhookVerifyInput): boolean;
package/src/index.js ADDED
@@ -0,0 +1,370 @@
1
+ import { createExpressWebhookHandler, verifyWebhook } from "./webhooks.js";
2
+
3
+ const ENVIRONMENT_DEFAULTS = {
4
+ local: {
5
+ merchantOsUrl: "http://localhost:4030",
6
+ facilitatorUrl: "http://localhost:4022",
7
+ paywallTestnet: true,
8
+ sourceNetworkFilter: "all",
9
+ },
10
+ testnet: {
11
+ merchantOsUrl: "https://api.testnet.railbridge.ai",
12
+ facilitatorUrl: "https://facilitator.testnet.railbridge.ai",
13
+ paywallTestnet: true,
14
+ sourceNetworkFilter: "testnet_only",
15
+ },
16
+ live: {
17
+ merchantOsUrl: "https://api.railbridge.xyz",
18
+ facilitatorUrl: "https://facilitator.railbridge.xyz",
19
+ paywallTestnet: false,
20
+ sourceNetworkFilter: "all",
21
+ },
22
+ };
23
+
24
+ const normalizeEnvironment = (value) => {
25
+ const normalized = String(value || "testnet").trim().toLowerCase();
26
+ if (normalized === "mainnet" || normalized === "production" || normalized === "prod") {
27
+ return "live";
28
+ }
29
+ if (normalized === "dev") {
30
+ return "local";
31
+ }
32
+ if (!["local", "testnet", "live"].includes(normalized)) {
33
+ throw new Error("environment must be one of: local, testnet, live");
34
+ }
35
+ return normalized;
36
+ };
37
+
38
+ const normalizeMethod = (method) => {
39
+ const value = String(method || "").trim().toUpperCase();
40
+ return value || "GET";
41
+ };
42
+
43
+ const normalizePath = (path, fieldName = "path") => {
44
+ const value = String(path || "").trim();
45
+ if (!value) {
46
+ throw new Error(`${fieldName} is required`);
47
+ }
48
+ return value.startsWith("/") ? value : `/${value}`;
49
+ };
50
+
51
+ const ensureNonEmpty = (value, fieldName) => {
52
+ const text = String(value || "").trim();
53
+ if (!text) {
54
+ throw new Error(`${fieldName} is required`);
55
+ }
56
+ return text;
57
+ };
58
+
59
+ const parseBoolean = (value, fieldName) => {
60
+ if (value === undefined || value === null || value === "") {
61
+ return undefined;
62
+ }
63
+ if (typeof value === "boolean") {
64
+ return value;
65
+ }
66
+ const normalized = String(value).trim().toLowerCase();
67
+ if (["1", "true", "yes", "y", "on"].includes(normalized)) {
68
+ return true;
69
+ }
70
+ if (["0", "false", "no", "n", "off"].includes(normalized)) {
71
+ return false;
72
+ }
73
+ throw new Error(`${fieldName} must be a boolean-like value`);
74
+ };
75
+
76
+ const parseOptionalFiniteNumber = (value, fieldName) => {
77
+ if (value === undefined || value === null || value === "") {
78
+ return undefined;
79
+ }
80
+ const parsed = Number(value);
81
+ if (!Number.isFinite(parsed)) {
82
+ throw new Error(`${fieldName} must be a finite number`);
83
+ }
84
+ return parsed;
85
+ };
86
+
87
+ const resolveEnvironmentInput = (value, fieldName = "environment") => {
88
+ const text = String(value || "").trim();
89
+ if (!text) {
90
+ throw new Error(`${fieldName} is required`);
91
+ }
92
+ return normalizeEnvironment(text);
93
+ };
94
+
95
+ const readAdvanced = (config = {}) => {
96
+ if (!config.advanced || typeof config.advanced !== "object") {
97
+ return {};
98
+ }
99
+ return config.advanced;
100
+ };
101
+
102
+ const mergeDefaults = (config = {}) => {
103
+ const advanced = readAdvanced(config);
104
+ const environment = normalizeEnvironment(config.environment || "testnet");
105
+ const defaults = ENVIRONMENT_DEFAULTS[environment];
106
+
107
+ return {
108
+ environment,
109
+ apiKey: ensureNonEmpty(config.apiKey, "apiKey"),
110
+ merchantOsUrl: String(
111
+ advanced.merchantOsUrl || config.merchantOsUrl || defaults.merchantOsUrl,
112
+ ).trim(),
113
+ facilitatorUrl: String(
114
+ advanced.facilitatorUrl || config.facilitatorUrl || defaults.facilitatorUrl,
115
+ ).trim(),
116
+ paywallTestnet:
117
+ typeof advanced.paywallTestnet === "boolean"
118
+ ? advanced.paywallTestnet
119
+ : typeof config.paywallTestnet === "boolean"
120
+ ? config.paywallTestnet
121
+ : defaults.paywallTestnet,
122
+ sourceNetworkFilter:
123
+ advanced.sourceNetworkFilter || config.sourceNetworkFilter || defaults.sourceNetworkFilter,
124
+ maxRequirementOptions: advanced.maxRequirementOptions ?? config.maxRequirementOptions,
125
+ autoRefreshMs: advanced.autoRefreshMs ?? config.autoRefreshMs,
126
+ logPrefix: advanced.logPrefix || config.logPrefix || "[railbridge-sdk]",
127
+ paywallAppName:
128
+ advanced.paywallAppName || config.paywallAppName || "RailBridge Merchant Integration",
129
+ supportedSourceNetworks: advanced.supportedSourceNetworks || config.supportedSourceNetworks,
130
+ logger: advanced.logger || config.logger || console,
131
+ logLevel: advanced.logLevel || config.logLevel || "warn",
132
+ };
133
+ };
134
+
135
+ const mountProtectedRoute = (app, method, path, middleware, handlers) => {
136
+ if (!app || (typeof app !== "object" && typeof app !== "function")) {
137
+ throw new Error("protectExpress(...) requires an Express app instance");
138
+ }
139
+
140
+ const normalizedMethod = String(method || "GET").trim().toLowerCase();
141
+ const normalizedPath = normalizePath(path, "routeConfig.path");
142
+ const normalizedHandlers = handlers.filter(Boolean);
143
+
144
+ if (!normalizedHandlers.length) {
145
+ throw new Error("protectExpress(...) requires at least one route handler");
146
+ }
147
+
148
+ if (typeof app[normalizedMethod] === "function") {
149
+ app[normalizedMethod](normalizedPath, middleware, ...normalizedHandlers);
150
+ return;
151
+ }
152
+
153
+ if (typeof app.all === "function") {
154
+ app.all(normalizedPath, middleware, ...normalizedHandlers);
155
+ return;
156
+ }
157
+
158
+ throw new Error(`protectExpress(...) could not find app.${normalizedMethod}(...) or app.all(...)`);
159
+ };
160
+
161
+ const resolveOnboardingStatus = async ({ merchantOsUrl, token }) => {
162
+ const response = await fetch(`${merchantOsUrl.replace(/\/$/, "")}/v1/onboarding/checklist`, {
163
+ method: "GET",
164
+ headers: {
165
+ authorization: `Bearer ${token}`,
166
+ },
167
+ });
168
+
169
+ const payload = await response.json().catch(() => ({}));
170
+ if (!response.ok) {
171
+ throw new Error(payload.error || `onboarding status failed (${response.status})`);
172
+ }
173
+ return payload;
174
+ };
175
+
176
+ const resolveRequirements = async ({
177
+ merchantOsUrl,
178
+ apiKey,
179
+ apiId,
180
+ apiProductId,
181
+ method = "GET",
182
+ path,
183
+ settlementModeOverride,
184
+ }) => {
185
+ const response = await fetch(`${merchantOsUrl.replace(/\/$/, "")}/v1/sdk/requirements/resolve`, {
186
+ method: "POST",
187
+ headers: {
188
+ "content-type": "application/json",
189
+ "x-railbridge-api-key": apiKey,
190
+ },
191
+ body: JSON.stringify({
192
+ apiId,
193
+ apiProductId,
194
+ method: String(method || "GET").trim().toUpperCase(),
195
+ path,
196
+ settlementModeOverride,
197
+ }),
198
+ });
199
+
200
+ const payload = await response.json().catch(() => ({}));
201
+ if (!response.ok) {
202
+ throw new Error(payload.error || `resolve requirements failed (${response.status})`);
203
+ }
204
+ return payload;
205
+ };
206
+
207
+ export const createRailbridge = (config = {}) => {
208
+ const base = mergeDefaults(config);
209
+ const client = {
210
+ environment: base.environment,
211
+ merchantOsUrl: base.merchantOsUrl,
212
+ facilitatorUrl: base.facilitatorUrl,
213
+
214
+ async resolveRequirements(input = {}) {
215
+ const method = input.method || "GET";
216
+ const path = String(input.path || "").trim();
217
+ if (!path) {
218
+ throw new Error("path is required");
219
+ }
220
+
221
+ return resolveRequirements({
222
+ merchantOsUrl: base.merchantOsUrl,
223
+ apiKey: base.apiKey,
224
+ apiId: input.apiId,
225
+ apiProductId: input.apiProductId,
226
+ method,
227
+ path,
228
+ settlementModeOverride: input.settlementModeOverride,
229
+ });
230
+ },
231
+
232
+ async getOnboardingStatus(input = {}) {
233
+ const token = ensureNonEmpty(input.token, "token");
234
+ const merchantOsUrl = String(input.merchantOsUrl || base.merchantOsUrl).trim();
235
+ return resolveOnboardingStatus({ merchantOsUrl, token });
236
+ },
237
+
238
+ async protect(routeConfig = {}) {
239
+ const advanced = readAdvanced(routeConfig);
240
+ const method = normalizeMethod(routeConfig.method || "GET");
241
+ const path = normalizePath(routeConfig.path, "routeConfig.path");
242
+
243
+ const guard = await createRailbridgePaymentGuard({
244
+ facilitatorUrl: base.facilitatorUrl,
245
+ merchantOsApiUrl: base.merchantOsUrl,
246
+ merchantApiKey: base.apiKey,
247
+ route: {
248
+ method,
249
+ path,
250
+ },
251
+ apiId: routeConfig.apiId,
252
+ apiProductId: routeConfig.apiProductId,
253
+ settlementModeOverride: routeConfig.settlementModeOverride,
254
+ paywallAppName: advanced.paywallAppName || routeConfig.paywallAppName || base.paywallAppName,
255
+ paywallTestnet:
256
+ typeof advanced.paywallTestnet === "boolean"
257
+ ? advanced.paywallTestnet
258
+ : typeof routeConfig.paywallTestnet === "boolean"
259
+ ? routeConfig.paywallTestnet
260
+ : base.paywallTestnet,
261
+ autoRefreshMs:
262
+ Number.isFinite(advanced.autoRefreshMs)
263
+ ? advanced.autoRefreshMs
264
+ : Number.isFinite(routeConfig.autoRefreshMs)
265
+ ? routeConfig.autoRefreshMs
266
+ : base.autoRefreshMs,
267
+ supportedSourceNetworks:
268
+ advanced.supportedSourceNetworks ||
269
+ routeConfig.supportedSourceNetworks ||
270
+ base.supportedSourceNetworks,
271
+ sourceNetworkFilter:
272
+ advanced.sourceNetworkFilter ||
273
+ routeConfig.sourceNetworkFilter ||
274
+ base.sourceNetworkFilter,
275
+ maxRequirementOptions:
276
+ Number.isFinite(advanced.maxRequirementOptions)
277
+ ? advanced.maxRequirementOptions
278
+ : Number.isFinite(routeConfig.maxRequirementOptions)
279
+ ? routeConfig.maxRequirementOptions
280
+ : base.maxRequirementOptions,
281
+ logPrefix: advanced.logPrefix || routeConfig.logPrefix || base.logPrefix,
282
+ logger: advanced.logger || routeConfig.logger || base.logger,
283
+ logLevel: advanced.logLevel || routeConfig.logLevel || base.logLevel,
284
+ });
285
+
286
+ const middleware = guard.middleware;
287
+ middleware.routeMethod = guard.routeMethod;
288
+ middleware.routePath = guard.routePath;
289
+ middleware.refreshRequirements = guard.refreshRequirements;
290
+ middleware.getCurrentRouteInfo = guard.getCurrentRouteInfo;
291
+ middleware.startAutoRefresh = guard.startAutoRefresh;
292
+ middleware.stopAutoRefresh = guard.stopAutoRefresh;
293
+
294
+ return middleware;
295
+ },
296
+
297
+ async protectExpress(app, routeConfig = {}, ...handlers) {
298
+ const middleware = await client.protect(routeConfig);
299
+ const method = middleware.routeMethod || normalizeMethod(routeConfig.method || "GET");
300
+ const path = middleware.routePath || normalizePath(routeConfig.path, "routeConfig.path");
301
+ mountProtectedRoute(app, method, path, middleware, handlers);
302
+ return middleware;
303
+ },
304
+
305
+ webhooks: {
306
+ verify: verifyWebhook,
307
+ express: createExpressWebhookHandler,
308
+ },
309
+ };
310
+
311
+ return client;
312
+ };
313
+
314
+ export const createRailbridgeFromEnv = (env = process.env, overrides = {}) => {
315
+ if (!env || typeof env !== "object") {
316
+ throw new Error("env must be an object");
317
+ }
318
+
319
+ const environment = resolveEnvironmentInput(
320
+ overrides.environment ?? env.RB_ENV ?? env.RAILBRIDGE_ENV,
321
+ "RB_ENV or RAILBRIDGE_ENV",
322
+ );
323
+
324
+ return createRailbridge({
325
+ ...overrides,
326
+ apiKey: overrides.apiKey ?? env.RB_API_KEY,
327
+ environment,
328
+ advanced: {
329
+ ...(overrides.advanced || {}),
330
+ merchantOsUrl: overrides.advanced?.merchantOsUrl ?? overrides.merchantOsUrl ?? env.RB_MERCHANT_OS_URL,
331
+ facilitatorUrl:
332
+ overrides.advanced?.facilitatorUrl ?? overrides.facilitatorUrl ?? env.RB_FACILITATOR_URL,
333
+ paywallTestnet:
334
+ overrides.advanced?.paywallTestnet ??
335
+ overrides.paywallTestnet ??
336
+ parseBoolean(env.RB_PAYWALL_TESTNET, "RB_PAYWALL_TESTNET"),
337
+ sourceNetworkFilter:
338
+ overrides.advanced?.sourceNetworkFilter ??
339
+ overrides.sourceNetworkFilter ??
340
+ env.RB_SOURCE_NETWORK_FILTER,
341
+ maxRequirementOptions:
342
+ overrides.advanced?.maxRequirementOptions ??
343
+ overrides.maxRequirementOptions ??
344
+ parseOptionalFiniteNumber(env.RB_MAX_REQUIREMENT_OPTIONS, "RB_MAX_REQUIREMENT_OPTIONS"),
345
+ autoRefreshMs:
346
+ overrides.advanced?.autoRefreshMs ??
347
+ overrides.autoRefreshMs ??
348
+ parseOptionalFiniteNumber(env.RB_AUTO_REFRESH_MS, "RB_AUTO_REFRESH_MS"),
349
+ logPrefix: overrides.advanced?.logPrefix ?? overrides.logPrefix ?? env.RB_LOG_PREFIX,
350
+ paywallAppName:
351
+ overrides.advanced?.paywallAppName ?? overrides.paywallAppName ?? env.RB_PAYWALL_APP_NAME,
352
+ logLevel: overrides.advanced?.logLevel ?? overrides.logLevel ?? env.RB_LOG_LEVEL,
353
+ logger: overrides.advanced?.logger ?? overrides.logger,
354
+ },
355
+ });
356
+ };
357
+
358
+ export const getOnboardingStatus = async ({ merchantOsUrl, token }) => {
359
+ return resolveOnboardingStatus({
360
+ merchantOsUrl: ensureNonEmpty(merchantOsUrl, "merchantOsUrl"),
361
+ token: ensureNonEmpty(token, "token"),
362
+ });
363
+ };
364
+
365
+ export const createRailbridgePaymentGuard = async (config) => {
366
+ const module = await import("./paymentGuard.js");
367
+ return module.createRailbridgePaymentGuard(config);
368
+ };
369
+
370
+ export { createExpressWebhookHandler, verifyWebhook };
@@ -0,0 +1,558 @@
1
+ import { paymentMiddleware } from "@x402/express";
2
+ import { x402ResourceServer } from "@x402/core/server";
3
+ import { HTTPFacilitatorClient } from "@x402/core/http";
4
+ import { registerExactEvmScheme } from "@x402/evm/exact/server";
5
+ import { createPaywall } from "@x402/paywall";
6
+ import { evmPaywall } from "@x402/paywall/evm";
7
+ import { CROSS_CHAIN, declareCrossChainExtension } from "./crossChain.js";
8
+
9
+ const DEFAULT_SUPPORTED_NETWORKS = [
10
+ "eip155:421614",
11
+ "eip155:5042002",
12
+ "eip155:84532",
13
+ "eip155:11155111",
14
+ "eip155:8453",
15
+ "eip155:137",
16
+ "eip155:1",
17
+ ];
18
+
19
+ const DEFAULT_MAX_REQUIREMENT_OPTIONS = 8;
20
+ const LOG_LEVEL_PRIORITY = {
21
+ silent: 0,
22
+ error: 1,
23
+ warn: 2,
24
+ info: 3,
25
+ debug: 4,
26
+ };
27
+ const PREFERRED_REQUIREMENT_NETWORKS = [
28
+ "eip155:84532",
29
+ "eip155:421614",
30
+ "eip155:11155111",
31
+ "eip155:5042002",
32
+ "eip155:8453",
33
+ "eip155:42161",
34
+ "eip155:10",
35
+ "eip155:1",
36
+ "eip155:137",
37
+ ];
38
+
39
+ const SUPPORTED_SETTLEMENT_MODES = new Set(["same_chain", "cross_chain"]);
40
+ const FACILITATOR_SUPPORTED_ENDPOINT = "/supported";
41
+ const FACILITATOR_CHAINS_ENDPOINT = "/chains";
42
+ const DEFAULT_FETCH_TIMEOUT_MS = 8000;
43
+
44
+ const isValidPayTo = (value) =>
45
+ typeof value === "string" && /^0x[a-fA-F0-9]{40}$/.test(value.trim());
46
+
47
+ const isValidNetwork = (value) =>
48
+ typeof value === "string" && /^[a-z0-9]+:[a-zA-Z0-9]+$/.test(value.trim());
49
+
50
+ const isValidPrice = (value) => {
51
+ if (!value || typeof value !== "object") {
52
+ return false;
53
+ }
54
+ if (typeof value.asset !== "string" || !value.asset.trim()) {
55
+ return false;
56
+ }
57
+ if (typeof value.amount !== "string" || !/^\d+(\.\d+)?$/.test(value.amount.trim())) {
58
+ return false;
59
+ }
60
+ return Number(value.amount) > 0;
61
+ };
62
+
63
+ const isValidRequirement = (value) =>
64
+ value?.scheme === "exact" &&
65
+ isValidNetwork(value.network) &&
66
+ isValidPayTo(value.payTo) &&
67
+ isValidPrice(value.price);
68
+
69
+ class LoggingFacilitatorClient extends HTTPFacilitatorClient {
70
+ constructor({ url, sdkLogger }) {
71
+ super({ url });
72
+ this.sdkLogger = sdkLogger;
73
+ }
74
+
75
+ async settle(paymentPayload, paymentRequirements) {
76
+ this.sdkLogger.debug("settle", {
77
+ network: paymentRequirements.network,
78
+ scheme: paymentRequirements.scheme,
79
+ });
80
+ return super.settle(paymentPayload, paymentRequirements);
81
+ }
82
+
83
+ async verify(paymentPayload, paymentRequirements) {
84
+ this.sdkLogger.debug("verify", {
85
+ network: paymentRequirements.network,
86
+ scheme: paymentRequirements.scheme,
87
+ });
88
+ return super.verify(paymentPayload, paymentRequirements);
89
+ }
90
+ }
91
+
92
+ const normalizeMethod = (method) => {
93
+ const value = String(method || "").trim().toUpperCase();
94
+ return value || "GET";
95
+ };
96
+
97
+ const normalizePath = (path) => {
98
+ const value = String(path || "").trim();
99
+ if (!value) {
100
+ throw new Error("route.path is required");
101
+ }
102
+ return value.startsWith("/") ? value : `/${value}`;
103
+ };
104
+
105
+ const normalizeRequestPath = (path) => {
106
+ const value = String(path || "").trim();
107
+ if (!value) {
108
+ return "/";
109
+ }
110
+ const withoutQuery = value.split("?")[0] || "/";
111
+ return withoutQuery.startsWith("/") ? withoutQuery : `/${withoutQuery}`;
112
+ };
113
+
114
+ const normalizeSettlementMode = (mode) => {
115
+ const value = String(mode || "").trim().toLowerCase();
116
+ if (!SUPPORTED_SETTLEMENT_MODES.has(value)) {
117
+ throw new Error("settlementModeOverride must be either 'same_chain' or 'cross_chain'");
118
+ }
119
+ return value;
120
+ };
121
+
122
+ const ensureRequired = (value, fieldName) => {
123
+ const trimmed = String(value || "").trim();
124
+ if (!trimmed) {
125
+ throw new Error(`${fieldName} is required`);
126
+ }
127
+ return trimmed;
128
+ };
129
+
130
+ const createSdkLogger = ({ logger = console, logLevel = "warn", logPrefix = "[railbridge-sdk]" }) => {
131
+ const threshold = LOG_LEVEL_PRIORITY[logLevel] ?? LOG_LEVEL_PRIORITY.warn;
132
+ const resolveMethod = (level) => {
133
+ const fn = logger?.[level];
134
+ return typeof fn === "function" ? fn.bind(logger) : null;
135
+ };
136
+
137
+ const emit = (level, message, metadata) => {
138
+ if ((LOG_LEVEL_PRIORITY[level] ?? LOG_LEVEL_PRIORITY.info) > threshold) {
139
+ return;
140
+ }
141
+
142
+ const fn = resolveMethod(level);
143
+ if (!fn) {
144
+ return;
145
+ }
146
+
147
+ if (metadata === undefined) {
148
+ fn(`${logPrefix} ${message}`);
149
+ return;
150
+ }
151
+
152
+ fn(`${logPrefix} ${message}`, metadata);
153
+ };
154
+
155
+ return {
156
+ debug(message, metadata) {
157
+ emit("debug", message, metadata);
158
+ },
159
+ info(message, metadata) {
160
+ emit("info", message, metadata);
161
+ },
162
+ warn(message, metadata) {
163
+ emit("warn", message, metadata);
164
+ },
165
+ error(message, metadata) {
166
+ emit("error", message, metadata);
167
+ },
168
+ };
169
+ };
170
+
171
+ const fetchWithTimeout = async (url, init = {}, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) => {
172
+ const controller = new AbortController();
173
+ const timer = setTimeout(() => controller.abort(), Math.max(1000, timeoutMs));
174
+ try {
175
+ return await fetch(url, {
176
+ ...init,
177
+ signal: controller.signal,
178
+ });
179
+ } finally {
180
+ clearTimeout(timer);
181
+ }
182
+ };
183
+
184
+ const fetchFacilitatorSupportedNetworks = async (facilitatorUrl) => {
185
+ try {
186
+ const response = await fetchWithTimeout(`${facilitatorUrl}${FACILITATOR_SUPPORTED_ENDPOINT}`);
187
+ if (!response.ok) {
188
+ return [...DEFAULT_SUPPORTED_NETWORKS];
189
+ }
190
+
191
+ const body = await response.json().catch(() => ({}));
192
+ const networks = Array.isArray(body.kinds)
193
+ ? body.kinds
194
+ .filter((kind) => Boolean(kind?.network && kind?.scheme))
195
+ .filter((kind) => kind.scheme === "exact")
196
+ .map((kind) => kind.network)
197
+ : [];
198
+
199
+ if (!networks.length) {
200
+ return [...DEFAULT_SUPPORTED_NETWORKS];
201
+ }
202
+
203
+ return Array.from(new Set(networks));
204
+ } catch {
205
+ return [...DEFAULT_SUPPORTED_NETWORKS];
206
+ }
207
+ };
208
+
209
+ const fetchFacilitatorChains = async (facilitatorUrl) => {
210
+ try {
211
+ const response = await fetchWithTimeout(`${facilitatorUrl}${FACILITATOR_CHAINS_ENDPOINT}`);
212
+ if (!response.ok) {
213
+ return [];
214
+ }
215
+ const body = await response.json().catch(() => ({}));
216
+ if (!Array.isArray(body.items)) {
217
+ return [];
218
+ }
219
+
220
+ return body.items
221
+ .filter((item) => typeof item?.network === "string" && item.network.includes(":"))
222
+ .filter((item) => String(item.status || "active").toLowerCase() !== "paused")
223
+ .map((item) => ({
224
+ network: item.network,
225
+ isTestnet: Boolean(item.isTestnet),
226
+ }));
227
+ } catch {
228
+ return [];
229
+ }
230
+ };
231
+
232
+ const clampMaxRequirementOptions = (value) => {
233
+ if (!Number.isFinite(value)) {
234
+ return DEFAULT_MAX_REQUIREMENT_OPTIONS;
235
+ }
236
+ const normalized = Math.floor(Number(value));
237
+ if (normalized <= 0) {
238
+ return 1;
239
+ }
240
+ return normalized;
241
+ };
242
+
243
+ const sortRequirementOptions = (options) => {
244
+ const weightByNetwork = new Map(
245
+ PREFERRED_REQUIREMENT_NETWORKS.map((network, index) => [network, index]),
246
+ );
247
+
248
+ return [...options].sort((left, right) => {
249
+ const leftWeight = weightByNetwork.get(left.network);
250
+ const rightWeight = weightByNetwork.get(right.network);
251
+
252
+ if (leftWeight !== undefined && rightWeight !== undefined) {
253
+ return leftWeight - rightWeight;
254
+ }
255
+ if (leftWeight !== undefined) {
256
+ return -1;
257
+ }
258
+ if (rightWeight !== undefined) {
259
+ return 1;
260
+ }
261
+ return left.network.localeCompare(right.network);
262
+ });
263
+ };
264
+
265
+ export const createRailbridgePaymentGuard = async (config) => {
266
+ const facilitatorUrl = ensureRequired(config.facilitatorUrl, "facilitatorUrl");
267
+ const merchantOsApiUrl = ensureRequired(config.merchantOsApiUrl, "merchantOsApiUrl");
268
+ const merchantApiKey = ensureRequired(config.merchantApiKey, "merchantApiKey");
269
+ const routeMethod = normalizeMethod(config.route?.method || "GET");
270
+ const routePath = normalizePath(config.route?.path || "");
271
+ const apiId = String(config.apiId || "").trim();
272
+ const apiProductId = String(config.apiProductId || "").trim();
273
+ const settlementMode = config.settlementModeOverride
274
+ ? normalizeSettlementMode(config.settlementModeOverride)
275
+ : null;
276
+
277
+ const logPrefix = config.logPrefix || "[railbridge-sdk]";
278
+ const maxRequirementOptions = clampMaxRequirementOptions(config.maxRequirementOptions);
279
+ const sourceNetworkFilter = config.sourceNetworkFilter || "all";
280
+ const sdkLogger = createSdkLogger({
281
+ logger: config.logger,
282
+ logLevel: config.logLevel,
283
+ logPrefix,
284
+ });
285
+
286
+ const routes = {};
287
+ let currentRouteInfo = null;
288
+ let refreshInFlight = null;
289
+ let refreshTimer = null;
290
+ let lastResolvedSignature = "";
291
+
292
+ const fetchedSupportedNetworks =
293
+ Array.isArray(config.supportedSourceNetworks) && config.supportedSourceNetworks.length
294
+ ? config.supportedSourceNetworks
295
+ : await fetchFacilitatorSupportedNetworks(facilitatorUrl);
296
+
297
+ const facilitatorChains = await fetchFacilitatorChains(facilitatorUrl);
298
+ const filteredSourceNetworkSet =
299
+ sourceNetworkFilter === "all"
300
+ ? null
301
+ : new Set(
302
+ facilitatorChains
303
+ .filter((chain) =>
304
+ sourceNetworkFilter === "testnet_only" ? chain.isTestnet : !chain.isTestnet,
305
+ )
306
+ .map((chain) => chain.network),
307
+ );
308
+
309
+ const supportedSourceNetworks =
310
+ filteredSourceNetworkSet && filteredSourceNetworkSet.size
311
+ ? fetchedSupportedNetworks.filter((network) => filteredSourceNetworkSet.has(network))
312
+ : fetchedSupportedNetworks;
313
+
314
+ if (filteredSourceNetworkSet && !filteredSourceNetworkSet.size) {
315
+ throw new Error(
316
+ `${logPrefix} unable to enforce source network filter: facilitator /chains metadata unavailable`,
317
+ );
318
+ }
319
+
320
+ if (!supportedSourceNetworks.length) {
321
+ throw new Error(`${logPrefix} no supported source networks available after initialization`);
322
+ }
323
+
324
+ const supportedSourceNetworkSet = new Set(supportedSourceNetworks);
325
+ const facilitatorClient = new LoggingFacilitatorClient({
326
+ url: facilitatorUrl,
327
+ sdkLogger,
328
+ });
329
+
330
+ const resourceServer = new x402ResourceServer(facilitatorClient);
331
+ registerExactEvmScheme(resourceServer, {
332
+ networks: supportedSourceNetworks,
333
+ });
334
+
335
+ const paywall = createPaywall()
336
+ .withNetwork(evmPaywall)
337
+ .withConfig({
338
+ appName: config.paywallAppName || "RailBridge Merchant Integration",
339
+ testnet: config.paywallTestnet ?? true,
340
+ })
341
+ .build();
342
+
343
+ const routeKey = `${routeMethod} ${routePath}`;
344
+
345
+ const fetchResolvedRequirement = async () => {
346
+ const response = await fetchWithTimeout(
347
+ `${merchantOsApiUrl.replace(/\/$/, "")}/v1/sdk/requirements/resolve`,
348
+ {
349
+ method: "POST",
350
+ headers: {
351
+ "content-type": "application/json",
352
+ "x-railbridge-api-key": merchantApiKey,
353
+ },
354
+ body: JSON.stringify({
355
+ apiId: apiId || undefined,
356
+ apiProductId: apiProductId || undefined,
357
+ method: routeMethod,
358
+ path: routePath,
359
+ settlementModeOverride: settlementMode || undefined,
360
+ }),
361
+ },
362
+ DEFAULT_FETCH_TIMEOUT_MS,
363
+ );
364
+
365
+ const body = await response.json().catch(() => ({}));
366
+ if (!response.ok) {
367
+ throw new Error(
368
+ `Merchant OS requirement resolve failed (${response.status}): ${body.error || body.message || "unknown error"}`,
369
+ );
370
+ }
371
+
372
+ return body;
373
+ };
374
+
375
+ const refreshRequirementsInternal = async ({ reason = "manual", continueOnError = false } = {}) => {
376
+ try {
377
+ const resolved = await fetchResolvedRequirement();
378
+ const requirementOptions =
379
+ Array.isArray(resolved.requirements) && resolved.requirements.length
380
+ ? resolved.requirements
381
+ : [resolved.requirement];
382
+
383
+ const invalidRequirement = requirementOptions.find((requirement) => !isValidRequirement(requirement));
384
+ if (invalidRequirement) {
385
+ throw new Error("Merchant OS returned invalid requirement payload");
386
+ }
387
+
388
+ const supportedRequirements = requirementOptions.filter((requirement) =>
389
+ supportedSourceNetworkSet.has(requirement.network),
390
+ );
391
+
392
+ if (!supportedRequirements.length) {
393
+ const requestedNetworks = Array.from(new Set(requirementOptions.map((r) => r.network)));
394
+ throw new Error(
395
+ `No supported source networks after filtering. Requested=${requestedNetworks.join(",")} Supported=${Array.from(
396
+ supportedSourceNetworkSet,
397
+ ).join(",")}`,
398
+ );
399
+ }
400
+
401
+ const prioritizedRequirements = sortRequirementOptions(supportedRequirements);
402
+ const truncatedRequirements = prioritizedRequirements.slice(0, maxRequirementOptions);
403
+
404
+ const nextRoute = {
405
+ accepts: truncatedRequirements.map((requirement) => ({
406
+ scheme: requirement.scheme,
407
+ network: requirement.network,
408
+ price: requirement.price,
409
+ payTo: requirement.payTo,
410
+ merchantId: resolved.merchantId,
411
+ accountId: resolved.accountId,
412
+ extra: requirement.extra,
413
+ })),
414
+ description:
415
+ resolved.requirement?.extra?.description ||
416
+ resolved.apiProduct?.description ||
417
+ resolved.apiProduct?.apiName ||
418
+ "RailBridge protected endpoint",
419
+ mimeType: "application/json",
420
+ };
421
+
422
+ if (resolved.crossChain) {
423
+ nextRoute.extensions = {
424
+ [CROSS_CHAIN]: declareCrossChainExtension({
425
+ destinationNetwork: resolved.crossChain.destinationNetwork,
426
+ destinationAsset: resolved.crossChain.destinationAsset,
427
+ destinationPayTo: resolved.crossChain.destinationPayTo,
428
+ }),
429
+ };
430
+ }
431
+
432
+ routes[routeKey] = nextRoute;
433
+
434
+ currentRouteInfo = {
435
+ routeKey,
436
+ payTo: resolved.requirement.payTo,
437
+ sourceNetworks: truncatedRequirements.map((requirement) => requirement.network),
438
+ crossChain: Boolean(resolved.crossChain),
439
+ settlementMode: resolved.settlementMode || settlementMode || "auto",
440
+ };
441
+
442
+ const nextSignature = JSON.stringify({
443
+ payTo: currentRouteInfo.payTo,
444
+ sourceNetworks: currentRouteInfo.sourceNetworks,
445
+ crossChain: currentRouteInfo.crossChain,
446
+ settlementMode: currentRouteInfo.settlementMode,
447
+ accepts: truncatedRequirements.map((requirement) => ({
448
+ scheme: requirement.scheme,
449
+ network: requirement.network,
450
+ payTo: requirement.payTo,
451
+ asset: requirement.price.asset,
452
+ amount: requirement.price.amount,
453
+ })),
454
+ extension: resolved.crossChain || null,
455
+ });
456
+
457
+ if (nextSignature !== lastResolvedSignature) {
458
+ lastResolvedSignature = nextSignature;
459
+ sdkLogger.info("route requirements refreshed", {
460
+ ...currentRouteInfo,
461
+ reason,
462
+ });
463
+ }
464
+
465
+ return currentRouteInfo;
466
+ } catch (error) {
467
+ if (continueOnError && currentRouteInfo) {
468
+ sdkLogger.warn("route refresh failed, continuing with cached requirements", {
469
+ reason,
470
+ error: error instanceof Error ? error.message : String(error),
471
+ });
472
+ return currentRouteInfo;
473
+ }
474
+ throw error;
475
+ }
476
+ };
477
+
478
+ const refreshRequirements = () => {
479
+ if (!refreshInFlight) {
480
+ refreshInFlight = refreshRequirementsInternal({ reason: "manual" }).finally(() => {
481
+ refreshInFlight = null;
482
+ });
483
+ }
484
+ return refreshInFlight;
485
+ };
486
+
487
+ const refreshForProtectedRequest = () => {
488
+ if (!refreshInFlight) {
489
+ refreshInFlight = refreshRequirementsInternal({
490
+ reason: "request",
491
+ continueOnError: false,
492
+ }).finally(() => {
493
+ refreshInFlight = null;
494
+ });
495
+ }
496
+ return refreshInFlight;
497
+ };
498
+
499
+ await refreshRequirementsInternal({ reason: "startup" });
500
+
501
+ const baseMiddleware = paymentMiddleware(routes, resourceServer, undefined, paywall, true);
502
+
503
+ const shouldRefreshForRequest = (req) =>
504
+ normalizeMethod(req.method || "GET") === routeMethod &&
505
+ normalizeRequestPath(req.path || req.originalUrl || req.url || "") === routePath;
506
+
507
+ const middleware = (req, res, next) => {
508
+ if (!shouldRefreshForRequest(req)) {
509
+ return baseMiddleware(req, res, next);
510
+ }
511
+
512
+ void refreshForProtectedRequest()
513
+ .then(() => {
514
+ baseMiddleware(req, res, next);
515
+ })
516
+ .catch((error) => {
517
+ next(error);
518
+ });
519
+ };
520
+
521
+ const startAutoRefresh = () => {
522
+ if (refreshTimer) {
523
+ return;
524
+ }
525
+ const everyMs = Math.max(10_000, Number(config.autoRefreshMs || 30_000));
526
+ refreshTimer = setInterval(() => {
527
+ if (!refreshInFlight) {
528
+ refreshInFlight = refreshRequirementsInternal({
529
+ reason: "interval",
530
+ continueOnError: true,
531
+ }).finally(() => {
532
+ refreshInFlight = null;
533
+ });
534
+ }
535
+ }, everyMs);
536
+
537
+ if (typeof refreshTimer.unref === "function") {
538
+ refreshTimer.unref();
539
+ }
540
+ };
541
+
542
+ const stopAutoRefresh = () => {
543
+ if (refreshTimer) {
544
+ clearInterval(refreshTimer);
545
+ refreshTimer = null;
546
+ }
547
+ };
548
+
549
+ return {
550
+ middleware,
551
+ routeMethod,
552
+ routePath,
553
+ refreshRequirements,
554
+ getCurrentRouteInfo: () => currentRouteInfo,
555
+ startAutoRefresh,
556
+ stopAutoRefresh,
557
+ };
558
+ };
@@ -0,0 +1,75 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ const normalizePayload = (payload) =>
4
+ typeof payload === "string" ? payload : JSON.stringify(payload ?? {});
5
+
6
+ const normalizeSignature = (signature) => String(signature || "").trim().toLowerCase();
7
+
8
+ const secureHexEqual = (leftHex, rightHex) => {
9
+ const left = Buffer.from(leftHex, "hex");
10
+ const right = Buffer.from(rightHex, "hex");
11
+ if (left.length !== right.length) {
12
+ return false;
13
+ }
14
+ return timingSafeEqual(left, right);
15
+ };
16
+
17
+ export const verifyWebhook = ({ secret, timestamp, payload, signature }) => {
18
+ if (!secret || !timestamp || !signature) {
19
+ return false;
20
+ }
21
+
22
+ const body = normalizePayload(payload);
23
+ const signedPayload = `${String(timestamp)}.${body}`;
24
+ const expected = createHmac("sha256", String(secret)).update(signedPayload).digest("hex");
25
+ const actual = normalizeSignature(signature);
26
+
27
+ if (!/^[a-f0-9]+$/.test(actual) || actual.length !== expected.length) {
28
+ return false;
29
+ }
30
+
31
+ return secureHexEqual(expected, actual);
32
+ };
33
+
34
+ export const createExpressWebhookHandler = ({
35
+ secret,
36
+ requireSignature = true,
37
+ onEvent,
38
+ } = {}) => {
39
+ const handler = async (req, res) => {
40
+ const signature = req.header("x-railbridge-signature");
41
+ const timestamp = req.header("x-railbridge-timestamp");
42
+ const rawBody = typeof req.body === "string" ? req.body : JSON.stringify(req.body ?? {});
43
+
44
+ if (requireSignature) {
45
+ const ok = verifyWebhook({
46
+ secret,
47
+ timestamp,
48
+ payload: rawBody,
49
+ signature,
50
+ });
51
+ if (!ok) {
52
+ return res.status(401).json({ error: "invalid signature" });
53
+ }
54
+ }
55
+
56
+ let event;
57
+ try {
58
+ event = JSON.parse(rawBody);
59
+ } catch {
60
+ return res.status(400).json({ error: "invalid JSON payload" });
61
+ }
62
+
63
+ if (typeof onEvent === "function") {
64
+ await onEvent(event, {
65
+ headers: req.headers,
66
+ eventType: req.header("x-railbridge-event") || null,
67
+ eventId: req.header("x-railbridge-event-id") || null,
68
+ });
69
+ }
70
+
71
+ return res.status(200).json({ ok: true });
72
+ };
73
+
74
+ return handler;
75
+ };