@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/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 cache = /* @__PURE__ */ new Map();
97
- function resolveHeaders() {
98
- return {
99
- "X-Service-Key": config.serviceKey,
100
- "Content-Type": "application/json"
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 cacheKey(path, buyerId) {
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 = cacheKey(path, buyerId);
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(cacheKey(path, buyerId));
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 ?? "never",
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
- const paths = config.paths ?? ["*"];
190
- const pathMatches = paths.includes("*") || paths.some((p) => {
191
- const normalized = p.toLowerCase().replace(/\/+$/, "") || "/";
192
- const reqNormalized = requestPath.toLowerCase().replace(/\/+$/, "") || "/";
193
- return normalized === reqNormalized || normalized === "*";
194
- });
195
- if (!pathMatches) {
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(() => {
@@ -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"]}
@@ -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, j as freeTier } from './server-DPYBh7fy.cjs';
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, j as freeTier } from './server-DJGM7dFH.js';
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 cache = /* @__PURE__ */ new Map();
72
- function resolveHeaders() {
73
- return {
74
- "X-Service-Key": config.serviceKey,
75
- "Content-Type": "application/json"
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 cacheKey(path, buyerId) {
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 = cacheKey(path, buyerId);
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(cacheKey(path, buyerId));
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 ?? "never",
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
- const paths = config.paths ?? ["*"];
165
- const pathMatches = paths.includes("*") || paths.some((p) => {
166
- const normalized = p.toLowerCase().replace(/\/+$/, "") || "/";
167
- const reqNormalized = requestPath.toLowerCase().replace(/\/+$/, "") || "/";
168
- return normalized === reqNormalized || normalized === "*";
169
- });
170
- if (!pathMatches) {
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(() => {