@fanth/payment-router-sdk 0.1.0 → 0.1.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
@@ -1,201 +1,49 @@
1
- # @fanth/payment-router-sdk
1
+ # payment-router-sdk
2
2
 
3
- TypeScript SDK for **payment-router** - the URL shortener for payment gateway webhooks.
3
+ TypeScript SDK for payment-router: create webhook routes and verify the signed callbacks it forwards.
4
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`).
5
+ ## Install
14
6
 
15
7
  ```bash
16
- npm install @fanth/payment-router-sdk
8
+ npm install payment-router-sdk
17
9
  ```
18
10
 
19
- ## Creating a route
11
+ ## Usage
20
12
 
21
13
  ```ts
22
- import { PaymentRouterClient } from "@fanth/payment-router-sdk";
14
+ import { PaymentRouterClient } from "payment-router-sdk";
23
15
 
24
- const router = new PaymentRouterClient(); // https://payment-r.fanth.pl
16
+ const router = new PaymentRouterClient("https://router.example.com");
25
17
 
18
+ // Register the real webhook URL, get back the id to send the gateway plus its callback URLs.
26
19
  const route = await router.createRoute({
27
20
  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
21
+ externalId: "order-123", // optional, echoed back on the callback
22
+ expiresAt: "2026-07-12T10:00:00Z", // optional, defaults to 12h out
30
23
  });
31
24
 
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
25
+ route.id; // send this to the gateway as its external payment id
26
+ route.callbackUrls; // { payu: "https://router.example.com/webhooks/payu" }
46
27
  ```
47
28
 
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.
29
+ In the receiving webhook handler, verify the forwarded callback's `X-Payment-Router-Signature`
30
+ header before trusting it:
82
31
 
83
32
  ```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`.
33
+ const token = req.headers["x-payment-router-signature"];
34
+ const rawBody = req.rawBody; // must be the exact bytes received
104
35
 
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
- }
36
+ const { routeId, gateway, externalId } = await router.verifyCallback(token, rawBody);
122
37
  ```
123
38
 
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`.
39
+ `verifyCallback` throws if the token is invalid, expired, issued by a different router, or the body
40
+ does not match what was signed.
193
41
 
194
42
  ## Development
195
43
 
196
44
  ```bash
197
45
  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)
46
+ pnpm test # node:test, run via tsx
47
+ pnpm type-check
48
+ pnpm build # tsup -> dist/ (esm + cjs + d.ts)
201
49
  ```