@fanth/payment-router-sdk 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/LICENSE +9 -0
- package/README.md +201 -0
- package/dist/index.cjs +448 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +275 -0
- package/dist/index.d.ts +275 -0
- package/dist/index.js +426 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fanth
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# @fanth/payment-router-sdk
|
|
2
|
+
|
|
3
|
+
TypeScript SDK for **payment-router** - the URL shortener for payment gateway webhooks.
|
|
4
|
+
|
|
5
|
+
Both ends of the trip, off one object - `PaymentRouterClient`:
|
|
6
|
+
|
|
7
|
+
- **creating a route** registers your real webhook URL and hands back the UUID you give the gateway
|
|
8
|
+
as its external payment id.
|
|
9
|
+
- **verifying a callback** proves the request the router forwarded back really came from the router,
|
|
10
|
+
and that its body was not touched on the way.
|
|
11
|
+
|
|
12
|
+
Runs anywhere with `fetch` and WebCrypto: Node 18+, Cloudflare Workers, Deno, Bun, Vercel Edge.
|
|
13
|
+
ESM and CJS builds, types included, one dependency (`jose`).
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @fanth/payment-router-sdk
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Creating a route
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { PaymentRouterClient } from "@fanth/payment-router-sdk";
|
|
23
|
+
|
|
24
|
+
const router = new PaymentRouterClient(); // https://payment-r.fanth.pl
|
|
25
|
+
|
|
26
|
+
const route = await router.createRoute({
|
|
27
|
+
webhookUrl: "https://shop.example.com/webhooks/payu",
|
|
28
|
+
externalId: "order-42", // your own id, echoed back on the callback
|
|
29
|
+
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // optional
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// route.id -> send as the gateway's external payment id (PayU: extOrderId)
|
|
33
|
+
// route.callbackUrls -> { payu: "https://payment-r.fanth.pl/webhooks/payu" }
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Every call defaults to the hosted router at `https://payment-r.fanth.pl` (exported as
|
|
37
|
+
`DEFAULT_BASE_URL`). Running your own worker? Pass `baseUrl` - the verifier's expected `iss` follows
|
|
38
|
+
it, so one option covers both ends.
|
|
39
|
+
|
|
40
|
+
Then create the payment at the gateway with `route.id` as its external order id, and point the
|
|
41
|
+
gateway's single webhook URL at `route.callbackUrls.payu`. That URL never changes, so it can just as
|
|
42
|
+
well be read straight off the client without creating a route:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
router.callbackUrl("payu"); // https://payment-r.fanth.pl/webhooks/payu
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Options
|
|
49
|
+
|
|
50
|
+
| Option | Default | What it does |
|
|
51
|
+
| -------------- | ------------------ | ------------------------------------------------------------ |
|
|
52
|
+
| `baseUrl` | `DEFAULT_BASE_URL` | The router's public origin. |
|
|
53
|
+
| `fetch` | global `fetch` | Custom fetch, for proxies, instrumentation or tests. |
|
|
54
|
+
| `timeoutMs` | `10000` | Per-attempt timeout. |
|
|
55
|
+
| `maxRetries` | `2` | Retries on network errors and 429/502/503/504, with backoff. |
|
|
56
|
+
| `retryDelayMs` | `200` | Base backoff, doubled per attempt. |
|
|
57
|
+
| `headers` | - | Extra headers on every request. |
|
|
58
|
+
| `verifier` | - | Overrides for the built-in verifier (see below). |
|
|
59
|
+
|
|
60
|
+
Route creation is not idempotent: if a create succeeded but its response was lost on the way back,
|
|
61
|
+
the retry creates a second route and the first is orphaned. That is harmless - an unused route is a
|
|
62
|
+
row nobody ever calls back on - but set `maxRetries: 0` if you would rather see the failure.
|
|
63
|
+
|
|
64
|
+
## Verifying a callback
|
|
65
|
+
|
|
66
|
+
The router signs every forwarded callback with an EdDSA (Ed25519) JWT in the
|
|
67
|
+
`X-Payment-Router-Signature` header, and publishes the public keys as a JWKS. The client checks both
|
|
68
|
+
the signature **and** `body_sha256`, so the signature covers the payload and not just its
|
|
69
|
+
provenance. Key rotation on the router is transparent - the key set is fetched by `kid` and cached.
|
|
70
|
+
|
|
71
|
+
Verification needs the **raw bytes** as they arrived. Re-serialized JSON changes the hash, and the
|
|
72
|
+
callback will be rejected.
|
|
73
|
+
|
|
74
|
+
The same client does it - three methods, same checks, different shape of input. The key set is
|
|
75
|
+
cached per client, so keep one around instead of building one per request. A service that only
|
|
76
|
+
receives callbacks and never creates routes builds the client just the same.
|
|
77
|
+
|
|
78
|
+
### Express, Fastify, Koa, anything else
|
|
79
|
+
|
|
80
|
+
`verifyWebhook(headers, rawBody)` takes the headers and the raw body and gives the same raw body
|
|
81
|
+
back - or throws. No middleware, no framework types.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import express from "express";
|
|
85
|
+
import { PaymentRouterClient } from "@fanth/payment-router-sdk";
|
|
86
|
+
|
|
87
|
+
const router = new PaymentRouterClient(); // or { baseUrl } for your own worker
|
|
88
|
+
|
|
89
|
+
// express.json() throws the raw bytes away, so parse them yourself after verifying
|
|
90
|
+
app.post("/webhooks/payu", express.raw({ type: "*/*" }), async (req, res) => {
|
|
91
|
+
let rawBody: string;
|
|
92
|
+
try {
|
|
93
|
+
rawBody = await router.verifyWebhook(req.headers, req.body);
|
|
94
|
+
} catch {
|
|
95
|
+
return res.sendStatus(401);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await markPaid(JSON.parse(rawBody));
|
|
99
|
+
res.sendStatus(200);
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Both header shapes work: Node's plain object and fetch's `Headers`.
|
|
104
|
+
|
|
105
|
+
### Fetch-API handlers (Workers, Hono, Next.js, Deno, Bun)
|
|
106
|
+
|
|
107
|
+
`verifyRequest(request)` reads the header and the body off a `Request` for you, and returns the
|
|
108
|
+
claims alongside the body:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
export async function POST(request: Request) {
|
|
112
|
+
try {
|
|
113
|
+
const { claims, rawBody } = await router.verifyRequest(request);
|
|
114
|
+
const notification = JSON.parse(rawBody);
|
|
115
|
+
// claims.routeId / claims.externalId say which of your payments this is
|
|
116
|
+
await markPaid(claims.externalId, notification);
|
|
117
|
+
return new Response("OK");
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return new Response("unauthorized", { status: 401 });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Anything the other two do not fit
|
|
125
|
+
|
|
126
|
+
`verifyCallback()` takes the two things that actually matter and returns the claims:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
const { claims, rawBody } = await router.verifyCallback({
|
|
130
|
+
token: req.headers["x-payment-router-signature"],
|
|
131
|
+
rawBody, // string or Uint8Array, exactly as received
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`readRawBody(req)` drains a plain `http.IncomingMessage` when there is no body parser at all.
|
|
136
|
+
|
|
137
|
+
### Options
|
|
138
|
+
|
|
139
|
+
Verifier settings go under the client's `verifier` option:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
const router = new PaymentRouterClient({ verifier: { maxAgeSeconds: 60 } });
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
| Option | Default | What it does |
|
|
146
|
+
| ----------------------- | -------------------- | ------------------------------------------------------------- |
|
|
147
|
+
| `issuer` | the client's baseUrl | Override when it differs from the router's `PUBLIC_BASE_URL`. |
|
|
148
|
+
| `jwksUrl` | baseUrl + JWKS path | Override the key set location. |
|
|
149
|
+
| `maxAgeSeconds` | `300` | Reject tokens older than this - the router's own TTL. |
|
|
150
|
+
| `clockToleranceSeconds` | `5` | Leeway for clock skew. |
|
|
151
|
+
| `fetch` | the client's fetch | Custom fetch for the JWKS request. |
|
|
152
|
+
| `keyStore` | remote JWKS | Swap the key source, e.g. jose's `createLocalJWKSet`. |
|
|
153
|
+
|
|
154
|
+
`baseUrl` is inherited from the client, so one origin covers route creation and verification alike.
|
|
155
|
+
|
|
156
|
+
### Verified claims
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
type CallbackClaims = {
|
|
160
|
+
routeId: string; // the id you gave the gateway
|
|
161
|
+
gateway: string; // "payu"
|
|
162
|
+
externalId: string | null; // your own id, if you set one
|
|
163
|
+
bodySha256: string; // already checked against the body
|
|
164
|
+
issuer: string;
|
|
165
|
+
issuedAt: Date;
|
|
166
|
+
expiresAt: Date;
|
|
167
|
+
raw: JWTPayload;
|
|
168
|
+
};
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The router also copies `routeId`, `gateway` and `externalId` into `X-Payment-Router-*` headers
|
|
172
|
+
(exported as `ROUTE_ID_HEADER` & co). They are convenience only - anyone who can reach your webhook
|
|
173
|
+
can set them. Log them, decide on the claims.
|
|
174
|
+
|
|
175
|
+
If the router runs without `WEBHOOK_SIGNING_PRIVATE_KEY` there is no signature to verify and the
|
|
176
|
+
JWKS endpoint 404s: every callback then fails with `missing_signature`, which is the honest answer.
|
|
177
|
+
|
|
178
|
+
## Errors
|
|
179
|
+
|
|
180
|
+
Everything thrown extends `PaymentRouterError`.
|
|
181
|
+
|
|
182
|
+
| Error | When |
|
|
183
|
+
| ------------------------------ | ------------------------------------------------------------- |
|
|
184
|
+
| `PaymentRouterValidationError` | Bad input, caught client-side before anything was sent. |
|
|
185
|
+
| `InvalidWebhookUrlError` | `422` - the router refused the URL (not https, private host). |
|
|
186
|
+
| `RateLimitedError` | `429` - the router's per-IP limit on route creation. |
|
|
187
|
+
| `PaymentRouterApiError` | Any other non-2xx; carries `status`, `code`, `body`. |
|
|
188
|
+
| `PaymentRouterNetworkError` | No response at all: DNS, TCP, TLS, timeout. |
|
|
189
|
+
| `CallbackVerificationError` | A callback that cannot be trusted; `reason` says why. |
|
|
190
|
+
|
|
191
|
+
`CallbackVerificationError.reason` is one of `missing_signature`, `invalid_signature`,
|
|
192
|
+
`body_mismatch`, `malformed_claims`, `body_already_consumed`.
|
|
193
|
+
|
|
194
|
+
## Development
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
pnpm install
|
|
198
|
+
pnpm test # vitest, signs real Ed25519 tokens against a fake JWKS
|
|
199
|
+
pnpm run type-check
|
|
200
|
+
pnpm run build # tsup -> dist/ (esm + cjs + d.ts)
|
|
201
|
+
```
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var jose = require('jose');
|
|
4
|
+
|
|
5
|
+
// src/defaults.ts
|
|
6
|
+
var DEFAULT_BASE_URL = "https://payment-r.fanth.pl";
|
|
7
|
+
|
|
8
|
+
// src/errors.ts
|
|
9
|
+
var PaymentRouterError = class extends Error {
|
|
10
|
+
constructor(message, options) {
|
|
11
|
+
super(message, options);
|
|
12
|
+
this.name = new.target.name;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
var PaymentRouterApiError = class extends PaymentRouterError {
|
|
16
|
+
status;
|
|
17
|
+
/** The `error` field of the response body, when the router sent one. */
|
|
18
|
+
code;
|
|
19
|
+
/** The parsed (or raw string) response body, for anything the typed fields do not cover. */
|
|
20
|
+
body;
|
|
21
|
+
constructor(message, status, code, body) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.status = status;
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.body = body;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
var RateLimitedError = class extends PaymentRouterApiError {
|
|
29
|
+
};
|
|
30
|
+
var InvalidWebhookUrlError = class extends PaymentRouterApiError {
|
|
31
|
+
/** The router's human-readable reason, e.g. "webhookUrl must use https". */
|
|
32
|
+
get reason() {
|
|
33
|
+
return this.code ?? this.message;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
var PaymentRouterNetworkError = class extends PaymentRouterError {
|
|
37
|
+
};
|
|
38
|
+
var PaymentRouterValidationError = class extends PaymentRouterError {
|
|
39
|
+
};
|
|
40
|
+
var CallbackVerificationError = class extends PaymentRouterError {
|
|
41
|
+
/** Machine-readable failure reason, for logging and metrics. */
|
|
42
|
+
reason;
|
|
43
|
+
constructor(reason, message, options) {
|
|
44
|
+
super(message, options);
|
|
45
|
+
this.reason = reason;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// src/http.ts
|
|
50
|
+
var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 502, 503, 504]);
|
|
51
|
+
async function requestJson(options, path, init) {
|
|
52
|
+
const url = new URL(path, ensureTrailingSlash(options.baseUrl)).toString();
|
|
53
|
+
let lastError;
|
|
54
|
+
for (let attempt = 0; attempt <= options.maxRetries; attempt++) {
|
|
55
|
+
if (attempt > 0) {
|
|
56
|
+
await sleep(options.retryDelayMs * 2 ** (attempt - 1));
|
|
57
|
+
}
|
|
58
|
+
let response;
|
|
59
|
+
try {
|
|
60
|
+
response = await options.fetch(url, {
|
|
61
|
+
...init,
|
|
62
|
+
headers: { ...options.headers, ...init.headers },
|
|
63
|
+
signal: init.signal ?? AbortSignal.timeout(options.timeoutMs)
|
|
64
|
+
});
|
|
65
|
+
} catch (error2) {
|
|
66
|
+
lastError = new PaymentRouterNetworkError(`request to ${url} failed: ${describe(error2)}`, { cause: error2 });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (response.ok) {
|
|
70
|
+
return await response.json();
|
|
71
|
+
}
|
|
72
|
+
const error = await toApiError(response);
|
|
73
|
+
if (!RETRYABLE_STATUSES.has(response.status)) {
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
lastError = error;
|
|
77
|
+
}
|
|
78
|
+
throw lastError ?? new PaymentRouterNetworkError(`request to ${url} failed for an unknown reason`);
|
|
79
|
+
}
|
|
80
|
+
async function toApiError(response) {
|
|
81
|
+
const body = await readBody(response);
|
|
82
|
+
const code = extractCode(body);
|
|
83
|
+
const message = `payment-router responded ${response.status}${code ? `: ${code}` : ""}`;
|
|
84
|
+
if (response.status === 429) return new RateLimitedError(message, response.status, code, body);
|
|
85
|
+
if (response.status === 422) return new InvalidWebhookUrlError(message, response.status, code, body);
|
|
86
|
+
return new PaymentRouterApiError(message, response.status, code, body);
|
|
87
|
+
}
|
|
88
|
+
async function readBody(response) {
|
|
89
|
+
const text = await response.text().catch(() => "");
|
|
90
|
+
if (text.length === 0) return null;
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(text);
|
|
93
|
+
} catch {
|
|
94
|
+
return text;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function extractCode(body) {
|
|
98
|
+
if (typeof body !== "object" || body === null) return null;
|
|
99
|
+
const error = body.error;
|
|
100
|
+
if (typeof error === "string") return error;
|
|
101
|
+
const issues = error?.issues;
|
|
102
|
+
const first = issues?.[0]?.message;
|
|
103
|
+
return typeof first === "string" ? first : null;
|
|
104
|
+
}
|
|
105
|
+
function describe(error) {
|
|
106
|
+
if (error instanceof Error) return `${error.name}: ${error.message}`;
|
|
107
|
+
return "unknown error";
|
|
108
|
+
}
|
|
109
|
+
function ensureTrailingSlash(url) {
|
|
110
|
+
return url.endsWith("/") ? url : `${url}/`;
|
|
111
|
+
}
|
|
112
|
+
function sleep(ms) {
|
|
113
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/headers.ts
|
|
117
|
+
var SIGNATURE_HEADER = "x-payment-router-signature";
|
|
118
|
+
var ROUTE_ID_HEADER = "x-payment-router-id";
|
|
119
|
+
var GATEWAY_HEADER = "x-payment-router-gateway";
|
|
120
|
+
var EXTERNAL_ID_HEADER = "x-payment-router-external-id";
|
|
121
|
+
var FORWARDED_HOST_HEADER = "x-forwarded-host";
|
|
122
|
+
|
|
123
|
+
// src/verify.ts
|
|
124
|
+
var DEFAULT_MAX_AGE_SECONDS = 300;
|
|
125
|
+
var CallbackVerifier = class {
|
|
126
|
+
issuer;
|
|
127
|
+
maxAgeSeconds;
|
|
128
|
+
clockToleranceSeconds;
|
|
129
|
+
getKey;
|
|
130
|
+
constructor(options = {}) {
|
|
131
|
+
const baseUrl = stripTrailingSlash(options.baseUrl ?? DEFAULT_BASE_URL);
|
|
132
|
+
this.issuer = options.issuer ?? baseUrl;
|
|
133
|
+
this.maxAgeSeconds = options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
|
|
134
|
+
this.clockToleranceSeconds = options.clockToleranceSeconds ?? 5;
|
|
135
|
+
this.getKey = options.keyStore ?? jose.createRemoteJWKSet(new URL(options.jwksUrl ?? jwksUrlFor(baseUrl)), {
|
|
136
|
+
...options.fetch ? { [jose.customFetch]: options.fetch } : {}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Verify a token against the body it is supposed to cover.
|
|
141
|
+
*
|
|
142
|
+
* @throws {CallbackVerificationError} always, when the callback cannot be trusted.
|
|
143
|
+
*/
|
|
144
|
+
async verify(input) {
|
|
145
|
+
const token = input.token?.trim();
|
|
146
|
+
if (!token) {
|
|
147
|
+
throw new CallbackVerificationError("missing_signature", `no ${SIGNATURE_HEADER} header on the callback`);
|
|
148
|
+
}
|
|
149
|
+
let payload;
|
|
150
|
+
try {
|
|
151
|
+
({ payload } = await jose.jwtVerify(token, this.getKey, {
|
|
152
|
+
issuer: this.issuer,
|
|
153
|
+
algorithms: ["EdDSA"],
|
|
154
|
+
maxTokenAge: this.maxAgeSeconds,
|
|
155
|
+
clockTolerance: this.clockToleranceSeconds
|
|
156
|
+
}));
|
|
157
|
+
} catch (error) {
|
|
158
|
+
throw new CallbackVerificationError("invalid_signature", `callback signature is not valid: ${describe2(error)}`, {
|
|
159
|
+
cause: error
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const claims = toClaims(payload);
|
|
163
|
+
const actual = await sha256Hex(input.rawBody);
|
|
164
|
+
if (!timingSafeEqual(actual, claims.bodySha256)) {
|
|
165
|
+
throw new CallbackVerificationError(
|
|
166
|
+
"body_mismatch",
|
|
167
|
+
"callback body does not match the body_sha256 claim - pass the raw bytes you received, not re-serialized JSON"
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return { claims, rawBody: toText(input.rawBody) };
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Verify a fetch-API `Request` (Workers, Hono, Next.js route handlers, Deno, Bun).
|
|
174
|
+
*
|
|
175
|
+
* The body is read here, so do not read it yourself first - clone the request if you need it.
|
|
176
|
+
*/
|
|
177
|
+
async verifyRequest(request) {
|
|
178
|
+
if (request.bodyUsed) {
|
|
179
|
+
throw new CallbackVerificationError(
|
|
180
|
+
"body_already_consumed",
|
|
181
|
+
"request body was already read - pass a request.clone() or use verify({ token, rawBody })"
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return this.verify({ token: request.headers.get(SIGNATURE_HEADER), rawBody: await request.text() });
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The plain version, for any framework: headers and the raw body in, the same raw body back out,
|
|
188
|
+
* or it throws. Handy when all you want is a guard at the top of a webhook handler.
|
|
189
|
+
*
|
|
190
|
+
* The body must be the raw bytes as they arrived - a parsed-and-re-serialized JSON body hashes
|
|
191
|
+
* differently and will be rejected. Use {@link verify} when you also want the claims.
|
|
192
|
+
*/
|
|
193
|
+
async verifyWebhook(headers, rawBody) {
|
|
194
|
+
const { rawBody: body } = await this.verify({ token: readHeader(headers, SIGNATURE_HEADER), rawBody });
|
|
195
|
+
return body;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
function jwksUrlFor(baseUrl) {
|
|
199
|
+
return new URL("/.well-known/jwks.json", baseUrl).toString();
|
|
200
|
+
}
|
|
201
|
+
function readHeader(headers, name) {
|
|
202
|
+
if (typeof headers.get === "function") {
|
|
203
|
+
return headers.get(name);
|
|
204
|
+
}
|
|
205
|
+
const bag = headers;
|
|
206
|
+
const value = bag[name] ?? bag[name.toLowerCase()] ?? bag[name.toUpperCase()];
|
|
207
|
+
if (Array.isArray(value)) return value[0] ?? null;
|
|
208
|
+
return value ?? null;
|
|
209
|
+
}
|
|
210
|
+
function toClaims(payload) {
|
|
211
|
+
const routeId = payload.route_id;
|
|
212
|
+
const gateway = payload.gateway;
|
|
213
|
+
const bodySha256 = payload.body_sha256;
|
|
214
|
+
const externalId = payload.external_id;
|
|
215
|
+
if (typeof routeId !== "string" || typeof gateway !== "string" || typeof bodySha256 !== "string") {
|
|
216
|
+
throw new CallbackVerificationError("malformed_claims", "callback token is missing route_id, gateway or body_sha256");
|
|
217
|
+
}
|
|
218
|
+
if (typeof payload.iat !== "number" || typeof payload.exp !== "number") {
|
|
219
|
+
throw new CallbackVerificationError("malformed_claims", "callback token is missing iat or exp");
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
routeId,
|
|
223
|
+
gateway,
|
|
224
|
+
externalId: typeof externalId === "string" ? externalId : null,
|
|
225
|
+
bodySha256,
|
|
226
|
+
issuer: payload.iss ?? "",
|
|
227
|
+
issuedAt: new Date(payload.iat * 1e3),
|
|
228
|
+
expiresAt: new Date(payload.exp * 1e3),
|
|
229
|
+
raw: payload
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
async function sha256Hex(data) {
|
|
233
|
+
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
234
|
+
const subtle = globalThis.crypto?.subtle;
|
|
235
|
+
if (!subtle) {
|
|
236
|
+
throw new PaymentRouterValidationError("WebCrypto is unavailable - Node 18+, Workers, Deno and Bun all provide it");
|
|
237
|
+
}
|
|
238
|
+
const digest = await subtle.digest("SHA-256", bytes);
|
|
239
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
240
|
+
}
|
|
241
|
+
function timingSafeEqual(a, b) {
|
|
242
|
+
if (a.length !== b.length) return false;
|
|
243
|
+
let diff = 0;
|
|
244
|
+
for (let i = 0; i < a.length; i++) {
|
|
245
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
246
|
+
}
|
|
247
|
+
return diff === 0;
|
|
248
|
+
}
|
|
249
|
+
function toText(body) {
|
|
250
|
+
return typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
251
|
+
}
|
|
252
|
+
function describe2(error) {
|
|
253
|
+
if (error instanceof Error) return error.message;
|
|
254
|
+
return "unknown error";
|
|
255
|
+
}
|
|
256
|
+
function stripTrailingSlash(url) {
|
|
257
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/client.ts
|
|
261
|
+
var DEFAULTS = {
|
|
262
|
+
timeoutMs: 1e4,
|
|
263
|
+
maxRetries: 2,
|
|
264
|
+
retryDelayMs: 200
|
|
265
|
+
};
|
|
266
|
+
var PaymentRouterClient = class {
|
|
267
|
+
baseUrl;
|
|
268
|
+
http;
|
|
269
|
+
verifierOptions;
|
|
270
|
+
cachedVerifier = null;
|
|
271
|
+
constructor(options = {}) {
|
|
272
|
+
this.baseUrl = stripTrailingSlash2(assertAbsoluteUrl(options.baseUrl ?? DEFAULT_BASE_URL, "baseUrl"));
|
|
273
|
+
this.verifierOptions = {
|
|
274
|
+
// The client's own fetch carries whatever a proxy in front of the router needs, and the
|
|
275
|
+
// JWKS lives on that same origin - so it fits the key fetch too, unless overridden.
|
|
276
|
+
...options.fetch ? { fetch: options.fetch } : {},
|
|
277
|
+
...options.verifier
|
|
278
|
+
};
|
|
279
|
+
this.http = {
|
|
280
|
+
baseUrl: this.baseUrl,
|
|
281
|
+
fetch: options.fetch ?? defaultFetch(),
|
|
282
|
+
timeoutMs: options.timeoutMs ?? DEFAULTS.timeoutMs,
|
|
283
|
+
maxRetries: options.maxRetries ?? DEFAULTS.maxRetries,
|
|
284
|
+
retryDelayMs: options.retryDelayMs ?? DEFAULTS.retryDelayMs,
|
|
285
|
+
headers: {
|
|
286
|
+
"Content-Type": "application/json",
|
|
287
|
+
Accept: "application/json",
|
|
288
|
+
...options.headers
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Register a webhook URL and get back the route whose `id` you send to the gateway as its
|
|
294
|
+
* external payment id.
|
|
295
|
+
*
|
|
296
|
+
* @throws {InvalidWebhookUrlError} the router refused the URL (not https, or a private host).
|
|
297
|
+
* @throws {RateLimitedError} the router's per-IP limit on route creation was hit.
|
|
298
|
+
*/
|
|
299
|
+
async createRoute(input) {
|
|
300
|
+
const payload = {
|
|
301
|
+
webhookUrl: assertAbsoluteUrl(input.webhookUrl, "webhookUrl"),
|
|
302
|
+
...input.externalId !== void 0 ? { externalId: assertExternalId(input.externalId) } : {},
|
|
303
|
+
...input.expiresAt !== void 0 ? { expiresAt: toIsoString(input.expiresAt) } : {}
|
|
304
|
+
};
|
|
305
|
+
const route = await requestJson(this.http, "v1/routes", {
|
|
306
|
+
method: "POST",
|
|
307
|
+
body: JSON.stringify(payload)
|
|
308
|
+
});
|
|
309
|
+
return parseRoute(route);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* The URL to paste into a gateway's dashboard as its single webhook URL. The same for every
|
|
313
|
+
* route, so it can be read off the client without creating one.
|
|
314
|
+
*/
|
|
315
|
+
callbackUrl(gateway) {
|
|
316
|
+
return new URL(`webhooks/${encodeURIComponent(gateway)}`, `${this.baseUrl}/`).toString();
|
|
317
|
+
}
|
|
318
|
+
/** Where the router publishes the public keys that verify forwarded callbacks. */
|
|
319
|
+
get jwksUrl() {
|
|
320
|
+
return jwksUrlFor(this.baseUrl);
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Headers and the raw body in, the same raw body back out - or it throws. The short way to guard
|
|
324
|
+
* a webhook handler: verifies the router's EdDSA JWT against its published keys, and checks that
|
|
325
|
+
* the body still hashes to what the token claims.
|
|
326
|
+
*
|
|
327
|
+
* The body must be the raw bytes as they arrived - parsed-and-re-serialized JSON hashes
|
|
328
|
+
* differently and will be rejected. Use {@link verifyCallback} when you also want the claims.
|
|
329
|
+
*
|
|
330
|
+
* @throws {CallbackVerificationError} when the callback cannot be trusted.
|
|
331
|
+
*/
|
|
332
|
+
async verifyWebhook(headers, rawBody) {
|
|
333
|
+
return this.verifier().verifyWebhook(headers, rawBody);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Verify a fetch-API `Request` (Workers, Hono, Next.js, Deno, Bun) and get its claims back.
|
|
337
|
+
*
|
|
338
|
+
* The body is read here, so do not read it yourself first - clone the request if you need it.
|
|
339
|
+
*/
|
|
340
|
+
async verifyRequest(request) {
|
|
341
|
+
return this.verifier().verifyRequest(request);
|
|
342
|
+
}
|
|
343
|
+
/** Verify a token against the body it covers, and get the claims back. */
|
|
344
|
+
async verifyCallback(input) {
|
|
345
|
+
return this.verifier().verify(input);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Built on first use and kept around: the verifier caches the router's key set, so reusing one
|
|
349
|
+
* client saves a JWKS fetch per callback.
|
|
350
|
+
*/
|
|
351
|
+
verifier() {
|
|
352
|
+
this.cachedVerifier ??= new CallbackVerifier({ baseUrl: this.baseUrl, ...this.verifierOptions });
|
|
353
|
+
return this.cachedVerifier;
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
function parseRoute(raw) {
|
|
357
|
+
return {
|
|
358
|
+
id: raw.id,
|
|
359
|
+
webhookUrl: raw.webhookUrl,
|
|
360
|
+
externalId: raw.externalId ?? null,
|
|
361
|
+
expiresAt: raw.expiresAt ? new Date(raw.expiresAt) : null,
|
|
362
|
+
createdAt: new Date(raw.createdAt),
|
|
363
|
+
callbackUrls: raw.callbackUrls ?? null
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function isRouteExpired(route, now = /* @__PURE__ */ new Date()) {
|
|
367
|
+
return route.expiresAt !== null && route.expiresAt.getTime() < now.getTime();
|
|
368
|
+
}
|
|
369
|
+
function callbackUrlFor(route, gateway) {
|
|
370
|
+
return route.callbackUrls?.[gateway] ?? null;
|
|
371
|
+
}
|
|
372
|
+
function assertAbsoluteUrl(value, field) {
|
|
373
|
+
let url;
|
|
374
|
+
try {
|
|
375
|
+
url = new URL(value);
|
|
376
|
+
} catch {
|
|
377
|
+
throw new PaymentRouterValidationError(`${field} must be an absolute URL, got "${value}"`);
|
|
378
|
+
}
|
|
379
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
380
|
+
throw new PaymentRouterValidationError(`${field} must use http or https, got "${url.protocol}"`);
|
|
381
|
+
}
|
|
382
|
+
return value;
|
|
383
|
+
}
|
|
384
|
+
function assertExternalId(value) {
|
|
385
|
+
if (value.length === 0 || value.length > 256) {
|
|
386
|
+
throw new PaymentRouterValidationError("externalId must be between 1 and 256 characters");
|
|
387
|
+
}
|
|
388
|
+
return value;
|
|
389
|
+
}
|
|
390
|
+
function toIsoString(value) {
|
|
391
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
392
|
+
if (Number.isNaN(date.getTime())) {
|
|
393
|
+
throw new PaymentRouterValidationError(`expiresAt is not a valid date: ${String(value)}`);
|
|
394
|
+
}
|
|
395
|
+
return date.toISOString();
|
|
396
|
+
}
|
|
397
|
+
function defaultFetch() {
|
|
398
|
+
if (typeof globalThis.fetch !== "function") {
|
|
399
|
+
throw new PaymentRouterValidationError(
|
|
400
|
+
"no global fetch available - pass one via the `fetch` option (Node 18+ has it built in)"
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
return globalThis.fetch.bind(globalThis);
|
|
404
|
+
}
|
|
405
|
+
function stripTrailingSlash2(url) {
|
|
406
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// src/node.ts
|
|
410
|
+
async function readRawBody(request) {
|
|
411
|
+
const chunks = [];
|
|
412
|
+
const encoder = new TextEncoder();
|
|
413
|
+
for await (const chunk of request) {
|
|
414
|
+
chunks.push(typeof chunk === "string" ? encoder.encode(chunk) : chunk);
|
|
415
|
+
}
|
|
416
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
417
|
+
const body = new Uint8Array(total);
|
|
418
|
+
let offset = 0;
|
|
419
|
+
for (const chunk of chunks) {
|
|
420
|
+
body.set(chunk, offset);
|
|
421
|
+
offset += chunk.byteLength;
|
|
422
|
+
}
|
|
423
|
+
return new TextDecoder().decode(body);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
exports.CallbackVerificationError = CallbackVerificationError;
|
|
427
|
+
exports.DEFAULT_BASE_URL = DEFAULT_BASE_URL;
|
|
428
|
+
exports.EXTERNAL_ID_HEADER = EXTERNAL_ID_HEADER;
|
|
429
|
+
exports.FORWARDED_HOST_HEADER = FORWARDED_HOST_HEADER;
|
|
430
|
+
exports.GATEWAY_HEADER = GATEWAY_HEADER;
|
|
431
|
+
exports.InvalidWebhookUrlError = InvalidWebhookUrlError;
|
|
432
|
+
exports.PaymentRouterApiError = PaymentRouterApiError;
|
|
433
|
+
exports.PaymentRouterClient = PaymentRouterClient;
|
|
434
|
+
exports.PaymentRouterError = PaymentRouterError;
|
|
435
|
+
exports.PaymentRouterNetworkError = PaymentRouterNetworkError;
|
|
436
|
+
exports.PaymentRouterValidationError = PaymentRouterValidationError;
|
|
437
|
+
exports.ROUTE_ID_HEADER = ROUTE_ID_HEADER;
|
|
438
|
+
exports.RateLimitedError = RateLimitedError;
|
|
439
|
+
exports.SIGNATURE_HEADER = SIGNATURE_HEADER;
|
|
440
|
+
exports.callbackUrlFor = callbackUrlFor;
|
|
441
|
+
exports.isRouteExpired = isRouteExpired;
|
|
442
|
+
exports.jwksUrlFor = jwksUrlFor;
|
|
443
|
+
exports.parseRoute = parseRoute;
|
|
444
|
+
exports.readHeader = readHeader;
|
|
445
|
+
exports.readRawBody = readRawBody;
|
|
446
|
+
exports.sha256Hex = sha256Hex;
|
|
447
|
+
//# sourceMappingURL=index.cjs.map
|
|
448
|
+
//# sourceMappingURL=index.cjs.map
|