@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 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
- - CORS preflight (`OPTIONS`) is always passed through without challenge.
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 4 or 5
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 4/5 |
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 — proceed normally
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
- function matchRoute(path, routes) {
58
- if (routes[path]) return routes[path];
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 [pattern, config] of Object.entries(routes)) {
62
- if (path.startsWith(`${pattern}/`) && pattern.length > bestLength) {
63
- bestMatch = config;
64
- bestLength = pattern.length;
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
- try {
252
- mppMiddlewareConfigSchema.parse(config);
253
- } catch (cause) {
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
- "Invalid MPP middleware config \u2014 check your routes, facilitator URL, payTo and currency addresses",
256
- cause
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
- if (adapter.getMethod().toUpperCase() === "OPTIONS")
268
- return { type: "pass" };
269
- const route = matchRoute(adapter.getPath(), config.routes);
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") return { pass: true };
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
- function matchRoute(path, routes) {
56
- if (routes[path]) return routes[path];
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 [pattern, config] of Object.entries(routes)) {
60
- if (path.startsWith(`${pattern}/`) && pattern.length > bestLength) {
61
- bestMatch = config;
62
- bestLength = pattern.length;
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
- try {
250
- mppMiddlewareConfigSchema.parse(config);
251
- } catch (cause) {
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
- "Invalid MPP middleware config \u2014 check your routes, facilitator URL, payTo and currency addresses",
254
- cause
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
- if (adapter.getMethod().toUpperCase() === "OPTIONS")
266
- return { type: "pass" };
267
- const route = matchRoute(adapter.getPath(), config.routes);
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") return { pass: true };
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 };