@peaje/proxy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # @peaje/proxy
2
+
3
+ Peaje for any server. It makes your own domain answer HTTP 402 (MPP, x402) for the priced API routes in your Peaje dashboard, and serves your agent catalog (`/llms.txt`, `/openapi.json`, `/mcp`, `/.well-known/*`) and `/r/` priced links from the same domain.
4
+
5
+ It reads the route list from your gateway at runtime (`/{slug}/kit/manifest.json`, cached for 60 seconds), so a route you publish tomorrow is charged without a redeploy. Everything else goes to your app untouched.
6
+
7
+ For Next.js use [`@peaje/next`](../peaje-next) instead. `npx @peaje/cli@1 init <slug>` installs either one for you.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install @peaje/proxy
13
+ ```
14
+
15
+ Node 20 or newer, or any runtime with `fetch`, `Request` and `Response`.
16
+
17
+ ## The secret
18
+
19
+ Set `PEAJE_ORIGIN_SECRET` in your hosting environment variables. Copy it from the Kit page of your Peaje dashboard.
20
+
21
+ After an agent pays, the gateway forwards the request to your site with that secret in the `x-peaje-origin` header, and only then does the proxy let it through to your handler. Without it the proxy cannot tell the gateway apart from someone faking the header, so priced API routes are not forwarded (they would be free) and you get one warning in the logs. Discovery files and `/r/` links work anyway.
22
+
23
+ You can also pass it as an option: `{ slug, secret }`.
24
+
25
+ ## Express
26
+
27
+ ```js
28
+ import express from 'express'
29
+ import { peajeExpress } from '@peaje/proxy/express'
30
+
31
+ const app = express()
32
+ app.use(peajeExpress({ slug: 'cafe-andino' })) // first: before express.json() and your routes
33
+ app.use(express.json())
34
+
35
+ app.get('/api/forecast', (req, res) => res.json({ tempC: 18 }))
36
+ ```
37
+
38
+ CommonJS: `const { peajeExpress } = require('@peaje/proxy/express')`. Works with Express 4 and 5 and plain Connect. Put it before body parsers: the body of a Peaje request is forwarded raw. If a parser already ran, it forwards `req.body` as JSON.
39
+
40
+ ## Hono
41
+
42
+ ```ts
43
+ import { Hono } from 'hono'
44
+ import { peajeHono } from '@peaje/proxy/hono'
45
+
46
+ const app = new Hono()
47
+ app.use(peajeHono({ slug: 'cafe-andino' }))
48
+
49
+ app.get('/api/forecast', (c) => c.json({ tempC: 18 }))
50
+ ```
51
+
52
+ On Cloudflare Workers the secret is read from `c.env.PEAJE_ORIGIN_SECRET` (`wrangler secret put PEAJE_ORIGIN_SECRET`). On Node, Bun and Deno it comes from `process.env`.
53
+
54
+ ## Vite (or any non-Next project) on Vercel
55
+
56
+ Vercel Routing Middleware runs before your static files, so a Vite SPA gets 402s on its own domain without a server. Create `middleware.ts` at the project root, next to `package.json`:
57
+
58
+ ```ts
59
+ import peaje from '@peaje/proxy/vercel'
60
+
61
+ export default peaje({ slug: 'cafe-andino' })
62
+
63
+ export const config = {
64
+ matcher: ['/((?!assets/|favicon.ico).*)'],
65
+ }
66
+ ```
67
+
68
+ It answers with the same headers that `rewrite()` and `next()` from `@vercel/functions` produce, so there is nothing else to install: Vercel rewrites the request to the gateway itself (method, body and query intact), and your middleware never proxies bytes. With a middleware of your own: `peaje({ slug }, yourMiddleware)`; Peaje runs first and yours runs when the request is not Peaje's.
69
+
70
+ ## Anything else (Astro, SvelteKit, Bun, Deno, Workers)
71
+
72
+ `peajeFetch` takes a `Request` and returns the gateway's `Response`, or `null` when the request is not Peaje's:
73
+
74
+ ```ts
75
+ import { peajeFetch } from '@peaje/proxy'
76
+
77
+ const peaje = peajeFetch({ slug: 'cafe-andino' })
78
+
79
+ // Astro (SSR), src/middleware.ts
80
+ export const onRequest = async (context, next) => (await peaje(context.request)) ?? next()
81
+
82
+ // SvelteKit, src/hooks.server.ts
83
+ export const handle = async ({ event, resolve }) => (await peaje(event.request)) ?? resolve(event)
84
+
85
+ // Bun / Deno / Workers
86
+ export default { fetch: async (req: Request) => (await peaje(req)) ?? app(req) }
87
+ ```
88
+
89
+ ## What gets forwarded
90
+
91
+ - The catalog paths: `/llms.txt`, `/openapi.json`, `/mcp`, `/.well-known/*` Peaje files, `/developers`, `/docs` and the rest of the kit list.
92
+ - `/r/*` priced links and `/checkout_sessions/*`.
93
+ - Your priced API routes from the dashboard (`GET /api/forecast`, `/api/history/:city`, `/api/batch/*`), matching the method. Catch-alls at the root (`/*`, `/:id`) are never forwarded from your domain: they would send the whole site to the gateway.
94
+
95
+ The forwarded request keeps method, body and query, and carries `x-forwarded-host` and `x-forwarded-proto` so the 402 challenge names your domain (strict x402 clients abort when it does not). If the gateway is unreachable, the catalog paths are still forwarded from a built-in list; priced API routes resume when the manifest loads.
96
+
97
+ ## Options
98
+
99
+ | Option | Default |
100
+ |---|---|
101
+ | `slug` | required, the business slug from your dashboard |
102
+ | `secret` | `process.env.PEAJE_ORIGIN_SECRET` |
103
+ | `gateway` | `process.env.PEAJE_GATEWAY_URL` or `https://peaje-gateway.up.railway.app` |
104
+
105
+ ## License
106
+
107
+ MIT
@@ -0,0 +1,345 @@
1
+ // ../shared/src/kit/rutas.ts
2
+ var RUTAS_PROXY = [
3
+ { path: "/llms.txt", nota: "gu\xEDa para agentes (fusionada con la tuya)" },
4
+ { path: "/pricing.md", nota: "precios por endpoint" },
5
+ { path: "/auth.md", nota: 'c\xF3mo pagar (el "auth" ac\xE1 es el pago)' },
6
+ { path: "/agents.md", nota: "qu\xE9 es y cu\xE1ndo usarte" },
7
+ { path: "/openapi.json", nota: "discovery con precios" },
8
+ { path: "/mcp", nota: "MCP server con tools pagas, en TU dominio" },
9
+ { path: "/.well-known/mcp", nota: "el MCP tambi\xE9n ac\xE1: es donde lo buscan los auditores" },
10
+ { path: "/.well-known/ard.json", nota: "cat\xE1logo ARD (path can\xF3nico)" },
11
+ { path: "/.well-known/ai-catalog.json", nota: "cat\xE1logo ARD (alias legacy)" },
12
+ { path: "/.well-known/api-catalog", nota: "linkset RFC 9727" },
13
+ { path: "/.well-known/agent-card.json", nota: "agent card A2A" },
14
+ { path: "/.well-known/mcp/server-card.json", nota: "server card MCP" },
15
+ { path: "/discovery/resources", nota: "lista x402 Bazaar: lo que cobra 402 de verdad" },
16
+ { path: "/.well-known/ucp", nota: "perfil UCP: qu\xE9 sabe hacer tu negocio" },
17
+ { path: "/checkout_sessions", nota: "checkout ACP (el de OpenAI)" },
18
+ { path: "/agentic_commerce/delegate_payment", nota: "delegate payment ACP" },
19
+ { path: "/developers", nota: "portal de desarrolladores, renderizado con tus precios" },
20
+ { path: "/.well-known/peaje-indexnow.txt", nota: "clave IndexNow: avisa a Bing y Copilot cuando cambian precios" },
21
+ // Los auditores prueban /docs y /developers; el gateway redirige uno al otro.
22
+ { path: "/docs", nota: "alias del portal: los auditores prueban ambos" }
23
+ ];
24
+ var RUTAS_DINAMICAS = [
25
+ { path: "/r/:slug", prefijo: "/r/", nota: "links con precio: el 402 en tu dominio" },
26
+ { path: "/checkout_sessions/:id", prefijo: "/checkout_sessions/", nota: "leer una sesi\xF3n de checkout ACP" }
27
+ ];
28
+ var PEAJE_FETCH_HEADER = "x-peaje-fetch";
29
+
30
+ // ../shared/src/kit/proxy-runtime.ts
31
+ var PEAJE_ORIGIN_HEADER = "x-peaje-origin";
32
+ function iguales(a, b) {
33
+ if (a.length !== b.length) return false;
34
+ let d = 0;
35
+ for (let i = 0; i < a.length; i++) d |= a.charCodeAt(i) ^ b.charCodeAt(i);
36
+ return d === 0;
37
+ }
38
+ function coincidePatron(patron, pathname) {
39
+ const p = patron.split("/").filter(Boolean);
40
+ const parts = segmentosCanonicos(pathname);
41
+ for (let i = 0; i < p.length; i++) {
42
+ const seg = p[i];
43
+ if (seg === "*") return true;
44
+ const v = parts[i];
45
+ if (v === void 0) return false;
46
+ if (seg.startsWith(":")) continue;
47
+ if (seg.toLowerCase() !== v.toLowerCase()) return false;
48
+ }
49
+ return p.length === parts.length;
50
+ }
51
+ function segmentosCanonicos(pathname) {
52
+ const out = [];
53
+ for (const crudo of pathname.split(/[\\/]+/)) {
54
+ if (!crudo) continue;
55
+ let seg = crudo;
56
+ try {
57
+ seg = decodeURIComponent(crudo);
58
+ } catch {
59
+ }
60
+ for (const s of seg.split(/[\\/]+/)) {
61
+ if (!s || s === ".") continue;
62
+ if (s === "..") out.pop();
63
+ else out.push(s);
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+ var pathCanonico = (pathname) => `/${segmentosCanonicos(pathname).join("/")}`;
69
+ function patronReenviable(patron) {
70
+ const segs = patron.split("/").filter(Boolean);
71
+ if (segs.length === 0) return false;
72
+ return !(segs.length === 1 && (segs[0] === "*" || segs[0].startsWith(":")));
73
+ }
74
+ function crearProxyRuntime(opciones) {
75
+ const base = opciones.base.replace(/\/+$/, "");
76
+ const ttl = opciones.ttlMs ?? 6e4;
77
+ const fetchImpl = opciones.fetchImpl ?? fetch;
78
+ let cache = {
79
+ at: 0,
80
+ rutas: opciones.respaldo?.rutas ?? [],
81
+ prefijos: opciones.respaldo?.prefijos ?? [],
82
+ patrones: opciones.respaldo?.patrones ?? []
83
+ };
84
+ let refrescando = null;
85
+ async function refrescar() {
86
+ try {
87
+ const res = await fetchImpl(`${base}/kit/manifest.json`, { signal: AbortSignal.timeout(5e3) });
88
+ if (!res.ok) throw new Error(String(res.status));
89
+ const m = await res.json();
90
+ cache = {
91
+ at: Date.now(),
92
+ rutas: Array.isArray(m.rutas) ? m.rutas : cache.rutas,
93
+ prefijos: Array.isArray(m.prefijos) ? m.prefijos : cache.prefijos,
94
+ patrones: Array.isArray(m.patrones) ? m.patrones : cache.patrones
95
+ };
96
+ } catch {
97
+ cache = { ...cache, at: Date.now() };
98
+ } finally {
99
+ refrescando = null;
100
+ }
101
+ }
102
+ async function listas() {
103
+ const vencido = Date.now() - cache.at >= ttl;
104
+ if (vencido) {
105
+ refrescando ??= refrescar();
106
+ if (cache.at === 0) await refrescando;
107
+ }
108
+ return cache;
109
+ }
110
+ return {
111
+ /** Rutas de API con precio que este sitio no puede cobrar por falta de secreto. */
112
+ async sinSecreto() {
113
+ if (opciones.secreto) return [];
114
+ return (await listas()).patrones.filter((p) => patronReenviable(p.path));
115
+ },
116
+ /** Fuerza la carga del manifiesto (útil al arrancar o en tests). */
117
+ cargar: () => refrescando ??= refrescar(),
118
+ /**
119
+ * Decide si la request va al gateway. `method` y `pathname` de la request,
120
+ * `search` con el `?` incluido, y los headers para cortar bucles.
121
+ */
122
+ async decidir(input) {
123
+ const secreto = opciones.secreto || null;
124
+ const prueba = input.headers.get(PEAJE_ORIGIN_HEADER);
125
+ if (secreto && prueba && iguales(prueba, secreto)) return null;
126
+ const { rutas, prefijos, patrones } = await listas();
127
+ const path = input.pathname;
128
+ const canon = pathCanonico(path).toLowerCase();
129
+ const esFetchDePeaje = input.headers.get(PEAJE_FETCH_HEADER) !== null;
130
+ const fija = (rutas.includes(path) || rutas.some((r) => r.toLowerCase() === canon)) && !(canon === "/llms.txt" && esFetchDePeaje);
131
+ const prefijo = prefijos.some((p) => path.startsWith(p) || canon.startsWith(p.toLowerCase()));
132
+ const method = input.method.toUpperCase();
133
+ const patron = secreto !== null && patrones.some((p) => p.method.toUpperCase() === method && patronReenviable(p.path) && coincidePatron(p.path, path));
134
+ if (!fija && !prefijo && !patron) return null;
135
+ return { destino: `${base}${path}${input.search ?? ""}` };
136
+ }
137
+ };
138
+ }
139
+
140
+ // ../shared/src/index.ts
141
+ var TEMPO = {
142
+ mainnet: {
143
+ chainId: 4217,
144
+ rpcUrl: "https://rpc.tempo.xyz",
145
+ explorerUrl: "https://explore.tempo.xyz"
146
+ },
147
+ testnet: {
148
+ chainId: 42431,
149
+ rpcUrl: "https://rpc.moderato.tempo.xyz",
150
+ explorerUrl: "https://explore.testnet.tempo.xyz"
151
+ }
152
+ };
153
+ var TOKENS = {
154
+ pathUsd: "0x20c0000000000000000000000000000000000000",
155
+ usdc: "0x20C000000000000000000000b9537d11c60E8b50"
156
+ };
157
+ var DEFAULT_CURRENCY = TOKENS.pathUsd;
158
+ var NETWORKS = {
159
+ tempo: {
160
+ id: "tempo",
161
+ label: "Tempo",
162
+ token: TOKENS.pathUsd,
163
+ tokenSymbol: "pathUSD",
164
+ decimals: 6,
165
+ mainnet: TEMPO.mainnet,
166
+ testnet: TEMPO.testnet
167
+ },
168
+ arc: {
169
+ id: "arc",
170
+ label: "Arc",
171
+ // Interfaz ERC-20 del USDC nativo de Arc; el gas de la red se paga en ese mismo USDC.
172
+ token: "0x3600000000000000000000000000000000000000",
173
+ tokenSymbol: "USDC",
174
+ decimals: 6,
175
+ // Leído on-chain del contrato: name() = "USDC", version() = "2".
176
+ eip3009: { name: "USDC", version: "2" },
177
+ // Arc no publica direcciones de mainnet todavía (sept 2026).
178
+ mainnet: null,
179
+ testnet: {
180
+ chainId: 5042002,
181
+ rpcUrl: "https://rpc.testnet.arc.io",
182
+ explorerUrl: "https://testnet.arcscan.app"
183
+ }
184
+ },
185
+ arbitrum: {
186
+ id: "arbitrum",
187
+ label: "Arbitrum",
188
+ // USDC de Circle en Arbitrum Sepolia. En Arbitrum One vive en otra dirección,
189
+ // por eso mainnet queda en null hasta que el gateway opere allá.
190
+ token: "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d",
191
+ tokenSymbol: "USDC",
192
+ decimals: 6,
193
+ // Leído on-chain: name() = "USD Coin", version() = "2" (distinto al USDC de Arc).
194
+ eip3009: { name: "USD Coin", version: "2" },
195
+ mainnet: null,
196
+ testnet: {
197
+ chainId: 421614,
198
+ rpcUrl: "https://sepolia-rollup.arbitrum.io/rpc",
199
+ explorerUrl: "https://sepolia.arbiscan.io"
200
+ }
201
+ },
202
+ "arbitrum-usdg": {
203
+ id: "arbitrum-usdg",
204
+ label: "Arbitrum \xB7 USDG",
205
+ // Mismo contrato y misma cadena que el riel USDC: cambia el stablecoin.
206
+ // Un riel es (cadena, token), y el saldo del negocio se lleva por token.
207
+ token: "0xFFC95faa3d63Cde504a05B567C600B78C0b41892",
208
+ tokenSymbol: "USDG",
209
+ decimals: 6,
210
+ eip3009: { name: "Global Dollar", version: "1" },
211
+ mainnet: null,
212
+ testnet: {
213
+ chainId: 421614,
214
+ rpcUrl: "https://sepolia-rollup.arbitrum.io/rpc",
215
+ explorerUrl: "https://sepolia.arbiscan.io"
216
+ }
217
+ },
218
+ robinhood: {
219
+ id: "robinhood",
220
+ label: "Robinhood",
221
+ // USDG de Paxos en Robinhood Chain Testnet (el "USDC" de esa testnet es un
222
+ // mock sin EIP-3009). Leído on-chain: dominio "Global Dollar", versión "1".
223
+ token: "0x7E955252E15c84f5768B83c41a71F9eba181802F",
224
+ tokenSymbol: "USDG",
225
+ decimals: 6,
226
+ eip3009: { name: "Global Dollar", version: "1" },
227
+ mainnet: null,
228
+ testnet: {
229
+ chainId: 46630,
230
+ rpcUrl: "https://rpc.testnet.chain.robinhood.com",
231
+ explorerUrl: "https://explorer.testnet.chain.robinhood.com"
232
+ }
233
+ }
234
+ };
235
+ var NETWORK_IDS = Object.keys(NETWORKS);
236
+ var CHAIN_LABELS = [
237
+ ...new Map(NETWORK_IDS.map((id) => [NETWORKS[id].testnet.chainId, NETWORKS[id].label.split(" \xB7 ")[0] ?? NETWORKS[id].label])).values()
238
+ ];
239
+ var GATEWAY_RAILS = {
240
+ "gateway-arbitrum": {
241
+ id: "gateway-arbitrum",
242
+ label: "Arbitrum \xB7 Nanopayments",
243
+ chainId: 421614,
244
+ rpcUrl: "https://sepolia-rollup.arbitrum.io/rpc",
245
+ token: "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d",
246
+ decimals: 6,
247
+ verifyingContract: "0x0077777d7EBA4688BDeF3E311b846F25870A19B9",
248
+ facilitatorUrl: "https://gateway-api-testnet.circle.com/v1/x402",
249
+ sdkChain: "arbitrumSepolia",
250
+ explorerUrl: "https://sepolia.arbiscan.io"
251
+ },
252
+ "gateway-arc": {
253
+ id: "gateway-arc",
254
+ label: "Arc \xB7 Nanopayments",
255
+ chainId: 5042002,
256
+ rpcUrl: "https://rpc.testnet.arc.network",
257
+ token: "0x3600000000000000000000000000000000000000",
258
+ decimals: 6,
259
+ verifyingContract: "0x0077777d7EBA4688BDeF3E311b846F25870A19B9",
260
+ facilitatorUrl: "https://gateway-api-testnet.circle.com/v1/x402",
261
+ sdkChain: "arcTestnet",
262
+ explorerUrl: "https://testnet.arcscan.app"
263
+ }
264
+ };
265
+ var GATEWAY_RAIL_IDS = Object.keys(GATEWAY_RAILS);
266
+
267
+ // src/index.ts
268
+ var GATEWAY_POR_DEFECTO = "https://peaje-gateway.up.railway.app";
269
+ function env(nombre) {
270
+ return typeof process !== "undefined" ? process.env?.[nombre] : void 0;
271
+ }
272
+ function baseDe({ slug, gateway }) {
273
+ const raiz = (gateway ?? env("PEAJE_GATEWAY_URL") ?? GATEWAY_POR_DEFECTO).replace(/\/+$/, "");
274
+ return `${raiz}/${slug}`;
275
+ }
276
+ var SALTO = ["host", "connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "te", "trailer", "content-length"];
277
+ var avisado = false;
278
+ function _reiniciarAviso() {
279
+ avisado = false;
280
+ }
281
+ function crearPeaje(opciones) {
282
+ if (!opciones?.slug) throw new Error("@peaje/proxy: `slug` is required (the business slug from your Peaje dashboard).");
283
+ const base = baseDe(opciones);
284
+ const secreto = opciones.secret ?? env("PEAJE_ORIGIN_SECRET") ?? null;
285
+ const fetchImpl = opciones.fetch ?? ((...a) => fetch(...a));
286
+ const runtime = crearProxyRuntime({
287
+ base,
288
+ secreto,
289
+ fetchImpl,
290
+ respaldo: { rutas: RUTAS_PROXY.map((x) => x.path), prefijos: RUTAS_DINAMICAS.map((x) => x.prefijo), patrones: [] }
291
+ });
292
+ return {
293
+ base,
294
+ async decidir({ method, url, headers }) {
295
+ if (!secreto && !avisado) {
296
+ avisado = true;
297
+ void runtime.sinSecreto().then((faltan) => {
298
+ if (faltan.length > 0) {
299
+ console.warn(
300
+ `[peaje] ${faltan.length} priced API route(s) are not charged on this domain because PEAJE_ORIGIN_SECRET is not set: ${faltan.map((p) => `${p.method} ${p.path}`).join(", ")}. Copy it from the Kit page of your Peaje dashboard into your hosting environment variables.`
301
+ );
302
+ }
303
+ });
304
+ }
305
+ const d = await runtime.decidir({ method, pathname: url.pathname, search: url.search, headers });
306
+ return d?.destino ?? null;
307
+ },
308
+ async reenviar(destino, { method, url, headers, body }) {
309
+ const salida = new Headers(headers);
310
+ for (const h of SALTO) salida.delete(h);
311
+ const primero = (h) => headers.get(h)?.split(",")[0]?.trim() || null;
312
+ salida.set("x-forwarded-host", primero("x-forwarded-host") ?? url.host);
313
+ salida.set("x-forwarded-proto", primero("x-forwarded-proto") ?? url.protocol.replace(":", ""));
314
+ const sinBody = method === "GET" || method === "HEAD";
315
+ const res = await fetchImpl(destino, { method, headers: salida, body: sinBody ? null : body, redirect: "manual" });
316
+ const vuelta = new Headers(res.headers);
317
+ vuelta.delete("content-encoding");
318
+ vuelta.delete("content-length");
319
+ vuelta.delete("transfer-encoding");
320
+ vuelta.delete("connection");
321
+ return new Response(method === "HEAD" ? null : res.body, { status: res.status, statusText: res.statusText, headers: vuelta });
322
+ }
323
+ };
324
+ }
325
+ function peajeFetch(opciones) {
326
+ const peaje = crearPeaje(opciones);
327
+ return async (request) => {
328
+ const url = new URL(request.url);
329
+ const entrada = { method: request.method.toUpperCase(), url, headers: request.headers };
330
+ const destino = await peaje.decidir(entrada);
331
+ if (!destino) return null;
332
+ const body = entrada.method === "GET" || entrada.method === "HEAD" ? null : await request.arrayBuffer();
333
+ return peaje.reenviar(destino, { ...entrada, body });
334
+ };
335
+ }
336
+ var src_default = peajeFetch;
337
+
338
+ export {
339
+ GATEWAY_POR_DEFECTO,
340
+ baseDe,
341
+ _reiniciarAviso,
342
+ crearPeaje,
343
+ peajeFetch,
344
+ src_default
345
+ };