@parallel-protocol/mpp 0.2.1 → 0.2.2
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 +17 -8
- package/dist/{chunk-MEDPOLT4.cjs → chunk-76KPBYGA.cjs} +192 -16
- package/dist/{chunk-CQ62EEHN.js → chunk-BYNIZG5S.js} +190 -17
- package/dist/express.cjs +30 -5
- package/dist/express.d.cts +2 -1
- package/dist/express.d.ts +2 -1
- package/dist/express.js +29 -4
- package/dist/fastify.cjs +70 -7
- package/dist/fastify.d.cts +1 -1
- package/dist/fastify.d.ts +1 -1
- package/dist/fastify.js +67 -4
- package/dist/hono.cjs +20 -9
- package/dist/hono.d.cts +1 -1
- package/dist/hono.d.ts +1 -1
- package/dist/hono.js +19 -8
- package/dist/index.cjs +13 -13
- package/dist/index.d.cts +6 -3
- package/dist/index.d.ts +6 -3
- package/dist/index.js +1 -1
- package/dist/next.cjs +8 -5
- package/dist/next.d.cts +1 -1
- package/dist/next.d.ts +1 -1
- package/dist/next.js +5 -2
- package/dist/{types-CCE-FI8i.d.cts → types-BSzM92sD.d.cts} +5 -0
- package/dist/{types-CCE-FI8i.d.ts → types-BSzM92sD.d.ts} +5 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -40,9 +40,11 @@ Agent Merchant (this SDK) Facilitator
|
|
|
40
40
|
**Key properties:**
|
|
41
41
|
- The handler only runs after the credential is verified.
|
|
42
42
|
- Settlement (on-chain) happens after a 2xx handler response — the agent is never charged on errors.
|
|
43
|
-
- If settlement fails, the middleware returns `402` and discards the handler response.
|
|
43
|
+
- If settlement fails, the middleware returns `402` and discards the handler response, its headers, cookies and trailers included. For the `cookies()` of Next.js, see [Next.js](#nextjs-app-router).
|
|
44
44
|
- If the handler does not finish within 30 seconds, the Express, Hono and Next.js adapters answer `504` and nothing is settled. Next.js also answers an empty `504` when the handler throws.
|
|
45
|
-
-
|
|
45
|
+
- On paid Express and Fastify routes, the handler does not see the conditional request headers (`If-Match`, `If-None-Match`, `If-Modified-Since`, `If-Unmodified-Since`): Express, or a plugin such as `@fastify/etag`, would turn the paid response into a `304` or a `412`, which is not settled. A paid request always gets the resource.
|
|
46
|
+
- On paid Fastify routes, writing to `reply.raw` (the SSE/streaming pattern) fails loudly instead of sending: a raw response skips the settlement hooks and would go out unpaid. Streaming is not supported on paid routes.
|
|
47
|
+
- CORS preflight (`OPTIONS`) is passed through without challenge, unless its path has `.` or `..` segments. On a paid route the handler still answers it, but only its status code and its `Access-Control-*`, `Vary` and `Allow` headers go out, with the standard reason phrase: no body, no other header, no trailer and no early hints. A handler that answers every method does not serve the paid content. On Next.js the handler does not run, see [Next.js](#nextjs-app-router).
|
|
46
48
|
- The `402` challenge body also includes the `challenge` object as JSON for agents that prefer parsing the body over parsing the header.
|
|
47
49
|
|
|
48
50
|
---
|
|
@@ -62,7 +64,7 @@ Install your framework separately (all are optional peer dependencies):
|
|
|
62
64
|
```bash
|
|
63
65
|
npm install express # Express 4 or 5
|
|
64
66
|
npm install next # Next.js 14 or 15
|
|
65
|
-
npm install fastify # Fastify
|
|
67
|
+
npm install fastify # Fastify 5.12.7 or a later 5.x
|
|
66
68
|
npm install hono # Hono 4
|
|
67
69
|
```
|
|
68
70
|
|
|
@@ -153,6 +155,8 @@ export const GET = withPayment(
|
|
|
153
155
|
);
|
|
154
156
|
```
|
|
155
157
|
|
|
158
|
+
On a paid route, a CORS preflight (`OPTIONS`) gets an empty `204` without running the handler, so set the route's CORS headers in the `headers` of `next.config.js`. Next.js adds the cookies set with `cookies()` from `next/headers` to the response the route returns, even to the `402` of a failed settlement. Set the cookies of a paid response on the `NextResponse` the handler returns instead: they leave with it only.
|
|
159
|
+
|
|
156
160
|
### Fastify
|
|
157
161
|
|
|
158
162
|
```typescript
|
|
@@ -183,6 +187,8 @@ app.get("/api/data", async () => ({ data: "protected content" }));
|
|
|
183
187
|
|
|
184
188
|
`paymentMiddleware` returns a Fastify plugin that adds `onRequest` and `onSend` hooks. Fastify keeps a plugin's hooks inside the plugin, so wrap it with [`fastify-plugin`](https://www.npmjs.com/package/fastify-plugin) (`npm install fastify-plugin`) as above. The hooks then also apply to routes declared outside it, like `/api/data` here.
|
|
185
189
|
|
|
190
|
+
On a refusal after the handler, only the cookies already set on the reply before the handler go out. Cookies that a plugin such as `@fastify/cookie` writes from its own `onSend` hook are left out of the `402`, even those set with `reply.setCookie` before the handler.
|
|
191
|
+
|
|
186
192
|
---
|
|
187
193
|
|
|
188
194
|
## Configuration
|
|
@@ -217,7 +223,7 @@ interface MppMiddlewareConfig {
|
|
|
217
223
|
| `description` | `string` | No | Human-readable description included in the challenge. |
|
|
218
224
|
| `realm` | `string` | No | Optional realm identifier in the `WWW-Authenticate` header. |
|
|
219
225
|
|
|
220
|
-
Route matching uses **longest prefix wins**, on whole path segments: `/api/v1/data` matches `/api/v1` before `/api`, and `/api/database` does not match `/api/data`.
|
|
226
|
+
Route matching uses **longest prefix wins**, on whole path segments: `/api/v1/data` matches `/api/v1` before `/api`, and `/api/database` does not match `/api/data`. The request path and the route keys are compared once normalized: case, percent-encoding, repeated or trailing slashes, a fragment, `;` path parameters and an absolute-form origin make no difference, so another form of a paid path is charged like the path itself. A key with a trailing slash, such as `/api/`, covers the paths below it as `/api` does. Routers and proxies do not all resolve `.` and `..` segments, so a path that has some and reaches a paid route is refused with a `400` and `INVALID_PATH`. Under an Express mount prefix, such as `app.use("/api", ...)`, a key can be written with the full path or relative to the mount, and the key `/` covers only the mount's root: write `/api` to cover every path below it. A Next.js route handler sees the path without the `basePath`, so write keys without it.
|
|
221
227
|
|
|
222
228
|
> **Note:** Unlike x402's `acceptedTokens` list, MPP charges a **single currency per route**. To accept multiple tokens, declare multiple routes.
|
|
223
229
|
|
|
@@ -400,7 +406,7 @@ import {
|
|
|
400
406
|
|-------------|--------|-----------|
|
|
401
407
|
| `@parallel-protocol/mpp/express` | `paymentMiddleware(config)`, `ExpressAdapter` | Express 4/5 |
|
|
402
408
|
| `@parallel-protocol/mpp/next` | `withPayment(config, handler)`, `NextAdapter` | Next.js 14/15 |
|
|
403
|
-
| `@parallel-protocol/mpp/fastify` | `paymentMiddleware(config)` (a plugin, see [Fastify](#fastify)), `FastifyAdapter` | Fastify
|
|
409
|
+
| `@parallel-protocol/mpp/fastify` | `paymentMiddleware(config)` (a plugin, see [Fastify](#fastify)), `FastifyAdapter` | Fastify 5 (5.12.7 or later) |
|
|
404
410
|
| `@parallel-protocol/mpp/hono` | `paymentMiddleware(config)`, `HonoAdapter` | Hono 4 |
|
|
405
411
|
|
|
406
412
|
---
|
|
@@ -422,7 +428,9 @@ const result = await gate({
|
|
|
422
428
|
});
|
|
423
429
|
|
|
424
430
|
if (result.type === "pass") {
|
|
425
|
-
// Route not configured, or OPTIONS
|
|
431
|
+
// Route not configured, or OPTIONS: proceed normally.
|
|
432
|
+
// result.preflight: an OPTIONS on a paid route, send only the handler's
|
|
433
|
+
// status code and its Access-Control-*, Vary and Allow headers
|
|
426
434
|
} else if (result.type === "error") {
|
|
427
435
|
// 402 challenge: result.result.status / .headers / .body
|
|
428
436
|
} else {
|
|
@@ -512,7 +520,7 @@ Both methods use a 10-second timeout. `verify` throws `MppRuntimeError` on any f
|
|
|
512
520
|
|
|
513
521
|
| Error class | When |
|
|
514
522
|
|-------------|------|
|
|
515
|
-
| `MppConfigError` | Invalid config at startup, when the middleware is created: for example no routes, a bad `price`, `payTo` or `currency`, a `network` other than `ethereum`, `base`, `avalanche` or `hyperevm`, or a facilitator URL that does not parse |
|
|
523
|
+
| `MppConfigError` | Invalid config at startup, when the middleware is created, the message naming the first field at fault: for example no routes, two routes that are the same path once normalized with different configs, a bad `price`, `payTo` or `currency`, a `network` other than `ethereum`, `base`, `avalanche` or `hyperevm`, or a facilitator URL that does not parse |
|
|
516
524
|
| `MppRuntimeError` | Thrown by `MppFacilitatorClient`. `.code` is one of the SDK codes below, or the facilitator's own code |
|
|
517
525
|
|
|
518
526
|
| `MPP_ERROR_CODES` | Meaning |
|
|
@@ -521,6 +529,7 @@ Both methods use a 10-second timeout. `verify` throws `MppRuntimeError` on any f
|
|
|
521
529
|
| `FACILITATOR_INVALID_RESPONSE` | The facilitator answered 2xx with something other than `{ "valid": true }` at verify, or other than a receipt at settle |
|
|
522
530
|
| `METHOD_MISMATCH` | The credential's `challenge.method` is not the route's `method`. The middleware returns it in the 402 body and does not throw it |
|
|
523
531
|
| `INVALID_CREDENTIAL` | Not emitted. A credential that cannot be read gets a challenge without `error` |
|
|
532
|
+
| `INVALID_PATH` | The path has `.` or `..` segments and reaches a paid route. The middleware answers it with a `400` and `{ error, message }`, before any challenge, and does not throw it |
|
|
524
533
|
|
|
525
534
|
### Propagated from the facilitator
|
|
526
535
|
|
|
@@ -573,7 +582,7 @@ import { chainIdFromSlug, matchRoute, parsePrice } from "@parallel-protocol/mpp"
|
|
|
573
582
|
|----------|-----------|-------------|
|
|
574
583
|
| `parsePrice` | `(price: string \| bigint, decimals?: number) => bigint` | Parse `"0.10"` → `100000n` (6 decimals by default) |
|
|
575
584
|
| `chainIdFromSlug` | `(network: string) => number \| undefined` | `"base"` → `8453`, `undefined` for unknown chains |
|
|
576
|
-
| `matchRoute` | `<T>(path: string, routes: Record<string, T>) => T \| undefined` | Longest-prefix route matching |
|
|
585
|
+
| `matchRoute` | `<T>(path: string, routes: Record<string, T>) => T \| undefined` | Longest-prefix route matching, on the normalized path, see [Route matching](#mpprouteconfig) |
|
|
577
586
|
|
|
578
587
|
---
|
|
579
588
|
|
|
@@ -9,6 +9,8 @@ var MPP_ERROR_CODES = {
|
|
|
9
9
|
FACILITATOR_UNAVAILABLE: "FACILITATOR_UNAVAILABLE",
|
|
10
10
|
FACILITATOR_INVALID_RESPONSE: "FACILITATOR_INVALID_RESPONSE",
|
|
11
11
|
INVALID_CREDENTIAL: "INVALID_CREDENTIAL",
|
|
12
|
+
/** A path with `.` or `..` segments on a paid route, refused with 400. */
|
|
13
|
+
INVALID_PATH: "INVALID_PATH",
|
|
12
14
|
METHOD_MISMATCH: "METHOD_MISMATCH"
|
|
13
15
|
};
|
|
14
16
|
var MppConfigError = class extends Error {
|
|
@@ -54,18 +56,147 @@ var SETTLE_FAILURE_BODY = {
|
|
|
54
56
|
error: "PAYMENT_SETTLEMENT_FAILED",
|
|
55
57
|
message: "Payment could not be confirmed after multiple attempts. Please retry."
|
|
56
58
|
};
|
|
57
|
-
|
|
58
|
-
if
|
|
59
|
+
var CONDITIONAL_HEADERS = [
|
|
60
|
+
"if-match",
|
|
61
|
+
"if-none-match",
|
|
62
|
+
"if-modified-since",
|
|
63
|
+
"if-unmodified-since"
|
|
64
|
+
];
|
|
65
|
+
function dropConditionalHeaders(headers) {
|
|
66
|
+
for (const name of CONDITIONAL_HEADERS) delete headers[name];
|
|
67
|
+
}
|
|
68
|
+
var UTF8 = new TextDecoder();
|
|
69
|
+
var DECODE_ROUNDS = 8;
|
|
70
|
+
function decodePercent(path) {
|
|
71
|
+
try {
|
|
72
|
+
return decodeURIComponent(path);
|
|
73
|
+
} catch {
|
|
74
|
+
return path.replace(
|
|
75
|
+
/(?:%[\da-f]{2})+/gi,
|
|
76
|
+
(run) => UTF8.decode(
|
|
77
|
+
Uint8Array.from(
|
|
78
|
+
run.slice(1).split("%"),
|
|
79
|
+
(hex) => Number.parseInt(hex, 16)
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function hasLongMarkRun(segment) {
|
|
86
|
+
for (const [run] of segment.matchAll(/\p{M}+/gu))
|
|
87
|
+
if (run.length > 8) return true;
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
function foldCase(segment) {
|
|
91
|
+
const composed = hasLongMarkRun(segment) ? segment : segment.normalize("NFC");
|
|
92
|
+
return composed.toLowerCase().toUpperCase().toLowerCase();
|
|
93
|
+
}
|
|
94
|
+
function pathSegments(path) {
|
|
95
|
+
let decoded = path;
|
|
96
|
+
for (let round = 0; round < DECODE_ROUNDS && decoded.includes("%"); round++) {
|
|
97
|
+
const next = decodePercent(decoded);
|
|
98
|
+
if (next === decoded) break;
|
|
99
|
+
decoded = next;
|
|
100
|
+
}
|
|
101
|
+
const target = decoded.replaceAll("\\", "/").replace(/^[a-z][\d+.a-z-]*:\/\/[^/]*/i, "").split(/[?#]/, 1)[0] ?? "";
|
|
102
|
+
return target.split("/").map((segment) => foldCase(segment).split(";", 1)[0] ?? "").filter((segment) => segment !== "");
|
|
103
|
+
}
|
|
104
|
+
function resolveDots(segments) {
|
|
105
|
+
const resolved = [];
|
|
106
|
+
for (const segment of segments) {
|
|
107
|
+
if (segment === "..") resolved.pop();
|
|
108
|
+
else if (segment !== ".") resolved.push(segment);
|
|
109
|
+
}
|
|
110
|
+
return resolved;
|
|
111
|
+
}
|
|
112
|
+
function normalizeRoutePath(path) {
|
|
113
|
+
return `/${resolveDots(pathSegments(path)).join("/")}`;
|
|
114
|
+
}
|
|
115
|
+
function routeTable(routes) {
|
|
116
|
+
return Object.entries(routes).map(([key, route]) => [
|
|
117
|
+
normalizeRoutePath(key),
|
|
118
|
+
route
|
|
119
|
+
]);
|
|
120
|
+
}
|
|
121
|
+
function longestMatch(target, table) {
|
|
59
122
|
let bestMatch;
|
|
60
123
|
let bestLength = 0;
|
|
61
|
-
for (const [
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
124
|
+
for (const [key, route] of table) {
|
|
125
|
+
if (key === target) return route;
|
|
126
|
+
if (target.startsWith(`${key}/`) && key.length > bestLength) {
|
|
127
|
+
bestMatch = route;
|
|
128
|
+
bestLength = key.length;
|
|
65
129
|
}
|
|
66
130
|
}
|
|
67
131
|
return bestMatch;
|
|
68
132
|
}
|
|
133
|
+
function findRoute(path, table) {
|
|
134
|
+
const segments = pathSegments(path);
|
|
135
|
+
const dotSegments = segments.some(
|
|
136
|
+
(segment) => segment === "." || segment === ".."
|
|
137
|
+
);
|
|
138
|
+
const routePath = `/${resolveDots(segments).join("/")}`;
|
|
139
|
+
const route = longestMatch(routePath, table) ?? (dotSegments ? longestMatch(`/${segments.join("/")}`, table) : void 0);
|
|
140
|
+
return { route, dotSegments, routePath };
|
|
141
|
+
}
|
|
142
|
+
function matchRoute(path, routes) {
|
|
143
|
+
return findRoute(path, routeTable(routes)).route;
|
|
144
|
+
}
|
|
145
|
+
function isPreflightHeader(name) {
|
|
146
|
+
return /^(?:access-control-|vary$|allow$)/i.test(String(name));
|
|
147
|
+
}
|
|
148
|
+
function preflightHeaders(headers) {
|
|
149
|
+
if (Array.isArray(headers)) {
|
|
150
|
+
if (Array.isArray(headers[0]))
|
|
151
|
+
return headers.filter((pair) => isPreflightHeader(pair[0]));
|
|
152
|
+
return headers.flatMap(
|
|
153
|
+
(item, i) => i % 2 === 0 && isPreflightHeader(item) ? [item, headers[i + 1]] : []
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (headers !== null && typeof headers === "object")
|
|
157
|
+
return Object.fromEntries(
|
|
158
|
+
Object.entries(headers).filter(([name]) => isPreflightHeader(name))
|
|
159
|
+
);
|
|
160
|
+
return headers;
|
|
161
|
+
}
|
|
162
|
+
function answerAsPreflight(res) {
|
|
163
|
+
const writeHead = res.writeHead.bind(res);
|
|
164
|
+
const end = res.end.bind(res);
|
|
165
|
+
const callbackOf = (args) => args.find((arg) => typeof arg === "function");
|
|
166
|
+
const dropOtherHeaders = () => {
|
|
167
|
+
for (const name of res.getHeaderNames())
|
|
168
|
+
if (!isPreflightHeader(name)) res.removeHeader(name);
|
|
169
|
+
};
|
|
170
|
+
res.writeHead = ((statusCode, ...rest) => {
|
|
171
|
+
dropOtherHeaders();
|
|
172
|
+
res.statusMessage = "";
|
|
173
|
+
const headers = rest.find((arg) => typeof arg !== "string");
|
|
174
|
+
return headers === void 0 ? writeHead(statusCode) : writeHead(statusCode, preflightHeaders(headers));
|
|
175
|
+
});
|
|
176
|
+
res.write = ((...args) => {
|
|
177
|
+
const done = callbackOf(args);
|
|
178
|
+
if (done) queueMicrotask(done);
|
|
179
|
+
return true;
|
|
180
|
+
});
|
|
181
|
+
res.end = ((...args) => {
|
|
182
|
+
if (!res.headersSent) dropOtherHeaders();
|
|
183
|
+
const done = callbackOf(args);
|
|
184
|
+
return done ? end(done) : end();
|
|
185
|
+
});
|
|
186
|
+
res.addTrailers = () => {
|
|
187
|
+
};
|
|
188
|
+
res.writeEarlyHints = ((_hints, done) => {
|
|
189
|
+
if (done) queueMicrotask(done);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
function preflightInit(res) {
|
|
193
|
+
res.body?.cancel().catch(() => {
|
|
194
|
+
});
|
|
195
|
+
const headers = new Headers();
|
|
196
|
+
for (const [name, value] of res.headers)
|
|
197
|
+
if (isPreflightHeader(name)) headers.append(name, value);
|
|
198
|
+
return { status: res.status, headers };
|
|
199
|
+
}
|
|
69
200
|
var DEFAULT_TIMEOUT_SECONDS = 300;
|
|
70
201
|
function challengeId() {
|
|
71
202
|
return globalThis.crypto.randomUUID();
|
|
@@ -226,9 +357,31 @@ var routeConfigSchema = zod.z.object({
|
|
|
226
357
|
description: zod.z.string().optional(),
|
|
227
358
|
realm: zod.z.string().optional()
|
|
228
359
|
});
|
|
360
|
+
function sameValue(a, b) {
|
|
361
|
+
if (Object.is(a, b)) return true;
|
|
362
|
+
if (typeof a !== "object" || typeof b !== "object" || !a || !b) return false;
|
|
363
|
+
const entries = Object.entries(a);
|
|
364
|
+
return Array.isArray(a) === Array.isArray(b) && entries.length === Object.keys(b).length && entries.every(
|
|
365
|
+
([key, value]) => sameValue(value, b[key])
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
function consistentRoutePaths(routes, ctx) {
|
|
369
|
+
const keyByPath = /* @__PURE__ */ new Map();
|
|
370
|
+
for (const [key, route] of Object.entries(routes)) {
|
|
371
|
+
const path = normalizeRoutePath(key);
|
|
372
|
+
const other = keyByPath.get(path);
|
|
373
|
+
if (other === void 0) keyByPath.set(path, key);
|
|
374
|
+
else if (!sameValue(routes[other], route))
|
|
375
|
+
ctx.addIssue({
|
|
376
|
+
code: "custom",
|
|
377
|
+
path: [key],
|
|
378
|
+
message: `Same path as "${other}" once normalized, with another config: keep one of the two routes`
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
229
382
|
var mppMiddlewareConfigSchema = zod.z.object({
|
|
230
383
|
facilitator: facilitatorConfigSchema,
|
|
231
|
-
routes: zod.z.record(zod.z.string(), routeConfigSchema).refine((r) => Object.keys(r).length > 0, "At least one route is required")
|
|
384
|
+
routes: zod.z.record(zod.z.string(), routeConfigSchema).refine((r) => Object.keys(r).length > 0, "At least one route is required").superRefine(consistentRoutePaths)
|
|
232
385
|
});
|
|
233
386
|
|
|
234
387
|
// src/middleware.ts
|
|
@@ -248,15 +401,17 @@ function challengeResponse(route, request, error) {
|
|
|
248
401
|
};
|
|
249
402
|
}
|
|
250
403
|
function createMppPaymentGate(config) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
404
|
+
const checked = mppMiddlewareConfigSchema.safeParse(config);
|
|
405
|
+
if (!checked.success) {
|
|
406
|
+
const issue = checked.error.issues[0];
|
|
407
|
+
const where = issue?.path.length ? ` at ${issue.path.join(".")}` : "";
|
|
254
408
|
throw new MppConfigError(
|
|
255
|
-
|
|
256
|
-
|
|
409
|
+
`Invalid MPP middleware config${where}: ${issue?.message ?? "check your routes, facilitator URL, payTo and currency addresses"}`,
|
|
410
|
+
checked.error
|
|
257
411
|
);
|
|
258
412
|
}
|
|
259
413
|
const client = new MppFacilitatorClient(config.facilitator);
|
|
414
|
+
const routes = routeTable(config.routes);
|
|
260
415
|
const requestByRoute = new Map(
|
|
261
416
|
Object.values(config.routes).map((route) => [
|
|
262
417
|
route,
|
|
@@ -264,10 +419,27 @@ function createMppPaymentGate(config) {
|
|
|
264
419
|
])
|
|
265
420
|
);
|
|
266
421
|
return async (adapter) => {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
422
|
+
const path = adapter.getPath();
|
|
423
|
+
const fullPath = adapter.getFullPath?.();
|
|
424
|
+
let { route, dotSegments } = findRoute(path, routes);
|
|
425
|
+
if (!route && fullPath !== void 0 && fullPath !== path)
|
|
426
|
+
({ route, dotSegments } = findRoute(fullPath, routes));
|
|
270
427
|
if (!route) return { type: "pass" };
|
|
428
|
+
if (dotSegments) {
|
|
429
|
+
return {
|
|
430
|
+
type: "error",
|
|
431
|
+
result: {
|
|
432
|
+
status: 400,
|
|
433
|
+
headers: { "Content-Type": "application/json" },
|
|
434
|
+
body: {
|
|
435
|
+
error: MPP_ERROR_CODES.INVALID_PATH,
|
|
436
|
+
message: "A paid route is not served on a path with . or .. segments: send the path resolved"
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
if (adapter.getMethod().toUpperCase() === "OPTIONS")
|
|
442
|
+
return { type: "pass", preflight: true };
|
|
271
443
|
const request = requestByRoute.get(route);
|
|
272
444
|
const credential = parseAuthorizationHeader(
|
|
273
445
|
adapter.getHeader("authorization")
|
|
@@ -304,7 +476,8 @@ function createMppPaymentMiddleware(config) {
|
|
|
304
476
|
const gate = createMppPaymentGate(config);
|
|
305
477
|
return async (adapter) => {
|
|
306
478
|
const gateResult = await gate(adapter);
|
|
307
|
-
if (gateResult.type === "pass")
|
|
479
|
+
if (gateResult.type === "pass")
|
|
480
|
+
return gateResult.preflight ? { pass: true, preflight: true } : { pass: true };
|
|
308
481
|
if (gateResult.type === "error") return gateResult.result;
|
|
309
482
|
let handlerResult;
|
|
310
483
|
try {
|
|
@@ -348,12 +521,15 @@ exports.MppConfigError = MppConfigError;
|
|
|
348
521
|
exports.MppFacilitatorClient = MppFacilitatorClient;
|
|
349
522
|
exports.MppRuntimeError = MppRuntimeError;
|
|
350
523
|
exports.SETTLE_FAILURE_BODY = SETTLE_FAILURE_BODY;
|
|
524
|
+
exports.answerAsPreflight = answerAsPreflight;
|
|
351
525
|
exports.buildChallenge = buildChallenge;
|
|
352
526
|
exports.chainIdFromSlug = chainIdFromSlug;
|
|
353
527
|
exports.createMppPaymentGate = createMppPaymentGate;
|
|
354
528
|
exports.createMppPaymentMiddleware = createMppPaymentMiddleware;
|
|
529
|
+
exports.dropConditionalHeaders = dropConditionalHeaders;
|
|
355
530
|
exports.isSuccessStatus = isSuccessStatus;
|
|
356
531
|
exports.matchRoute = matchRoute;
|
|
357
532
|
exports.parseAuthorizationHeader = parseAuthorizationHeader;
|
|
358
533
|
exports.parsePrice = parsePrice;
|
|
534
|
+
exports.preflightInit = preflightInit;
|
|
359
535
|
exports.serializeChallenge = serializeChallenge;
|
|
@@ -7,6 +7,8 @@ var MPP_ERROR_CODES = {
|
|
|
7
7
|
FACILITATOR_UNAVAILABLE: "FACILITATOR_UNAVAILABLE",
|
|
8
8
|
FACILITATOR_INVALID_RESPONSE: "FACILITATOR_INVALID_RESPONSE",
|
|
9
9
|
INVALID_CREDENTIAL: "INVALID_CREDENTIAL",
|
|
10
|
+
/** A path with `.` or `..` segments on a paid route, refused with 400. */
|
|
11
|
+
INVALID_PATH: "INVALID_PATH",
|
|
10
12
|
METHOD_MISMATCH: "METHOD_MISMATCH"
|
|
11
13
|
};
|
|
12
14
|
var MppConfigError = class extends Error {
|
|
@@ -52,18 +54,147 @@ var SETTLE_FAILURE_BODY = {
|
|
|
52
54
|
error: "PAYMENT_SETTLEMENT_FAILED",
|
|
53
55
|
message: "Payment could not be confirmed after multiple attempts. Please retry."
|
|
54
56
|
};
|
|
55
|
-
|
|
56
|
-
if
|
|
57
|
+
var CONDITIONAL_HEADERS = [
|
|
58
|
+
"if-match",
|
|
59
|
+
"if-none-match",
|
|
60
|
+
"if-modified-since",
|
|
61
|
+
"if-unmodified-since"
|
|
62
|
+
];
|
|
63
|
+
function dropConditionalHeaders(headers) {
|
|
64
|
+
for (const name of CONDITIONAL_HEADERS) delete headers[name];
|
|
65
|
+
}
|
|
66
|
+
var UTF8 = new TextDecoder();
|
|
67
|
+
var DECODE_ROUNDS = 8;
|
|
68
|
+
function decodePercent(path) {
|
|
69
|
+
try {
|
|
70
|
+
return decodeURIComponent(path);
|
|
71
|
+
} catch {
|
|
72
|
+
return path.replace(
|
|
73
|
+
/(?:%[\da-f]{2})+/gi,
|
|
74
|
+
(run) => UTF8.decode(
|
|
75
|
+
Uint8Array.from(
|
|
76
|
+
run.slice(1).split("%"),
|
|
77
|
+
(hex) => Number.parseInt(hex, 16)
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function hasLongMarkRun(segment) {
|
|
84
|
+
for (const [run] of segment.matchAll(/\p{M}+/gu))
|
|
85
|
+
if (run.length > 8) return true;
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
function foldCase(segment) {
|
|
89
|
+
const composed = hasLongMarkRun(segment) ? segment : segment.normalize("NFC");
|
|
90
|
+
return composed.toLowerCase().toUpperCase().toLowerCase();
|
|
91
|
+
}
|
|
92
|
+
function pathSegments(path) {
|
|
93
|
+
let decoded = path;
|
|
94
|
+
for (let round = 0; round < DECODE_ROUNDS && decoded.includes("%"); round++) {
|
|
95
|
+
const next = decodePercent(decoded);
|
|
96
|
+
if (next === decoded) break;
|
|
97
|
+
decoded = next;
|
|
98
|
+
}
|
|
99
|
+
const target = decoded.replaceAll("\\", "/").replace(/^[a-z][\d+.a-z-]*:\/\/[^/]*/i, "").split(/[?#]/, 1)[0] ?? "";
|
|
100
|
+
return target.split("/").map((segment) => foldCase(segment).split(";", 1)[0] ?? "").filter((segment) => segment !== "");
|
|
101
|
+
}
|
|
102
|
+
function resolveDots(segments) {
|
|
103
|
+
const resolved = [];
|
|
104
|
+
for (const segment of segments) {
|
|
105
|
+
if (segment === "..") resolved.pop();
|
|
106
|
+
else if (segment !== ".") resolved.push(segment);
|
|
107
|
+
}
|
|
108
|
+
return resolved;
|
|
109
|
+
}
|
|
110
|
+
function normalizeRoutePath(path) {
|
|
111
|
+
return `/${resolveDots(pathSegments(path)).join("/")}`;
|
|
112
|
+
}
|
|
113
|
+
function routeTable(routes) {
|
|
114
|
+
return Object.entries(routes).map(([key, route]) => [
|
|
115
|
+
normalizeRoutePath(key),
|
|
116
|
+
route
|
|
117
|
+
]);
|
|
118
|
+
}
|
|
119
|
+
function longestMatch(target, table) {
|
|
57
120
|
let bestMatch;
|
|
58
121
|
let bestLength = 0;
|
|
59
|
-
for (const [
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
122
|
+
for (const [key, route] of table) {
|
|
123
|
+
if (key === target) return route;
|
|
124
|
+
if (target.startsWith(`${key}/`) && key.length > bestLength) {
|
|
125
|
+
bestMatch = route;
|
|
126
|
+
bestLength = key.length;
|
|
63
127
|
}
|
|
64
128
|
}
|
|
65
129
|
return bestMatch;
|
|
66
130
|
}
|
|
131
|
+
function findRoute(path, table) {
|
|
132
|
+
const segments = pathSegments(path);
|
|
133
|
+
const dotSegments = segments.some(
|
|
134
|
+
(segment) => segment === "." || segment === ".."
|
|
135
|
+
);
|
|
136
|
+
const routePath = `/${resolveDots(segments).join("/")}`;
|
|
137
|
+
const route = longestMatch(routePath, table) ?? (dotSegments ? longestMatch(`/${segments.join("/")}`, table) : void 0);
|
|
138
|
+
return { route, dotSegments, routePath };
|
|
139
|
+
}
|
|
140
|
+
function matchRoute(path, routes) {
|
|
141
|
+
return findRoute(path, routeTable(routes)).route;
|
|
142
|
+
}
|
|
143
|
+
function isPreflightHeader(name) {
|
|
144
|
+
return /^(?:access-control-|vary$|allow$)/i.test(String(name));
|
|
145
|
+
}
|
|
146
|
+
function preflightHeaders(headers) {
|
|
147
|
+
if (Array.isArray(headers)) {
|
|
148
|
+
if (Array.isArray(headers[0]))
|
|
149
|
+
return headers.filter((pair) => isPreflightHeader(pair[0]));
|
|
150
|
+
return headers.flatMap(
|
|
151
|
+
(item, i) => i % 2 === 0 && isPreflightHeader(item) ? [item, headers[i + 1]] : []
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (headers !== null && typeof headers === "object")
|
|
155
|
+
return Object.fromEntries(
|
|
156
|
+
Object.entries(headers).filter(([name]) => isPreflightHeader(name))
|
|
157
|
+
);
|
|
158
|
+
return headers;
|
|
159
|
+
}
|
|
160
|
+
function answerAsPreflight(res) {
|
|
161
|
+
const writeHead = res.writeHead.bind(res);
|
|
162
|
+
const end = res.end.bind(res);
|
|
163
|
+
const callbackOf = (args) => args.find((arg) => typeof arg === "function");
|
|
164
|
+
const dropOtherHeaders = () => {
|
|
165
|
+
for (const name of res.getHeaderNames())
|
|
166
|
+
if (!isPreflightHeader(name)) res.removeHeader(name);
|
|
167
|
+
};
|
|
168
|
+
res.writeHead = ((statusCode, ...rest) => {
|
|
169
|
+
dropOtherHeaders();
|
|
170
|
+
res.statusMessage = "";
|
|
171
|
+
const headers = rest.find((arg) => typeof arg !== "string");
|
|
172
|
+
return headers === void 0 ? writeHead(statusCode) : writeHead(statusCode, preflightHeaders(headers));
|
|
173
|
+
});
|
|
174
|
+
res.write = ((...args) => {
|
|
175
|
+
const done = callbackOf(args);
|
|
176
|
+
if (done) queueMicrotask(done);
|
|
177
|
+
return true;
|
|
178
|
+
});
|
|
179
|
+
res.end = ((...args) => {
|
|
180
|
+
if (!res.headersSent) dropOtherHeaders();
|
|
181
|
+
const done = callbackOf(args);
|
|
182
|
+
return done ? end(done) : end();
|
|
183
|
+
});
|
|
184
|
+
res.addTrailers = () => {
|
|
185
|
+
};
|
|
186
|
+
res.writeEarlyHints = ((_hints, done) => {
|
|
187
|
+
if (done) queueMicrotask(done);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
function preflightInit(res) {
|
|
191
|
+
res.body?.cancel().catch(() => {
|
|
192
|
+
});
|
|
193
|
+
const headers = new Headers();
|
|
194
|
+
for (const [name, value] of res.headers)
|
|
195
|
+
if (isPreflightHeader(name)) headers.append(name, value);
|
|
196
|
+
return { status: res.status, headers };
|
|
197
|
+
}
|
|
67
198
|
var DEFAULT_TIMEOUT_SECONDS = 300;
|
|
68
199
|
function challengeId() {
|
|
69
200
|
return globalThis.crypto.randomUUID();
|
|
@@ -224,9 +355,31 @@ var routeConfigSchema = z.object({
|
|
|
224
355
|
description: z.string().optional(),
|
|
225
356
|
realm: z.string().optional()
|
|
226
357
|
});
|
|
358
|
+
function sameValue(a, b) {
|
|
359
|
+
if (Object.is(a, b)) return true;
|
|
360
|
+
if (typeof a !== "object" || typeof b !== "object" || !a || !b) return false;
|
|
361
|
+
const entries = Object.entries(a);
|
|
362
|
+
return Array.isArray(a) === Array.isArray(b) && entries.length === Object.keys(b).length && entries.every(
|
|
363
|
+
([key, value]) => sameValue(value, b[key])
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
function consistentRoutePaths(routes, ctx) {
|
|
367
|
+
const keyByPath = /* @__PURE__ */ new Map();
|
|
368
|
+
for (const [key, route] of Object.entries(routes)) {
|
|
369
|
+
const path = normalizeRoutePath(key);
|
|
370
|
+
const other = keyByPath.get(path);
|
|
371
|
+
if (other === void 0) keyByPath.set(path, key);
|
|
372
|
+
else if (!sameValue(routes[other], route))
|
|
373
|
+
ctx.addIssue({
|
|
374
|
+
code: "custom",
|
|
375
|
+
path: [key],
|
|
376
|
+
message: `Same path as "${other}" once normalized, with another config: keep one of the two routes`
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
227
380
|
var mppMiddlewareConfigSchema = z.object({
|
|
228
381
|
facilitator: facilitatorConfigSchema,
|
|
229
|
-
routes: z.record(z.string(), routeConfigSchema).refine((r) => Object.keys(r).length > 0, "At least one route is required")
|
|
382
|
+
routes: z.record(z.string(), routeConfigSchema).refine((r) => Object.keys(r).length > 0, "At least one route is required").superRefine(consistentRoutePaths)
|
|
230
383
|
});
|
|
231
384
|
|
|
232
385
|
// src/middleware.ts
|
|
@@ -246,15 +399,17 @@ function challengeResponse(route, request, error) {
|
|
|
246
399
|
};
|
|
247
400
|
}
|
|
248
401
|
function createMppPaymentGate(config) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
402
|
+
const checked = mppMiddlewareConfigSchema.safeParse(config);
|
|
403
|
+
if (!checked.success) {
|
|
404
|
+
const issue = checked.error.issues[0];
|
|
405
|
+
const where = issue?.path.length ? ` at ${issue.path.join(".")}` : "";
|
|
252
406
|
throw new MppConfigError(
|
|
253
|
-
|
|
254
|
-
|
|
407
|
+
`Invalid MPP middleware config${where}: ${issue?.message ?? "check your routes, facilitator URL, payTo and currency addresses"}`,
|
|
408
|
+
checked.error
|
|
255
409
|
);
|
|
256
410
|
}
|
|
257
411
|
const client = new MppFacilitatorClient(config.facilitator);
|
|
412
|
+
const routes = routeTable(config.routes);
|
|
258
413
|
const requestByRoute = new Map(
|
|
259
414
|
Object.values(config.routes).map((route) => [
|
|
260
415
|
route,
|
|
@@ -262,10 +417,27 @@ function createMppPaymentGate(config) {
|
|
|
262
417
|
])
|
|
263
418
|
);
|
|
264
419
|
return async (adapter) => {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
420
|
+
const path = adapter.getPath();
|
|
421
|
+
const fullPath = adapter.getFullPath?.();
|
|
422
|
+
let { route, dotSegments } = findRoute(path, routes);
|
|
423
|
+
if (!route && fullPath !== void 0 && fullPath !== path)
|
|
424
|
+
({ route, dotSegments } = findRoute(fullPath, routes));
|
|
268
425
|
if (!route) return { type: "pass" };
|
|
426
|
+
if (dotSegments) {
|
|
427
|
+
return {
|
|
428
|
+
type: "error",
|
|
429
|
+
result: {
|
|
430
|
+
status: 400,
|
|
431
|
+
headers: { "Content-Type": "application/json" },
|
|
432
|
+
body: {
|
|
433
|
+
error: MPP_ERROR_CODES.INVALID_PATH,
|
|
434
|
+
message: "A paid route is not served on a path with . or .. segments: send the path resolved"
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
if (adapter.getMethod().toUpperCase() === "OPTIONS")
|
|
440
|
+
return { type: "pass", preflight: true };
|
|
269
441
|
const request = requestByRoute.get(route);
|
|
270
442
|
const credential = parseAuthorizationHeader(
|
|
271
443
|
adapter.getHeader("authorization")
|
|
@@ -302,7 +474,8 @@ function createMppPaymentMiddleware(config) {
|
|
|
302
474
|
const gate = createMppPaymentGate(config);
|
|
303
475
|
return async (adapter) => {
|
|
304
476
|
const gateResult = await gate(adapter);
|
|
305
|
-
if (gateResult.type === "pass")
|
|
477
|
+
if (gateResult.type === "pass")
|
|
478
|
+
return gateResult.preflight ? { pass: true, preflight: true } : { pass: true };
|
|
306
479
|
if (gateResult.type === "error") return gateResult.result;
|
|
307
480
|
let handlerResult;
|
|
308
481
|
try {
|
|
@@ -341,4 +514,4 @@ function createMppPaymentMiddleware(config) {
|
|
|
341
514
|
};
|
|
342
515
|
}
|
|
343
516
|
|
|
344
|
-
export { MPP_ERROR_CODES, MppConfigError, MppFacilitatorClient, MppRuntimeError, SETTLE_FAILURE_BODY, buildChallenge, chainIdFromSlug, createMppPaymentGate, createMppPaymentMiddleware, isSuccessStatus, matchRoute, parseAuthorizationHeader, parsePrice, serializeChallenge };
|
|
517
|
+
export { MPP_ERROR_CODES, MppConfigError, MppFacilitatorClient, MppRuntimeError, SETTLE_FAILURE_BODY, answerAsPreflight, buildChallenge, chainIdFromSlug, createMppPaymentGate, createMppPaymentMiddleware, dropConditionalHeaders, isSuccessStatus, matchRoute, parseAuthorizationHeader, parsePrice, preflightInit, serializeChallenge };
|