@relai-fi/x402 0.5.37 → 0.5.39
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/dist/client.cjs +2 -1
- package/dist/client.cjs.map +1 -1
- package/dist/client.js +2 -1
- package/dist/client.js.map +1 -1
- package/dist/index.cjs +2 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins.cjs +141 -22
- package/dist/plugins.cjs.map +1 -1
- package/dist/plugins.d.cts +1 -1
- package/dist/plugins.d.ts +1 -1
- package/dist/plugins.js +131 -22
- package/dist/plugins.js.map +1 -1
- package/dist/react/index.cjs +2 -1
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.js +2 -1
- package/dist/react/index.js.map +1 -1
- package/dist/{server-DJGM7dFH.d.ts → server-CaSmhDnd.d.ts} +35 -5
- package/dist/{server-DPYBh7fy.d.cts → server-CyfEHW9D.d.cts} +35 -5
- package/dist/server.d.cts +1 -1
- package/dist/server.d.ts +1 -1
- package/package.json +1 -1
package/dist/plugins.cjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
2
3
|
var __defProp = Object.defineProperty;
|
|
3
4
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
5
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
8
|
var __export = (target, all) => {
|
|
7
9
|
for (var name in all)
|
|
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
15
17
|
}
|
|
16
18
|
return to;
|
|
17
19
|
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
18
28
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
29
|
|
|
20
30
|
// src/plugins.ts
|
|
@@ -24,10 +34,15 @@ __export(plugins_exports, {
|
|
|
24
34
|
freeTier: () => freeTier
|
|
25
35
|
});
|
|
26
36
|
module.exports = __toCommonJS(plugins_exports);
|
|
37
|
+
var import_crypto = __toESM(require("crypto"), 1);
|
|
27
38
|
var RELAI_API_BASE = "https://api.relai.fi";
|
|
28
39
|
function bridge(config) {
|
|
29
40
|
const base = (config?.baseUrl ?? RELAI_API_BASE).replace(/\/$/, "");
|
|
30
41
|
let bridgeInfo = null;
|
|
42
|
+
let serviceKeyHash = null;
|
|
43
|
+
if (config?.serviceKey) {
|
|
44
|
+
serviceKeyHash = import_crypto.default.createHash("sha256").update(config.serviceKey).digest("hex").slice(0, 16);
|
|
45
|
+
}
|
|
31
46
|
async function fetchBridgeInfo() {
|
|
32
47
|
try {
|
|
33
48
|
const res = await fetch(`${base}/bridge/info`);
|
|
@@ -83,7 +98,8 @@ function bridge(config) {
|
|
|
83
98
|
payToMap: bridgeInfo.payTo,
|
|
84
99
|
feePayerSvm: bridgeInfo.feePayerSvm,
|
|
85
100
|
feeBps: bridgeInfo.feeBps,
|
|
86
|
-
paymentFacilitator: bridgeInfo.paymentFacilitator
|
|
101
|
+
paymentFacilitator: bridgeInfo.paymentFacilitator,
|
|
102
|
+
...serviceKeyHash ? { serviceKeyHash } : {}
|
|
87
103
|
}
|
|
88
104
|
};
|
|
89
105
|
return response;
|
|
@@ -93,13 +109,11 @@ function bridge(config) {
|
|
|
93
109
|
function freeTier(config) {
|
|
94
110
|
const base = (config.baseUrl ?? RELAI_API_BASE).replace(/\/$/, "");
|
|
95
111
|
const cacheTtl = config.cacheTtlMs ?? 5e3;
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
};
|
|
102
|
-
}
|
|
112
|
+
const isLocal = !config.serviceKey;
|
|
113
|
+
const resetPeriod = config.resetPeriod ?? "none";
|
|
114
|
+
const limit = config.perBuyerLimit;
|
|
115
|
+
const globalCap = config.globalCap ?? null;
|
|
116
|
+
const pluginPaths = config.paths ?? ["*"];
|
|
103
117
|
function resolveBuyerId(req) {
|
|
104
118
|
try {
|
|
105
119
|
const auth = req.headers?.authorization || "";
|
|
@@ -115,11 +129,99 @@ function freeTier(config) {
|
|
|
115
129
|
const ip = (req.headers?.["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || req.ip || "unknown";
|
|
116
130
|
return `ip:${ip}`;
|
|
117
131
|
}
|
|
118
|
-
function
|
|
132
|
+
function pathMatches(requestPath) {
|
|
133
|
+
return pluginPaths.includes("*") || pluginPaths.some((p) => {
|
|
134
|
+
const normalized = p.toLowerCase().replace(/\/+$/, "") || "/";
|
|
135
|
+
const reqNormalized = requestPath.toLowerCase().replace(/\/+$/, "") || "/";
|
|
136
|
+
return normalized === reqNormalized || normalized === "*";
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const localUsage = /* @__PURE__ */ new Map();
|
|
140
|
+
let localGlobalCount = 0;
|
|
141
|
+
function currentPeriodStart() {
|
|
142
|
+
const now = /* @__PURE__ */ new Date();
|
|
143
|
+
if (resetPeriod === "daily") {
|
|
144
|
+
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString();
|
|
145
|
+
}
|
|
146
|
+
if (resetPeriod === "monthly") {
|
|
147
|
+
return new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
|
|
148
|
+
}
|
|
149
|
+
return "none";
|
|
150
|
+
}
|
|
151
|
+
function isCurrentPeriod(entry) {
|
|
152
|
+
if (resetPeriod === "none") return true;
|
|
153
|
+
return entry.periodStart === currentPeriodStart();
|
|
154
|
+
}
|
|
155
|
+
function localKey(path, buyerId) {
|
|
156
|
+
return `${path}:${buyerId}`;
|
|
157
|
+
}
|
|
158
|
+
function localCheck(path, buyerId) {
|
|
159
|
+
const key = localKey(path, buyerId);
|
|
160
|
+
const entry = localUsage.get(key);
|
|
161
|
+
const count = entry && isCurrentPeriod(entry) ? entry.count : 0;
|
|
162
|
+
const remaining = Math.max(0, limit - count);
|
|
163
|
+
if (globalCap != null && localGlobalCount >= globalCap) {
|
|
164
|
+
return { free: false, remaining: 0, total: limit, reason: "global_cap_reached", globalRemaining: 0 };
|
|
165
|
+
}
|
|
166
|
+
if (count >= limit) {
|
|
167
|
+
return { free: false, remaining: 0, total: limit, reason: "limit_reached" };
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
free: true,
|
|
171
|
+
remaining: remaining - 1,
|
|
172
|
+
// after this call
|
|
173
|
+
total: limit,
|
|
174
|
+
...globalCap != null ? { globalRemaining: globalCap - localGlobalCount - 1 } : {}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function localRecord(path, buyerId) {
|
|
178
|
+
const key = localKey(path, buyerId);
|
|
179
|
+
const period = currentPeriodStart();
|
|
180
|
+
const entry = localUsage.get(key);
|
|
181
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
182
|
+
if (entry && isCurrentPeriod(entry)) {
|
|
183
|
+
entry.count++;
|
|
184
|
+
entry.lastCall = now;
|
|
185
|
+
} else {
|
|
186
|
+
localUsage.set(key, { count: 1, periodStart: period, lastCall: now });
|
|
187
|
+
}
|
|
188
|
+
localGlobalCount++;
|
|
189
|
+
}
|
|
190
|
+
function exportLocalData() {
|
|
191
|
+
const usage = [];
|
|
192
|
+
for (const [key, entry] of localUsage.entries()) {
|
|
193
|
+
const firstSlash = key.indexOf("/");
|
|
194
|
+
const colonAfterPath = key.indexOf(":", firstSlash > -1 ? firstSlash : 0);
|
|
195
|
+
const path = colonAfterPath > -1 ? key.slice(0, colonAfterPath) : key;
|
|
196
|
+
const buyerId = colonAfterPath > -1 ? key.slice(colonAfterPath + 1) : "unknown";
|
|
197
|
+
usage.push({
|
|
198
|
+
buyerId,
|
|
199
|
+
path,
|
|
200
|
+
count: entry.count,
|
|
201
|
+
periodStart: entry.periodStart,
|
|
202
|
+
lastCall: entry.lastCall
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
mode: "local",
|
|
207
|
+
config: { perBuyerLimit: limit, resetPeriod, globalCap, paths: pluginPaths },
|
|
208
|
+
usage,
|
|
209
|
+
globalCount: localGlobalCount,
|
|
210
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const cache = /* @__PURE__ */ new Map();
|
|
214
|
+
function resolveHeaders() {
|
|
215
|
+
return {
|
|
216
|
+
"X-Service-Key": config.serviceKey,
|
|
217
|
+
"Content-Type": "application/json"
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function cloudCacheKey(path, buyerId) {
|
|
119
221
|
return `${config.serviceKey}:${path}:${buyerId}`;
|
|
120
222
|
}
|
|
121
223
|
async function checkFreeTier(path, buyerId) {
|
|
122
|
-
const key =
|
|
224
|
+
const key = cloudCacheKey(path, buyerId);
|
|
123
225
|
const now = Date.now();
|
|
124
226
|
const cached = cache.get(key);
|
|
125
227
|
if (cached && cached.expiresAt > now) {
|
|
@@ -148,7 +250,7 @@ function freeTier(config) {
|
|
|
148
250
|
headers: resolveHeaders(),
|
|
149
251
|
body: JSON.stringify({ path, buyerId })
|
|
150
252
|
});
|
|
151
|
-
cache.delete(
|
|
253
|
+
cache.delete(cloudCacheKey(path, buyerId));
|
|
152
254
|
} catch {
|
|
153
255
|
}
|
|
154
256
|
}
|
|
@@ -164,15 +266,14 @@ function freeTier(config) {
|
|
|
164
266
|
return;
|
|
165
267
|
}
|
|
166
268
|
}
|
|
167
|
-
const paths = config.paths ?? ["*"];
|
|
168
269
|
await fetch(`${base}/v1/plugins/free-tier/config`, {
|
|
169
270
|
method: "PUT",
|
|
170
271
|
headers: resolveHeaders(),
|
|
171
272
|
body: JSON.stringify({
|
|
172
273
|
perBuyerLimit: config.perBuyerLimit,
|
|
173
|
-
resetPeriod: config.resetPeriod ?? "
|
|
274
|
+
resetPeriod: config.resetPeriod ?? "none",
|
|
174
275
|
globalCap: config.globalCap ?? null,
|
|
175
|
-
paths
|
|
276
|
+
paths: pluginPaths
|
|
176
277
|
})
|
|
177
278
|
});
|
|
178
279
|
} catch (err) {
|
|
@@ -181,21 +282,39 @@ function freeTier(config) {
|
|
|
181
282
|
}
|
|
182
283
|
return {
|
|
183
284
|
name: "free-tier",
|
|
285
|
+
mode: isLocal ? "local" : "cloud",
|
|
286
|
+
getUsageData() {
|
|
287
|
+
if (!isLocal) return null;
|
|
288
|
+
return exportLocalData();
|
|
289
|
+
},
|
|
184
290
|
async onInit() {
|
|
291
|
+
if (isLocal) {
|
|
292
|
+
console.log(`[relai:freeTier] Running in local mode (in-memory). perBuyerLimit=${limit}, resetPeriod=${resetPeriod}`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
185
295
|
await syncConfig();
|
|
186
296
|
},
|
|
187
297
|
async beforePaymentCheck(req, ctx) {
|
|
188
298
|
const requestPath = ctx.path || "/";
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
299
|
+
if (!pathMatches(requestPath)) return {};
|
|
300
|
+
const buyerId = resolveBuyerId(req);
|
|
301
|
+
if (isLocal) {
|
|
302
|
+
const result2 = localCheck(requestPath, buyerId);
|
|
303
|
+
if (result2.free) {
|
|
304
|
+
localRecord(requestPath, buyerId);
|
|
305
|
+
return {
|
|
306
|
+
skip: true,
|
|
307
|
+
headers: {
|
|
308
|
+
"X-Free-Calls-Remaining": String(result2.remaining ?? 0),
|
|
309
|
+
"X-Free-Calls-Total": String(result2.total ?? limit),
|
|
310
|
+
...result2.globalRemaining != null ? { "X-Free-Calls-Global-Remaining": String(result2.globalRemaining) } : {},
|
|
311
|
+
"X-Free-Tier-Mode": "local"
|
|
312
|
+
},
|
|
313
|
+
meta: { freeTier: true, buyerId, remaining: result2.remaining, total: result2.total ?? limit, mode: "local" }
|
|
314
|
+
};
|
|
315
|
+
}
|
|
196
316
|
return {};
|
|
197
317
|
}
|
|
198
|
-
const buyerId = resolveBuyerId(req);
|
|
199
318
|
const result = await checkFreeTier(requestPath, buyerId);
|
|
200
319
|
if (result.free) {
|
|
201
320
|
recordCall(requestPath, buyerId).catch(() => {
|
package/dist/plugins.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/plugins.ts"],"sourcesContent":["// src/plugins.ts\n// RelAI Plugin System - extensible middleware hooks for Relai.protect()\n\nimport type { RelaiNetwork } from './types';\nimport type { SettleResult } from './server';\n\nconst RELAI_API_BASE = 'https://api.relai.fi';\n\n// ============================================================================\n// Plugin Interface\n// ============================================================================\n\nexport interface PluginContext {\n /** Network for this endpoint */\n network: RelaiNetwork;\n /** Price in USD */\n price: number;\n /** Request path */\n path: string;\n /** HTTP method */\n method: string;\n}\n\nexport interface PluginResult {\n /** If true, skip payment and serve content for free */\n skip?: boolean;\n /** Extra response headers to set */\n headers?: Record<string, string>;\n /** Metadata attached to req.pluginMeta */\n meta?: Record<string, unknown>;\n}\n\nexport interface RelaiPlugin {\n /** Unique plugin name */\n name: string;\n\n /**\n * Called before the 402 payment check.\n * Return { skip: true } to bypass payment entirely.\n */\n beforePaymentCheck?(req: any, ctx: PluginContext): Promise<PluginResult>;\n\n /**\n * Called after a successful payment settlement.\n * Use for analytics, logging, webhooks, etc.\n */\n afterSettled?(req: any, result: SettleResult, ctx: PluginContext): Promise<void>;\n\n /**\n * Called once when the Relai instance initializes (server start).\n * Use to sync config to RelAI backend or validate credentials.\n */\n onInit?(): Promise<void>;\n\n /**\n * Called before sending the 402 response. Allows plugins to add\n * extensions or modify the response body (e.g. bridge info).\n */\n enrich402Response?(response: any, ctx: PluginContext): any;\n}\n\n// ============================================================================\n// Free Tier Plugin\n// ============================================================================\n\nexport interface FreeTierPluginConfig {\n /** Service key (sk_live_...) for authenticating with RelAI API */\n serviceKey: string;\n /** Max free calls per buyer per period */\n perBuyerLimit: number;\n /** Reset period for per-buyer counters */\n resetPeriod?: 'never' | 'daily' | 'monthly';\n /** Optional global cap across all buyers */\n globalCap?: number;\n /** Specific paths to apply free tier to (default: '*' = all) */\n paths?: string[];\n /** Override RelAI API base URL (default: https://api.relai.fi) */\n baseUrl?: string;\n /** Cache TTL in ms for check results (default: 5000) */\n cacheTtlMs?: number;\n}\n\ninterface FreeTierCheckResponse {\n free: boolean;\n remaining?: number;\n total?: number;\n reason?: string;\n globalRemaining?: number;\n}\n\n/**\n * Free Tier plugin - gives buyers a number of free API calls\n * before requiring payment.\n *\n * State is stored in the RelAI backend, keyed by your service key.\n * Config can be set here (SDK-side) or overridden in the relai.fi dashboard.\n *\n * @example\n * ```typescript\n * import Relai from '@relai-fi/x402/server';\n * import { freeTier } from '@relai-fi/x402/plugins';\n *\n * const relai = new Relai({\n * network: 'base',\n * plugins: [\n * freeTier({\n * serviceKey: process.env.RELAI_SERVICE_KEY!,\n * perBuyerLimit: 10,\n * resetPeriod: 'daily',\n * }),\n * ],\n * });\n *\n * app.get('/api/data', relai.protect({\n * payTo: '0xYourWallet',\n * price: 0.01,\n * }), (req, res) => {\n * res.json({ data: 'paid content' });\n * });\n * ```\n */\n// ============================================================================\n// Bridge Plugin\n// ============================================================================\n\nexport interface BridgePluginConfig {\n /** RelAI API base URL (default: https://api.relai.fi) */\n baseUrl?: string;\n /** Override settle endpoint (auto-discovered from /bridge/info if not set) */\n settleEndpoint?: string;\n /** Override supported source chains (auto-discovered if not set) */\n supportedSourceChains?: string[];\n /** Override supported source assets (auto-discovered if not set) */\n supportedSourceAssets?: string[];\n /** Override bridge payTo map: { [caip2]: address } */\n payTo?: Record<string, string>;\n /** Override Solana fee payer address (auto-discovered if not set) */\n feePayerSvm?: string;\n /** Override payment facilitator URL */\n paymentFacilitator?: string;\n /** Bridge fee in basis points (default: auto-discovered) */\n feeBps?: number;\n}\n\ninterface BridgeInfo {\n settleEndpoint: string;\n supportedSourceChains: string[];\n supportedSourceAssets: string[];\n payTo: Record<string, string>;\n feePayerSvm: string | null;\n feeBps: number;\n paymentFacilitator: string;\n}\n\n/**\n * Bridge plugin - enables cross-chain payments via the RelAI bridge.\n *\n * When a buyer's wallet is on a different chain than the merchant accepts,\n * the client SDK can automatically route the payment through the bridge.\n * This plugin adds `extensions.bridge` to the 402 response with all the\n * info the client needs to execute a cross-chain payment.\n *\n * @example\n * ```typescript\n * import Relai from '@relai-fi/x402/server';\n * import { bridge } from '@relai-fi/x402/plugins';\n *\n * const relai = new Relai({\n * network: 'skale-base',\n * plugins: [\n * bridge(), // auto-discovers from https://api.relai.fi\n * ],\n * });\n *\n * // Buyer on Solana can now pay for a SKALE endpoint\n * app.get('/api/data', relai.protect({\n * payTo: '0xYourWallet',\n * price: 0.05,\n * }), (req, res) => {\n * res.json({ data: 'paid content' });\n * });\n * ```\n */\nexport function bridge(config?: BridgePluginConfig): RelaiPlugin {\n const base = (config?.baseUrl ?? RELAI_API_BASE).replace(/\\/$/, '');\n let bridgeInfo: BridgeInfo | null = null;\n\n async function fetchBridgeInfo(): Promise<BridgeInfo | null> {\n try {\n const res = await fetch(`${base}/bridge/info`);\n if (!res.ok) {\n console.warn(`[relai:bridge] Failed to fetch /bridge/info: ${res.status}`);\n return null;\n }\n const data = await res.json() as any;\n return {\n settleEndpoint: config?.settleEndpoint || data.settleEndpoint,\n supportedSourceChains: config?.supportedSourceChains || data.supportedSourceChains || [],\n supportedSourceAssets: config?.supportedSourceAssets || data.supportedSourceAssets || [],\n payTo: config?.payTo || data.payTo || {},\n feePayerSvm: config?.feePayerSvm ?? data.feePayerSvm ?? null,\n feeBps: config?.feeBps ?? data.feeBps ?? 100,\n paymentFacilitator: config?.paymentFacilitator || data.paymentFacilitator || 'https://facilitator.x402.fi',\n };\n } catch (err) {\n console.warn(`[relai:bridge] Failed to fetch bridge info: ${err}`);\n return null;\n }\n }\n\n return {\n name: 'bridge',\n\n async onInit() {\n bridgeInfo = await fetchBridgeInfo();\n if (bridgeInfo) {\n console.log(`[relai:bridge] Initialized — ${bridgeInfo.supportedSourceChains.length} source chains, settle: ${bridgeInfo.settleEndpoint}`);\n } else {\n console.warn('[relai:bridge] Bridge info not available — cross-chain payments disabled');\n }\n },\n\n enrich402Response(response: any, ctx: PluginContext) {\n if (!bridgeInfo || bridgeInfo.supportedSourceChains.length === 0) {\n return response;\n }\n\n // Don't add bridge extension if merchant's network is already a source chain\n // and there's only that one chain — no bridging needed\n const merchantCaip2 = response?.accepts?.[0]?.network;\n\n // Filter out the merchant's own chain from source chains\n // (buyer on same chain should pay directly, not bridge)\n const otherSourceChains = bridgeInfo.supportedSourceChains.filter(\n (c: string) => c !== merchantCaip2,\n );\n\n if (otherSourceChains.length === 0) {\n return response;\n }\n\n // Find the payTo for the first available source chain (Solana-first for UX)\n const solanaChain = otherSourceChains.find((c: string) => c.startsWith('solana:'));\n const primaryPayTo = solanaChain\n ? bridgeInfo.payTo[solanaChain]\n : bridgeInfo.payTo[otherSourceChains[0]];\n\n response.extensions = response.extensions || {};\n response.extensions.bridge = {\n info: {\n settleEndpoint: bridgeInfo.settleEndpoint,\n supportedSourceChains: otherSourceChains,\n supportedSourceAssets: bridgeInfo.supportedSourceAssets,\n payTo: primaryPayTo || null,\n payToMap: bridgeInfo.payTo,\n feePayerSvm: bridgeInfo.feePayerSvm,\n feeBps: bridgeInfo.feeBps,\n paymentFacilitator: bridgeInfo.paymentFacilitator,\n },\n };\n\n return response;\n },\n };\n}\n\n// ============================================================================\n// Free Tier Plugin\n// ============================================================================\n\nexport function freeTier(config: FreeTierPluginConfig): RelaiPlugin {\n const base = (config.baseUrl ?? RELAI_API_BASE).replace(/\\/$/, '');\n const cacheTtl = config.cacheTtlMs ?? 5000;\n\n // Simple in-memory cache: \"serviceKey:path:buyerId\" -> { result, expiresAt }\n const cache = new Map<string, { result: FreeTierCheckResponse; expiresAt: number }>();\n\n function resolveHeaders(): Record<string, string> {\n return {\n 'X-Service-Key': config.serviceKey,\n 'Content-Type': 'application/json',\n };\n }\n\n /**\n * Resolve buyer identity from the request.\n * Priority: JWT sub > x-wallet-address > IP fallback\n */\n function resolveBuyerId(req: any): string {\n // 1. JWT Bearer token\n try {\n const auth = req.headers?.authorization || '';\n if (auth.startsWith('Bearer ')) {\n const token = auth.slice(7).trim();\n const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());\n if (payload?.sub) return `user:${payload.sub}`;\n }\n } catch { /* ignore */ }\n\n // 2. Explicit wallet header\n const wallet = req.headers?.['x-wallet-address'] || req.headers?.['x-buyer-address'];\n if (wallet) return `wallet:${wallet}`;\n\n // 3. IP fallback\n const ip =\n (req.headers?.['x-forwarded-for'] || '').split(',')[0].trim() ||\n req.socket?.remoteAddress ||\n req.ip ||\n 'unknown';\n return `ip:${ip}`;\n }\n\n function cacheKey(path: string, buyerId: string): string {\n return `${config.serviceKey}:${path}:${buyerId}`;\n }\n\n async function checkFreeTier(path: string, buyerId: string): Promise<FreeTierCheckResponse> {\n const key = cacheKey(path, buyerId);\n const now = Date.now();\n const cached = cache.get(key);\n if (cached && cached.expiresAt > now) {\n return cached.result;\n }\n\n try {\n const res = await fetch(`${base}/v1/plugins/free-tier/check`, {\n method: 'POST',\n headers: resolveHeaders(),\n body: JSON.stringify({ path, buyerId }),\n });\n\n if (!res.ok) {\n // Non-blocking: if API unreachable, default to not-free\n return { free: false, reason: `api_error_${res.status}` };\n }\n\n const result = await res.json() as FreeTierCheckResponse;\n cache.set(key, { result, expiresAt: now + cacheTtl });\n return result;\n } catch (err) {\n // Network error - non-blocking, default to paid\n return { free: false, reason: 'network_error' };\n }\n }\n\n async function recordCall(path: string, buyerId: string): Promise<void> {\n try {\n await fetch(`${base}/v1/plugins/free-tier/record`, {\n method: 'POST',\n headers: resolveHeaders(),\n body: JSON.stringify({ path, buyerId }),\n });\n // Invalidate cache for this buyer+path after recording\n cache.delete(cacheKey(path, buyerId));\n } catch {\n // Fire-and-forget\n }\n }\n\n async function syncConfig(): Promise<void> {\n try {\n // Check if config already exists on backend — don't overwrite dashboard-managed configs\n const existing = await fetch(`${base}/v1/plugins/free-tier/config`, {\n method: 'GET',\n headers: resolveHeaders(),\n });\n if (existing.ok) {\n const data = await existing.json();\n if (data.configs && data.configs.length > 0) {\n return; // Config managed via dashboard or previous sync — skip\n }\n }\n\n const paths = config.paths ?? ['*'];\n await fetch(`${base}/v1/plugins/free-tier/config`, {\n method: 'PUT',\n headers: resolveHeaders(),\n body: JSON.stringify({\n perBuyerLimit: config.perBuyerLimit,\n resetPeriod: config.resetPeriod ?? 'never',\n globalCap: config.globalCap ?? null,\n paths,\n }),\n });\n } catch (err) {\n console.warn(`[relai:freeTier] Failed to sync config to RelAI: ${err}`);\n }\n }\n\n return {\n name: 'free-tier',\n\n async onInit() {\n await syncConfig();\n },\n\n async beforePaymentCheck(req, ctx) {\n const requestPath = ctx.path || '/';\n\n // Check if this path is covered by the plugin\n const paths = config.paths ?? ['*'];\n const pathMatches = paths.includes('*') || paths.some((p) => {\n const normalized = p.toLowerCase().replace(/\\/+$/, '') || '/';\n const reqNormalized = requestPath.toLowerCase().replace(/\\/+$/, '') || '/';\n return normalized === reqNormalized || normalized === '*';\n });\n\n if (!pathMatches) {\n return {};\n }\n\n const buyerId = resolveBuyerId(req);\n const result = await checkFreeTier(requestPath, buyerId);\n\n if (result.free) {\n // Record the call (fire-and-forget)\n recordCall(requestPath, buyerId).catch(() => {});\n\n return {\n skip: true,\n headers: {\n 'X-Free-Calls-Remaining': String(result.remaining ?? 0),\n 'X-Free-Calls-Total': String(result.total ?? config.perBuyerLimit),\n ...(result.globalRemaining != null\n ? { 'X-Free-Calls-Global-Remaining': String(result.globalRemaining) }\n : {}),\n },\n meta: {\n freeTier: true,\n buyerId,\n remaining: result.remaining,\n total: result.total ?? config.perBuyerLimit,\n },\n };\n }\n\n return {};\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/plugins.ts"],"sourcesContent":["// src/plugins.ts\n// RelAI Plugin System - extensible middleware hooks for Relai.protect()\n\nimport crypto from 'crypto';\nimport type { RelaiNetwork } from './types';\nimport type { SettleResult } from './server';\n\nconst RELAI_API_BASE = 'https://api.relai.fi';\n\n// ============================================================================\n// Plugin Interface\n// ============================================================================\n\nexport interface PluginContext {\n /** Network for this endpoint */\n network: RelaiNetwork;\n /** Price in USD */\n price: number;\n /** Request path */\n path: string;\n /** HTTP method */\n method: string;\n}\n\nexport interface PluginResult {\n /** If true, skip payment and serve content for free */\n skip?: boolean;\n /** Extra response headers to set */\n headers?: Record<string, string>;\n /** Metadata attached to req.pluginMeta */\n meta?: Record<string, unknown>;\n}\n\nexport interface RelaiPlugin {\n /** Unique plugin name */\n name: string;\n\n /**\n * Called before the 402 payment check.\n * Return { skip: true } to bypass payment entirely.\n */\n beforePaymentCheck?(req: any, ctx: PluginContext): Promise<PluginResult>;\n\n /**\n * Called after a successful payment settlement.\n * Use for analytics, logging, webhooks, etc.\n */\n afterSettled?(req: any, result: SettleResult, ctx: PluginContext): Promise<void>;\n\n /**\n * Called once when the Relai instance initializes (server start).\n * Use to sync config to RelAI backend or validate credentials.\n */\n onInit?(): Promise<void>;\n\n /**\n * Called before sending the 402 response. Allows plugins to add\n * extensions or modify the response body (e.g. bridge info).\n */\n enrich402Response?(response: any, ctx: PluginContext): any;\n}\n\n// ============================================================================\n// Free Tier Plugin\n// ============================================================================\n\nexport interface FreeTierPluginConfig {\n /** Service key (sk_live_...) for syncing with RelAI backend. If omitted, runs in local in-memory mode. */\n serviceKey?: string;\n /** Max free calls per buyer per period */\n perBuyerLimit: number;\n /** Reset period for per-buyer counters */\n resetPeriod?: 'none' | 'daily' | 'monthly';\n /** Optional global cap across all buyers */\n globalCap?: number;\n /** Specific paths to apply free tier to (default: '*' = all) */\n paths?: string[];\n /** Override RelAI API base URL (default: https://api.relai.fi) */\n baseUrl?: string;\n /** Cache TTL in ms for check results (default: 5000) */\n cacheTtlMs?: number;\n}\n\ninterface FreeTierCheckResponse {\n free: boolean;\n remaining?: number;\n total?: number;\n reason?: string;\n globalRemaining?: number;\n}\n\n/**\n * Free Tier plugin - gives buyers a number of free API calls\n * before requiring payment.\n *\n * State is stored in the RelAI backend, keyed by your service key.\n * Config can be set here (SDK-side) or overridden in the relai.fi dashboard.\n *\n * @example\n * ```typescript\n * import Relai from '@relai-fi/x402/server';\n * import { freeTier } from '@relai-fi/x402/plugins';\n *\n * const relai = new Relai({\n * network: 'base',\n * plugins: [\n * freeTier({\n * serviceKey: process.env.RELAI_SERVICE_KEY!,\n * perBuyerLimit: 10,\n * resetPeriod: 'daily',\n * }),\n * ],\n * });\n *\n * app.get('/api/data', relai.protect({\n * payTo: '0xYourWallet',\n * price: 0.01,\n * }), (req, res) => {\n * res.json({ data: 'paid content' });\n * });\n * ```\n */\n// ============================================================================\n// Bridge Plugin\n// ============================================================================\n\nexport interface BridgePluginConfig {\n /** Service key (sk_live_...) for tracking bridge usage per API owner */\n serviceKey?: string;\n /** RelAI API base URL (default: https://api.relai.fi) */\n baseUrl?: string;\n /** Override settle endpoint (auto-discovered from /bridge/info if not set) */\n settleEndpoint?: string;\n /** Override supported source chains (auto-discovered if not set) */\n supportedSourceChains?: string[];\n /** Override supported source assets (auto-discovered if not set) */\n supportedSourceAssets?: string[];\n /** Override bridge payTo map: { [caip2]: address } */\n payTo?: Record<string, string>;\n /** Override Solana fee payer address (auto-discovered if not set) */\n feePayerSvm?: string;\n /** Override payment facilitator URL */\n paymentFacilitator?: string;\n /** Bridge fee in basis points (default: auto-discovered) */\n feeBps?: number;\n}\n\ninterface BridgeInfo {\n settleEndpoint: string;\n supportedSourceChains: string[];\n supportedSourceAssets: string[];\n payTo: Record<string, string>;\n feePayerSvm: string | null;\n feeBps: number;\n paymentFacilitator: string;\n}\n\n/**\n * Bridge plugin - enables cross-chain payments via the RelAI bridge.\n *\n * When a buyer's wallet is on a different chain than the merchant accepts,\n * the client SDK can automatically route the payment through the bridge.\n * This plugin adds `extensions.bridge` to the 402 response with all the\n * info the client needs to execute a cross-chain payment.\n *\n * @example\n * ```typescript\n * import Relai from '@relai-fi/x402/server';\n * import { bridge } from '@relai-fi/x402/plugins';\n *\n * const relai = new Relai({\n * network: 'skale-base',\n * plugins: [\n * bridge(), // auto-discovers from https://api.relai.fi\n * ],\n * });\n *\n * // Buyer on Solana can now pay for a SKALE endpoint\n * app.get('/api/data', relai.protect({\n * payTo: '0xYourWallet',\n * price: 0.05,\n * }), (req, res) => {\n * res.json({ data: 'paid content' });\n * });\n * ```\n */\nexport function bridge(config?: BridgePluginConfig): RelaiPlugin {\n const base = (config?.baseUrl ?? RELAI_API_BASE).replace(/\\/$/, '');\n let bridgeInfo: BridgeInfo | null = null;\n\n // Hash service key for tracking (never expose raw key to clients)\n // Must match backend: crypto.createHash(\"sha256\").update(key).digest(\"hex\").slice(0, 16)\n let serviceKeyHash: string | null = null;\n if (config?.serviceKey) {\n serviceKeyHash = crypto.createHash('sha256').update(config.serviceKey).digest('hex').slice(0, 16);\n }\n\n async function fetchBridgeInfo(): Promise<BridgeInfo | null> {\n try {\n const res = await fetch(`${base}/bridge/info`);\n if (!res.ok) {\n console.warn(`[relai:bridge] Failed to fetch /bridge/info: ${res.status}`);\n return null;\n }\n const data = await res.json() as any;\n return {\n settleEndpoint: config?.settleEndpoint || data.settleEndpoint,\n supportedSourceChains: config?.supportedSourceChains || data.supportedSourceChains || [],\n supportedSourceAssets: config?.supportedSourceAssets || data.supportedSourceAssets || [],\n payTo: config?.payTo || data.payTo || {},\n feePayerSvm: config?.feePayerSvm ?? data.feePayerSvm ?? null,\n feeBps: config?.feeBps ?? data.feeBps ?? 100,\n paymentFacilitator: config?.paymentFacilitator || data.paymentFacilitator || 'https://facilitator.x402.fi',\n };\n } catch (err) {\n console.warn(`[relai:bridge] Failed to fetch bridge info: ${err}`);\n return null;\n }\n }\n\n return {\n name: 'bridge',\n\n async onInit() {\n bridgeInfo = await fetchBridgeInfo();\n if (bridgeInfo) {\n console.log(`[relai:bridge] Initialized — ${bridgeInfo.supportedSourceChains.length} source chains, settle: ${bridgeInfo.settleEndpoint}`);\n } else {\n console.warn('[relai:bridge] Bridge info not available — cross-chain payments disabled');\n }\n },\n\n enrich402Response(response: any, ctx: PluginContext) {\n if (!bridgeInfo || bridgeInfo.supportedSourceChains.length === 0) {\n return response;\n }\n\n // Don't add bridge extension if merchant's network is already a source chain\n // and there's only that one chain — no bridging needed\n const merchantCaip2 = response?.accepts?.[0]?.network;\n\n // Filter out the merchant's own chain from source chains\n // (buyer on same chain should pay directly, not bridge)\n const otherSourceChains = bridgeInfo.supportedSourceChains.filter(\n (c: string) => c !== merchantCaip2,\n );\n\n if (otherSourceChains.length === 0) {\n return response;\n }\n\n // Find the payTo for the first available source chain (Solana-first for UX)\n const solanaChain = otherSourceChains.find((c: string) => c.startsWith('solana:'));\n const primaryPayTo = solanaChain\n ? bridgeInfo.payTo[solanaChain]\n : bridgeInfo.payTo[otherSourceChains[0]];\n\n response.extensions = response.extensions || {};\n response.extensions.bridge = {\n info: {\n settleEndpoint: bridgeInfo.settleEndpoint,\n supportedSourceChains: otherSourceChains,\n supportedSourceAssets: bridgeInfo.supportedSourceAssets,\n payTo: primaryPayTo || null,\n payToMap: bridgeInfo.payTo,\n feePayerSvm: bridgeInfo.feePayerSvm,\n feeBps: bridgeInfo.feeBps,\n paymentFacilitator: bridgeInfo.paymentFacilitator,\n ...(serviceKeyHash ? { serviceKeyHash } : {}),\n },\n };\n\n return response;\n },\n };\n}\n\n// ============================================================================\n// Free Tier Plugin\n// ============================================================================\n\n/**\n * Extended plugin interface with data export for free tier.\n */\nexport interface FreeTierPlugin extends RelaiPlugin {\n /** Export all in-memory usage data (local mode only). Returns null when using cloud mode. */\n getUsageData(): FreeTierUsageExport | null;\n /** Whether plugin is running in local (in-memory) or cloud (RelAI backend) mode. */\n readonly mode: 'local' | 'cloud';\n}\n\nexport interface FreeTierUsageExport {\n mode: 'local';\n config: {\n perBuyerLimit: number;\n resetPeriod: string;\n globalCap: number | null;\n paths: string[];\n };\n /** Per-buyer usage entries */\n usage: Array<{\n buyerId: string;\n path: string;\n count: number;\n periodStart: string;\n lastCall: string;\n }>;\n globalCount: number;\n exportedAt: string;\n}\n\nexport function freeTier(config: FreeTierPluginConfig): FreeTierPlugin {\n const base = (config.baseUrl ?? RELAI_API_BASE).replace(/\\/$/, '');\n const cacheTtl = config.cacheTtlMs ?? 5000;\n const isLocal = !config.serviceKey;\n const resetPeriod = config.resetPeriod ?? 'none';\n const limit = config.perBuyerLimit;\n const globalCap = config.globalCap ?? null;\n const pluginPaths = config.paths ?? ['*'];\n\n /**\n * Resolve buyer identity from the request.\n * Priority: JWT sub > x-wallet-address > IP fallback\n */\n function resolveBuyerId(req: any): string {\n // 1. JWT Bearer token\n try {\n const auth = req.headers?.authorization || '';\n if (auth.startsWith('Bearer ')) {\n const token = auth.slice(7).trim();\n const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());\n if (payload?.sub) return `user:${payload.sub}`;\n }\n } catch { /* ignore */ }\n\n // 2. Explicit wallet header\n const wallet = req.headers?.['x-wallet-address'] || req.headers?.['x-buyer-address'];\n if (wallet) return `wallet:${wallet}`;\n\n // 3. IP fallback\n const ip =\n (req.headers?.['x-forwarded-for'] || '').split(',')[0].trim() ||\n req.socket?.remoteAddress ||\n req.ip ||\n 'unknown';\n return `ip:${ip}`;\n }\n\n function pathMatches(requestPath: string): boolean {\n return pluginPaths.includes('*') || pluginPaths.some((p) => {\n const normalized = p.toLowerCase().replace(/\\/+$/, '') || '/';\n const reqNormalized = requestPath.toLowerCase().replace(/\\/+$/, '') || '/';\n return normalized === reqNormalized || normalized === '*';\n });\n }\n\n // ---------------------------------------------------------------------------\n // LOCAL IN-MEMORY MODE\n // ---------------------------------------------------------------------------\n\n interface LocalUsageEntry {\n count: number;\n periodStart: string;\n lastCall: string;\n }\n\n // Map key: \"path:buyerId\"\n const localUsage = new Map<string, LocalUsageEntry>();\n let localGlobalCount = 0;\n\n function currentPeriodStart(): string {\n const now = new Date();\n if (resetPeriod === 'daily') {\n return new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString();\n }\n if (resetPeriod === 'monthly') {\n return new Date(now.getFullYear(), now.getMonth(), 1).toISOString();\n }\n return 'none'; // permanent\n }\n\n function isCurrentPeriod(entry: LocalUsageEntry): boolean {\n if (resetPeriod === 'none') return true;\n return entry.periodStart === currentPeriodStart();\n }\n\n function localKey(path: string, buyerId: string): string {\n return `${path}:${buyerId}`;\n }\n\n function localCheck(path: string, buyerId: string): FreeTierCheckResponse {\n const key = localKey(path, buyerId);\n const entry = localUsage.get(key);\n\n // If entry is from a previous period, it's stale — treat as 0\n const count = (entry && isCurrentPeriod(entry)) ? entry.count : 0;\n const remaining = Math.max(0, limit - count);\n\n // Global cap check\n if (globalCap != null && localGlobalCount >= globalCap) {\n return { free: false, remaining: 0, total: limit, reason: 'global_cap_reached', globalRemaining: 0 };\n }\n\n if (count >= limit) {\n return { free: false, remaining: 0, total: limit, reason: 'limit_reached' };\n }\n\n return {\n free: true,\n remaining: remaining - 1, // after this call\n total: limit,\n ...(globalCap != null ? { globalRemaining: globalCap - localGlobalCount - 1 } : {}),\n };\n }\n\n function localRecord(path: string, buyerId: string): void {\n const key = localKey(path, buyerId);\n const period = currentPeriodStart();\n const entry = localUsage.get(key);\n const now = new Date().toISOString();\n\n if (entry && isCurrentPeriod(entry)) {\n entry.count++;\n entry.lastCall = now;\n } else {\n localUsage.set(key, { count: 1, periodStart: period, lastCall: now });\n }\n localGlobalCount++;\n }\n\n function exportLocalData(): FreeTierUsageExport {\n const usage: FreeTierUsageExport['usage'] = [];\n for (const [key, entry] of localUsage.entries()) {\n // key format is \"path:buyerId\" but buyerId itself can contain ':'\n // We stored it as `${path}:${buyerId}` where path starts with '/'\n // So we find the first ':' after the path portion\n const firstSlash = key.indexOf('/');\n const colonAfterPath = key.indexOf(':', firstSlash > -1 ? firstSlash : 0);\n const path = colonAfterPath > -1 ? key.slice(0, colonAfterPath) : key;\n const buyerId = colonAfterPath > -1 ? key.slice(colonAfterPath + 1) : 'unknown';\n usage.push({\n buyerId,\n path,\n count: entry.count,\n periodStart: entry.periodStart,\n lastCall: entry.lastCall,\n });\n }\n return {\n mode: 'local',\n config: { perBuyerLimit: limit, resetPeriod, globalCap, paths: pluginPaths },\n usage,\n globalCount: localGlobalCount,\n exportedAt: new Date().toISOString(),\n };\n }\n\n // ---------------------------------------------------------------------------\n // CLOUD MODE (original — requires serviceKey)\n // ---------------------------------------------------------------------------\n\n // Simple in-memory cache: \"serviceKey:path:buyerId\" -> { result, expiresAt }\n const cache = new Map<string, { result: FreeTierCheckResponse; expiresAt: number }>();\n\n function resolveHeaders(): Record<string, string> {\n return {\n 'X-Service-Key': config.serviceKey!,\n 'Content-Type': 'application/json',\n };\n }\n\n function cloudCacheKey(path: string, buyerId: string): string {\n return `${config.serviceKey}:${path}:${buyerId}`;\n }\n\n async function checkFreeTier(path: string, buyerId: string): Promise<FreeTierCheckResponse> {\n const key = cloudCacheKey(path, buyerId);\n const now = Date.now();\n const cached = cache.get(key);\n if (cached && cached.expiresAt > now) {\n return cached.result;\n }\n\n try {\n const res = await fetch(`${base}/v1/plugins/free-tier/check`, {\n method: 'POST',\n headers: resolveHeaders(),\n body: JSON.stringify({ path, buyerId }),\n });\n\n if (!res.ok) {\n // Non-blocking: if API unreachable, default to not-free\n return { free: false, reason: `api_error_${res.status}` };\n }\n\n const result = await res.json() as FreeTierCheckResponse;\n cache.set(key, { result, expiresAt: now + cacheTtl });\n return result;\n } catch (err) {\n // Network error - non-blocking, default to paid\n return { free: false, reason: 'network_error' };\n }\n }\n\n async function recordCall(path: string, buyerId: string): Promise<void> {\n try {\n await fetch(`${base}/v1/plugins/free-tier/record`, {\n method: 'POST',\n headers: resolveHeaders(),\n body: JSON.stringify({ path, buyerId }),\n });\n // Invalidate cache for this buyer+path after recording\n cache.delete(cloudCacheKey(path, buyerId));\n } catch {\n // Fire-and-forget\n }\n }\n\n async function syncConfig(): Promise<void> {\n try {\n // Check if config already exists on backend — don't overwrite dashboard-managed configs\n const existing = await fetch(`${base}/v1/plugins/free-tier/config`, {\n method: 'GET',\n headers: resolveHeaders(),\n });\n if (existing.ok) {\n const data = await existing.json();\n if (data.configs && data.configs.length > 0) {\n return; // Config managed via dashboard or previous sync — skip\n }\n }\n\n await fetch(`${base}/v1/plugins/free-tier/config`, {\n method: 'PUT',\n headers: resolveHeaders(),\n body: JSON.stringify({\n perBuyerLimit: config.perBuyerLimit,\n resetPeriod: config.resetPeriod ?? 'none',\n globalCap: config.globalCap ?? null,\n paths: pluginPaths,\n }),\n });\n } catch (err) {\n console.warn(`[relai:freeTier] Failed to sync config to RelAI: ${err}`);\n }\n }\n\n // ---------------------------------------------------------------------------\n // Plugin instance\n // ---------------------------------------------------------------------------\n\n return {\n name: 'free-tier',\n mode: isLocal ? 'local' : 'cloud',\n\n getUsageData(): FreeTierUsageExport | null {\n if (!isLocal) return null;\n return exportLocalData();\n },\n\n async onInit() {\n if (isLocal) {\n console.log(`[relai:freeTier] Running in local mode (in-memory). perBuyerLimit=${limit}, resetPeriod=${resetPeriod}`);\n return;\n }\n await syncConfig();\n },\n\n async beforePaymentCheck(req, ctx) {\n const requestPath = ctx.path || '/';\n if (!pathMatches(requestPath)) return {};\n\n const buyerId = resolveBuyerId(req);\n\n // ── Local mode ──\n if (isLocal) {\n const result = localCheck(requestPath, buyerId);\n if (result.free) {\n localRecord(requestPath, buyerId);\n return {\n skip: true,\n headers: {\n 'X-Free-Calls-Remaining': String(result.remaining ?? 0),\n 'X-Free-Calls-Total': String(result.total ?? limit),\n ...(result.globalRemaining != null\n ? { 'X-Free-Calls-Global-Remaining': String(result.globalRemaining) }\n : {}),\n 'X-Free-Tier-Mode': 'local',\n },\n meta: { freeTier: true, buyerId, remaining: result.remaining, total: result.total ?? limit, mode: 'local' },\n };\n }\n return {};\n }\n\n // ── Cloud mode ──\n const result = await checkFreeTier(requestPath, buyerId);\n\n if (result.free) {\n // Record the call (fire-and-forget)\n recordCall(requestPath, buyerId).catch(() => {});\n\n return {\n skip: true,\n headers: {\n 'X-Free-Calls-Remaining': String(result.remaining ?? 0),\n 'X-Free-Calls-Total': String(result.total ?? config.perBuyerLimit),\n ...(result.globalRemaining != null\n ? { 'X-Free-Calls-Global-Remaining': String(result.globalRemaining) }\n : {}),\n },\n meta: {\n freeTier: true,\n buyerId,\n remaining: result.remaining,\n total: result.total ?? config.perBuyerLimit,\n },\n };\n }\n\n return {};\n },\n };\n}\n"],"mappings":"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","names":["crypto","result"]}
|
package/dist/plugins.d.cts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import './types-Y9ni5XwY.cjs';
|
|
2
|
-
export { B as BridgePluginConfig, F as FreeTierPluginConfig, g as PluginContext, h as PluginResult, f as RelaiPlugin, i as bridge,
|
|
2
|
+
export { B as BridgePluginConfig, j as FreeTierPlugin, F as FreeTierPluginConfig, k as FreeTierUsageExport, g as PluginContext, h as PluginResult, f as RelaiPlugin, i as bridge, l as freeTier } from './server-CyfEHW9D.cjs';
|
package/dist/plugins.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import './types-Y9ni5XwY.js';
|
|
2
|
-
export { B as BridgePluginConfig, F as FreeTierPluginConfig, g as PluginContext, h as PluginResult, f as RelaiPlugin, i as bridge,
|
|
2
|
+
export { B as BridgePluginConfig, j as FreeTierPlugin, F as FreeTierPluginConfig, k as FreeTierUsageExport, g as PluginContext, h as PluginResult, f as RelaiPlugin, i as bridge, l as freeTier } from './server-CaSmhDnd.js';
|
package/dist/plugins.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
// src/plugins.ts
|
|
2
|
+
import crypto from "crypto";
|
|
2
3
|
var RELAI_API_BASE = "https://api.relai.fi";
|
|
3
4
|
function bridge(config) {
|
|
4
5
|
const base = (config?.baseUrl ?? RELAI_API_BASE).replace(/\/$/, "");
|
|
5
6
|
let bridgeInfo = null;
|
|
7
|
+
let serviceKeyHash = null;
|
|
8
|
+
if (config?.serviceKey) {
|
|
9
|
+
serviceKeyHash = crypto.createHash("sha256").update(config.serviceKey).digest("hex").slice(0, 16);
|
|
10
|
+
}
|
|
6
11
|
async function fetchBridgeInfo() {
|
|
7
12
|
try {
|
|
8
13
|
const res = await fetch(`${base}/bridge/info`);
|
|
@@ -58,7 +63,8 @@ function bridge(config) {
|
|
|
58
63
|
payToMap: bridgeInfo.payTo,
|
|
59
64
|
feePayerSvm: bridgeInfo.feePayerSvm,
|
|
60
65
|
feeBps: bridgeInfo.feeBps,
|
|
61
|
-
paymentFacilitator: bridgeInfo.paymentFacilitator
|
|
66
|
+
paymentFacilitator: bridgeInfo.paymentFacilitator,
|
|
67
|
+
...serviceKeyHash ? { serviceKeyHash } : {}
|
|
62
68
|
}
|
|
63
69
|
};
|
|
64
70
|
return response;
|
|
@@ -68,13 +74,11 @@ function bridge(config) {
|
|
|
68
74
|
function freeTier(config) {
|
|
69
75
|
const base = (config.baseUrl ?? RELAI_API_BASE).replace(/\/$/, "");
|
|
70
76
|
const cacheTtl = config.cacheTtlMs ?? 5e3;
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
};
|
|
77
|
-
}
|
|
77
|
+
const isLocal = !config.serviceKey;
|
|
78
|
+
const resetPeriod = config.resetPeriod ?? "none";
|
|
79
|
+
const limit = config.perBuyerLimit;
|
|
80
|
+
const globalCap = config.globalCap ?? null;
|
|
81
|
+
const pluginPaths = config.paths ?? ["*"];
|
|
78
82
|
function resolveBuyerId(req) {
|
|
79
83
|
try {
|
|
80
84
|
const auth = req.headers?.authorization || "";
|
|
@@ -90,11 +94,99 @@ function freeTier(config) {
|
|
|
90
94
|
const ip = (req.headers?.["x-forwarded-for"] || "").split(",")[0].trim() || req.socket?.remoteAddress || req.ip || "unknown";
|
|
91
95
|
return `ip:${ip}`;
|
|
92
96
|
}
|
|
93
|
-
function
|
|
97
|
+
function pathMatches(requestPath) {
|
|
98
|
+
return pluginPaths.includes("*") || pluginPaths.some((p) => {
|
|
99
|
+
const normalized = p.toLowerCase().replace(/\/+$/, "") || "/";
|
|
100
|
+
const reqNormalized = requestPath.toLowerCase().replace(/\/+$/, "") || "/";
|
|
101
|
+
return normalized === reqNormalized || normalized === "*";
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const localUsage = /* @__PURE__ */ new Map();
|
|
105
|
+
let localGlobalCount = 0;
|
|
106
|
+
function currentPeriodStart() {
|
|
107
|
+
const now = /* @__PURE__ */ new Date();
|
|
108
|
+
if (resetPeriod === "daily") {
|
|
109
|
+
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString();
|
|
110
|
+
}
|
|
111
|
+
if (resetPeriod === "monthly") {
|
|
112
|
+
return new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
|
|
113
|
+
}
|
|
114
|
+
return "none";
|
|
115
|
+
}
|
|
116
|
+
function isCurrentPeriod(entry) {
|
|
117
|
+
if (resetPeriod === "none") return true;
|
|
118
|
+
return entry.periodStart === currentPeriodStart();
|
|
119
|
+
}
|
|
120
|
+
function localKey(path, buyerId) {
|
|
121
|
+
return `${path}:${buyerId}`;
|
|
122
|
+
}
|
|
123
|
+
function localCheck(path, buyerId) {
|
|
124
|
+
const key = localKey(path, buyerId);
|
|
125
|
+
const entry = localUsage.get(key);
|
|
126
|
+
const count = entry && isCurrentPeriod(entry) ? entry.count : 0;
|
|
127
|
+
const remaining = Math.max(0, limit - count);
|
|
128
|
+
if (globalCap != null && localGlobalCount >= globalCap) {
|
|
129
|
+
return { free: false, remaining: 0, total: limit, reason: "global_cap_reached", globalRemaining: 0 };
|
|
130
|
+
}
|
|
131
|
+
if (count >= limit) {
|
|
132
|
+
return { free: false, remaining: 0, total: limit, reason: "limit_reached" };
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
free: true,
|
|
136
|
+
remaining: remaining - 1,
|
|
137
|
+
// after this call
|
|
138
|
+
total: limit,
|
|
139
|
+
...globalCap != null ? { globalRemaining: globalCap - localGlobalCount - 1 } : {}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function localRecord(path, buyerId) {
|
|
143
|
+
const key = localKey(path, buyerId);
|
|
144
|
+
const period = currentPeriodStart();
|
|
145
|
+
const entry = localUsage.get(key);
|
|
146
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
147
|
+
if (entry && isCurrentPeriod(entry)) {
|
|
148
|
+
entry.count++;
|
|
149
|
+
entry.lastCall = now;
|
|
150
|
+
} else {
|
|
151
|
+
localUsage.set(key, { count: 1, periodStart: period, lastCall: now });
|
|
152
|
+
}
|
|
153
|
+
localGlobalCount++;
|
|
154
|
+
}
|
|
155
|
+
function exportLocalData() {
|
|
156
|
+
const usage = [];
|
|
157
|
+
for (const [key, entry] of localUsage.entries()) {
|
|
158
|
+
const firstSlash = key.indexOf("/");
|
|
159
|
+
const colonAfterPath = key.indexOf(":", firstSlash > -1 ? firstSlash : 0);
|
|
160
|
+
const path = colonAfterPath > -1 ? key.slice(0, colonAfterPath) : key;
|
|
161
|
+
const buyerId = colonAfterPath > -1 ? key.slice(colonAfterPath + 1) : "unknown";
|
|
162
|
+
usage.push({
|
|
163
|
+
buyerId,
|
|
164
|
+
path,
|
|
165
|
+
count: entry.count,
|
|
166
|
+
periodStart: entry.periodStart,
|
|
167
|
+
lastCall: entry.lastCall
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
mode: "local",
|
|
172
|
+
config: { perBuyerLimit: limit, resetPeriod, globalCap, paths: pluginPaths },
|
|
173
|
+
usage,
|
|
174
|
+
globalCount: localGlobalCount,
|
|
175
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const cache = /* @__PURE__ */ new Map();
|
|
179
|
+
function resolveHeaders() {
|
|
180
|
+
return {
|
|
181
|
+
"X-Service-Key": config.serviceKey,
|
|
182
|
+
"Content-Type": "application/json"
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function cloudCacheKey(path, buyerId) {
|
|
94
186
|
return `${config.serviceKey}:${path}:${buyerId}`;
|
|
95
187
|
}
|
|
96
188
|
async function checkFreeTier(path, buyerId) {
|
|
97
|
-
const key =
|
|
189
|
+
const key = cloudCacheKey(path, buyerId);
|
|
98
190
|
const now = Date.now();
|
|
99
191
|
const cached = cache.get(key);
|
|
100
192
|
if (cached && cached.expiresAt > now) {
|
|
@@ -123,7 +215,7 @@ function freeTier(config) {
|
|
|
123
215
|
headers: resolveHeaders(),
|
|
124
216
|
body: JSON.stringify({ path, buyerId })
|
|
125
217
|
});
|
|
126
|
-
cache.delete(
|
|
218
|
+
cache.delete(cloudCacheKey(path, buyerId));
|
|
127
219
|
} catch {
|
|
128
220
|
}
|
|
129
221
|
}
|
|
@@ -139,15 +231,14 @@ function freeTier(config) {
|
|
|
139
231
|
return;
|
|
140
232
|
}
|
|
141
233
|
}
|
|
142
|
-
const paths = config.paths ?? ["*"];
|
|
143
234
|
await fetch(`${base}/v1/plugins/free-tier/config`, {
|
|
144
235
|
method: "PUT",
|
|
145
236
|
headers: resolveHeaders(),
|
|
146
237
|
body: JSON.stringify({
|
|
147
238
|
perBuyerLimit: config.perBuyerLimit,
|
|
148
|
-
resetPeriod: config.resetPeriod ?? "
|
|
239
|
+
resetPeriod: config.resetPeriod ?? "none",
|
|
149
240
|
globalCap: config.globalCap ?? null,
|
|
150
|
-
paths
|
|
241
|
+
paths: pluginPaths
|
|
151
242
|
})
|
|
152
243
|
});
|
|
153
244
|
} catch (err) {
|
|
@@ -156,21 +247,39 @@ function freeTier(config) {
|
|
|
156
247
|
}
|
|
157
248
|
return {
|
|
158
249
|
name: "free-tier",
|
|
250
|
+
mode: isLocal ? "local" : "cloud",
|
|
251
|
+
getUsageData() {
|
|
252
|
+
if (!isLocal) return null;
|
|
253
|
+
return exportLocalData();
|
|
254
|
+
},
|
|
159
255
|
async onInit() {
|
|
256
|
+
if (isLocal) {
|
|
257
|
+
console.log(`[relai:freeTier] Running in local mode (in-memory). perBuyerLimit=${limit}, resetPeriod=${resetPeriod}`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
160
260
|
await syncConfig();
|
|
161
261
|
},
|
|
162
262
|
async beforePaymentCheck(req, ctx) {
|
|
163
263
|
const requestPath = ctx.path || "/";
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
264
|
+
if (!pathMatches(requestPath)) return {};
|
|
265
|
+
const buyerId = resolveBuyerId(req);
|
|
266
|
+
if (isLocal) {
|
|
267
|
+
const result2 = localCheck(requestPath, buyerId);
|
|
268
|
+
if (result2.free) {
|
|
269
|
+
localRecord(requestPath, buyerId);
|
|
270
|
+
return {
|
|
271
|
+
skip: true,
|
|
272
|
+
headers: {
|
|
273
|
+
"X-Free-Calls-Remaining": String(result2.remaining ?? 0),
|
|
274
|
+
"X-Free-Calls-Total": String(result2.total ?? limit),
|
|
275
|
+
...result2.globalRemaining != null ? { "X-Free-Calls-Global-Remaining": String(result2.globalRemaining) } : {},
|
|
276
|
+
"X-Free-Tier-Mode": "local"
|
|
277
|
+
},
|
|
278
|
+
meta: { freeTier: true, buyerId, remaining: result2.remaining, total: result2.total ?? limit, mode: "local" }
|
|
279
|
+
};
|
|
280
|
+
}
|
|
171
281
|
return {};
|
|
172
282
|
}
|
|
173
|
-
const buyerId = resolveBuyerId(req);
|
|
174
283
|
const result = await checkFreeTier(requestPath, buyerId);
|
|
175
284
|
if (result.free) {
|
|
176
285
|
recordCall(requestPath, buyerId).catch(() => {
|