@fanth/payment-router-sdk 0.1.1 → 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/dist/index.cjs CHANGED
@@ -1,448 +1,88 @@
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;
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
13
15
  }
16
+ return to;
14
17
  };
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) {
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ PaymentRouterClient: () => PaymentRouterClient,
24
+ PaymentRouterError: () => PaymentRouterError
25
+ });
26
+ module.exports = __toCommonJS(index_exports);
27
+ var import_jose = require("jose");
28
+ var PaymentRouterError = class extends Error {
29
+ constructor(message, status, body) {
22
30
  super(message);
23
31
  this.status = status;
24
- this.code = code;
25
32
  this.body = body;
26
33
  }
27
34
  };
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
35
  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);
36
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data));
37
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
251
38
  }
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
- };
39
+ var DEFAULT_BASE_URL = "https://payment-r.fanth.pl";
266
40
  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", {
41
+ #baseUrl;
42
+ #jwks;
43
+ constructor(baseUrl = DEFAULT_BASE_URL) {
44
+ this.#baseUrl = baseUrl.replace(/\/+$/, "");
45
+ this.#jwks = (0, import_jose.createRemoteJWKSet)(new URL("/.well-known/jwks.json", this.#baseUrl));
46
+ }
47
+ /** Register a webhook URL, get back the route id to hand the gateway plus its callback URLs. */
48
+ async createRoute(options) {
49
+ const res = await fetch(new URL("/v1/routes", this.#baseUrl), {
306
50
  method: "POST",
307
- body: JSON.stringify(payload)
51
+ headers: { "Content-Type": "application/json" },
52
+ body: JSON.stringify({
53
+ webhookUrl: options.webhookUrl,
54
+ externalId: options.externalId,
55
+ expiresAt: options.expiresAt instanceof Date ? options.expiresAt.toISOString() : options.expiresAt
56
+ })
308
57
  });
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);
58
+ const data = await res.json();
59
+ if (!res.ok) {
60
+ const message = data?.error ?? "request_failed";
61
+ throw new PaymentRouterError(message, res.status, data);
62
+ }
63
+ return data;
346
64
  }
347
65
  /**
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.
66
+ * Verify the `X-Payment-Router-Signature` JWT on a forwarded callback. `rawBody` must be the
67
+ * exact bytes received, since the signature binds `body_sha256`. Throws if the token is
68
+ * invalid, expired, or the body does not match what was signed.
350
69
  */
351
- verifier() {
352
- this.cachedVerifier ??= new CallbackVerifier({ baseUrl: this.baseUrl, ...this.verifierOptions });
353
- return this.cachedVerifier;
70
+ async verifyCallback(token, rawBody) {
71
+ const { payload } = await (0, import_jose.jwtVerify)(token, this.#jwks, { issuer: this.#baseUrl });
72
+ const bodySha256 = await sha256Hex(rawBody);
73
+ if (payload.body_sha256 !== bodySha256) {
74
+ throw new Error("callback body does not match signature");
75
+ }
76
+ return {
77
+ routeId: payload.route_id,
78
+ gateway: payload.gateway,
79
+ externalId: payload.external_id ?? null,
80
+ bodySha256
81
+ };
354
82
  }
355
83
  };
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
84
+ // Annotate the CommonJS export names for ESM import in node:
85
+ 0 && (module.exports = {
86
+ PaymentRouterClient,
87
+ PaymentRouterError
88
+ });