paykit-bd 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.
@@ -0,0 +1,379 @@
1
+ import { createVerify } from 'crypto';
2
+
3
+ // src/core/errors.ts
4
+ var BRANDS = /* @__PURE__ */ Symbol.for("paykit-bd.error.brands");
5
+ function brandedInstanceOf(tag) {
6
+ return (value) => {
7
+ if (typeof value !== "object" || value === null) return false;
8
+ const brands = value[BRANDS];
9
+ return Array.isArray(brands) && brands.includes(tag);
10
+ };
11
+ }
12
+ var PaykitError = class extends Error {
13
+ static [Symbol.hasInstance] = brandedInstanceOf("PaykitError");
14
+ /** Class lineage, innermost last. Read `instanceof` instead of this. */
15
+ [BRANDS];
16
+ /** Gateway this came from — `"bkash"`, or `"paykit"` for local failures. */
17
+ provider;
18
+ /** Stable machine-readable code. Gateway codes are passed through verbatim. */
19
+ code;
20
+ /** True when retrying the identical request could plausibly succeed. */
21
+ retryable;
22
+ /** Untouched gateway response body, for logging. */
23
+ raw;
24
+ constructor(message, opts, brands = []) {
25
+ super(message, { cause: opts.cause });
26
+ this.name = new.target.name;
27
+ this[BRANDS] = ["PaykitError", ...brands];
28
+ this.provider = opts.provider;
29
+ this.code = opts.code;
30
+ this.retryable = opts.retryable ?? false;
31
+ this.raw = opts.raw;
32
+ }
33
+ toJSON() {
34
+ return {
35
+ name: this.name,
36
+ provider: this.provider,
37
+ code: this.code,
38
+ message: this.message,
39
+ retryable: this.retryable
40
+ };
41
+ }
42
+ };
43
+ var WebhookVerificationError = class extends PaykitError {
44
+ static [Symbol.hasInstance] = brandedInstanceOf("WebhookVerificationError");
45
+ constructor(message, opts) {
46
+ super(
47
+ message,
48
+ {
49
+ provider: opts.provider,
50
+ code: opts.code ?? "webhook_verification_failed",
51
+ retryable: false,
52
+ raw: opts.raw,
53
+ cause: opts.cause
54
+ },
55
+ ["WebhookVerificationError"]
56
+ );
57
+ }
58
+ };
59
+
60
+ // src/core/http.ts
61
+ var noopLogger = {
62
+ debug() {
63
+ },
64
+ warn() {
65
+ },
66
+ error() {
67
+ }
68
+ };
69
+ function parseBkashCompactTime(value) {
70
+ if (!value) return void 0;
71
+ const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/.exec(value.trim());
72
+ if (!match) return void 0;
73
+ const [, y, mo, d, h, mi, s] = match;
74
+ const date = new Date(
75
+ Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)) - 6 * 60 * 6e4
76
+ );
77
+ return Number.isNaN(date.getTime()) ? void 0 : date;
78
+ }
79
+
80
+ // src/bkash/webhook.ts
81
+ var SNS_CERT_HOST = /^sns\.[a-z0-9-]+\.amazonaws\.com(\.cn)?$/i;
82
+ var BkashWebhookVerifier = class _BkashWebhookVerifier {
83
+ #options;
84
+ #logger;
85
+ #fetch;
86
+ #certCache = /* @__PURE__ */ new Map();
87
+ constructor(options = {}) {
88
+ this.#options = options;
89
+ this.#logger = options.logger ?? noopLogger;
90
+ this.#fetch = options.fetchImpl ?? fetch;
91
+ }
92
+ /**
93
+ * Verify an inbound request and normalise it.
94
+ *
95
+ * `request.body` must be the raw bytes as received. A body that has been
96
+ * parsed and re-serialised will not match the signature.
97
+ */
98
+ async verify(request) {
99
+ const envelope = this.#parseEnvelope(request.body);
100
+ await this.#verifySignature(envelope);
101
+ this.#checkTopic(envelope);
102
+ this.#checkAge(envelope);
103
+ return this.#normalise(envelope);
104
+ }
105
+ /**
106
+ * Confirm an SNS subscription by visiting its SubscribeURL.
107
+ *
108
+ * Deliberately not automatic: it is an outbound call that switches on real
109
+ * payment traffic, so your handler decides when to make it. Verify the
110
+ * message first — this method re-checks the URL host but assumes the
111
+ * signature was already proven.
112
+ */
113
+ async confirmSubscription(envelope) {
114
+ const url = envelope.SubscribeURL;
115
+ if (!url) {
116
+ throw new WebhookVerificationError("bKash: subscription confirmation has no SubscribeURL", {
117
+ provider: "bkash",
118
+ code: "missing_subscribe_url",
119
+ raw: envelope
120
+ });
121
+ }
122
+ assertSnsUrl(url, "SubscribeURL");
123
+ const response = await this.#fetch(url, { method: "GET" });
124
+ if (!response.ok) {
125
+ throw new WebhookVerificationError(
126
+ `bKash: SNS subscription confirmation failed with HTTP ${response.status}`,
127
+ { provider: "bkash", code: "subscribe_failed", raw: await response.text().catch(() => "") }
128
+ );
129
+ }
130
+ this.#logger.debug("paykit/bkash: SNS subscription confirmed", { topicArn: envelope.TopicArn });
131
+ }
132
+ /** Parse an already-verified envelope's inner payment message. */
133
+ static parseMessage(envelope) {
134
+ if (!envelope.Message) return null;
135
+ try {
136
+ return JSON.parse(envelope.Message);
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+ #parseEnvelope(body) {
142
+ if (!body || body.trim() === "") {
143
+ throw new WebhookVerificationError("bKash: empty webhook body", {
144
+ provider: "bkash",
145
+ code: "empty_body"
146
+ });
147
+ }
148
+ try {
149
+ return JSON.parse(body);
150
+ } catch (cause) {
151
+ throw new WebhookVerificationError("bKash: webhook body is not JSON", {
152
+ provider: "bkash",
153
+ code: "invalid_body",
154
+ raw: body.slice(0, 500),
155
+ cause
156
+ });
157
+ }
158
+ }
159
+ async #verifySignature(envelope) {
160
+ const { Signature, SignatureVersion, SigningCertURL, Type } = envelope;
161
+ if (!Signature || !SigningCertURL || !Type) {
162
+ throw new WebhookVerificationError(
163
+ "bKash: webhook is missing Type, Signature or SigningCertURL \u2014 it is not an SNS message",
164
+ { provider: "bkash", code: "not_sns", raw: envelope }
165
+ );
166
+ }
167
+ const algorithm = SignatureVersion === "2" ? "RSA-SHA256" : "RSA-SHA1";
168
+ if (SignatureVersion !== "1" && SignatureVersion !== "2") {
169
+ throw new WebhookVerificationError(
170
+ `bKash: unsupported SNS SignatureVersion ${JSON.stringify(SignatureVersion)}`,
171
+ { provider: "bkash", code: "unsupported_signature_version", raw: envelope }
172
+ );
173
+ }
174
+ assertSnsUrl(SigningCertURL, "SigningCertURL");
175
+ const pem = await this.#loadCertificate(SigningCertURL);
176
+ const canonical = canonicalString(envelope);
177
+ let valid;
178
+ try {
179
+ valid = createVerify(algorithm).update(canonical, "utf8").verify(pem, Signature, "base64");
180
+ } catch (cause) {
181
+ throw new WebhookVerificationError("bKash: SNS signature could not be checked", {
182
+ provider: "bkash",
183
+ code: "signature_check_failed",
184
+ cause
185
+ });
186
+ }
187
+ if (!valid) {
188
+ throw new WebhookVerificationError(
189
+ "bKash: SNS signature does not match the message. Treat the payload as forged, and check that the raw request body reached the verifier unmodified.",
190
+ { provider: "bkash", code: "signature_mismatch" }
191
+ );
192
+ }
193
+ }
194
+ #checkTopic(envelope) {
195
+ const expected = this.#options.topicArn;
196
+ if (!expected) {
197
+ this.#logger.warn(
198
+ "paykit/bkash: webhook accepted without a pinned topicArn \u2014 any Amazon-signed SNS topic will pass"
199
+ );
200
+ return;
201
+ }
202
+ const allowed = Array.isArray(expected) ? expected : [expected];
203
+ if (!envelope.TopicArn || !allowed.includes(envelope.TopicArn)) {
204
+ throw new WebhookVerificationError(
205
+ `bKash: message came from SNS topic ${JSON.stringify(envelope.TopicArn)}, which is not one of yours`,
206
+ { provider: "bkash", code: "topic_mismatch", raw: envelope.TopicArn }
207
+ );
208
+ }
209
+ }
210
+ #checkAge(envelope) {
211
+ const maxAgeMs = this.#options.maxAgeMs;
212
+ if (!maxAgeMs || !envelope.Timestamp) return;
213
+ const sent = new Date(envelope.Timestamp).getTime();
214
+ if (Number.isNaN(sent)) return;
215
+ if (Date.now() - sent > maxAgeMs) {
216
+ throw new WebhookVerificationError(
217
+ `bKash: message is older than the configured maxAgeMs (${maxAgeMs}ms)`,
218
+ { provider: "bkash", code: "message_too_old", raw: envelope.Timestamp }
219
+ );
220
+ }
221
+ }
222
+ #normalise(envelope) {
223
+ const base = {
224
+ provider: "bkash",
225
+ eventId: envelope.MessageId,
226
+ raw: envelope
227
+ };
228
+ if (envelope.Type === "SubscriptionConfirmation" || envelope.Type === "UnsubscribeConfirmation") {
229
+ return { ...base, type: "subscription.confirmation" };
230
+ }
231
+ const message = _BkashWebhookVerifier.parseMessage(envelope);
232
+ if (!message) return { ...base, type: "unknown" };
233
+ const completed = (message.transactionStatus ?? "").toLowerCase() === "completed";
234
+ return {
235
+ ...base,
236
+ type: completed ? "payment.completed" : "payment.failed",
237
+ transactionId: message.trxID,
238
+ reference: message.merchantInvoiceNumber ?? message.transactionReference,
239
+ amount: message.amount,
240
+ currency: message.currency ?? "BDT",
241
+ payerAccount: message.debitMSISDN,
242
+ occurredAt: parseBkashCompactTime(message.dateTime)
243
+ };
244
+ }
245
+ async #loadCertificate(url) {
246
+ const ttl = this.#options.certCacheMs ?? 24 * 60 * 60 * 1e3;
247
+ const cached = this.#certCache.get(url);
248
+ if (cached && Date.now() - cached.fetchedAt < ttl) return cached.pem;
249
+ const response = await this.#fetch(url, { method: "GET", signal: AbortSignal.timeout(1e4) });
250
+ if (!response.ok) {
251
+ throw new WebhookVerificationError(
252
+ `bKash: could not fetch the SNS signing certificate (HTTP ${response.status})`,
253
+ { provider: "bkash", code: "cert_fetch_failed", raw: url }
254
+ );
255
+ }
256
+ const pem = await response.text();
257
+ if (!pem.includes("BEGIN CERTIFICATE") && !pem.includes("BEGIN PUBLIC KEY")) {
258
+ throw new WebhookVerificationError("bKash: SigningCertURL did not return a PEM certificate", {
259
+ provider: "bkash",
260
+ code: "cert_invalid",
261
+ raw: pem.slice(0, 200)
262
+ });
263
+ }
264
+ this.#certCache.set(url, { pem, fetchedAt: Date.now() });
265
+ return pem;
266
+ }
267
+ };
268
+ function canonicalString(envelope) {
269
+ const fields = envelope.Type === "SubscriptionConfirmation" || envelope.Type === "UnsubscribeConfirmation" ? ["Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type"] : ["Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"];
270
+ let canonical = "";
271
+ for (const field of fields) {
272
+ const value = envelope[field];
273
+ if (value === void 0 || value === null) continue;
274
+ canonical += `${field}
275
+ ${value}
276
+ `;
277
+ }
278
+ return canonical;
279
+ }
280
+ function assertSnsUrl(rawUrl, field) {
281
+ let url;
282
+ try {
283
+ url = new URL(rawUrl);
284
+ } catch {
285
+ throw new WebhookVerificationError(`bKash: ${field} is not a URL`, {
286
+ provider: "bkash",
287
+ code: "cert_url_invalid",
288
+ raw: rawUrl
289
+ });
290
+ }
291
+ if (url.protocol !== "https:") {
292
+ throw new WebhookVerificationError(`bKash: ${field} must be https`, {
293
+ provider: "bkash",
294
+ code: "cert_url_insecure",
295
+ raw: rawUrl
296
+ });
297
+ }
298
+ if (!SNS_CERT_HOST.test(url.hostname)) {
299
+ throw new WebhookVerificationError(
300
+ `bKash: ${field} points at ${url.hostname}, which is not an Amazon SNS host. This is what a forged notification looks like \u2014 the payload is not from bKash.`,
301
+ { provider: "bkash", code: "cert_url_untrusted", raw: rawUrl }
302
+ );
303
+ }
304
+ if (field === "SigningCertURL" && !url.pathname.endsWith(".pem")) {
305
+ throw new WebhookVerificationError(`bKash: ${field} does not point at a .pem file`, {
306
+ provider: "bkash",
307
+ code: "cert_url_invalid",
308
+ raw: rawUrl
309
+ });
310
+ }
311
+ return url;
312
+ }
313
+
314
+ // src/bkash/adapters/next.ts
315
+ function createBkashWebhookHandler(client, options = {}) {
316
+ return async function POST(request) {
317
+ const rawBody = await request.text();
318
+ let event;
319
+ try {
320
+ event = await client.verifyWebhook({ body: rawBody, headers: headersToObject(request.headers) });
321
+ } catch (error) {
322
+ await options.onVerificationFailure?.(error, rawBody);
323
+ const message = error instanceof WebhookVerificationError ? error.message : "webhook verification failed";
324
+ return Response.json({ ok: false, error: message }, { status: 400 });
325
+ }
326
+ try {
327
+ if (event.type === "subscription.confirmation") {
328
+ const envelope = event.raw;
329
+ const shouldConfirm = await options.onSubscriptionConfirmation?.(envelope) ?? false;
330
+ if (shouldConfirm) await client.webhooks.confirmSubscription(envelope);
331
+ return Response.json({ ok: true, confirmed: shouldConfirm });
332
+ }
333
+ if (event.type === "payment.completed") {
334
+ await options.onPaymentCompleted?.(event);
335
+ } else {
336
+ await options.onOtherEvent?.(event);
337
+ }
338
+ return Response.json({ ok: true });
339
+ } catch (error) {
340
+ return Response.json(
341
+ { ok: false, error: error instanceof PaykitError ? error.message : "handler failed" },
342
+ { status: 500 }
343
+ );
344
+ }
345
+ };
346
+ }
347
+ function createBkashCallbackHandler(client, options = {}) {
348
+ return async function GET(request) {
349
+ const url = new URL(request.url);
350
+ const query = client.constructor.parseCallback(url.searchParams);
351
+ const paymentId = query.paymentID;
352
+ if (!paymentId) {
353
+ const handled = await options.onFailure?.(new Error("callback has no paymentID"), void 0);
354
+ return handled ?? redirect(options.failureUrl ?? "/", url);
355
+ }
356
+ try {
357
+ const payment = query.status === "success" ? await client.executePayment(paymentId) : await client.getPayment(paymentId);
358
+ const handled = await options.onSettled?.(payment);
359
+ if (handled) return handled;
360
+ const destination = payment.status === "completed" ? options.successUrl ?? "/" : options.failureUrl ?? "/";
361
+ return redirect(destination, url);
362
+ } catch (error) {
363
+ const handled = await options.onFailure?.(error, paymentId);
364
+ return handled ?? redirect(options.failureUrl ?? "/", url);
365
+ }
366
+ };
367
+ }
368
+ function redirect(destination, base) {
369
+ return Response.redirect(new URL(destination, base).toString(), 303);
370
+ }
371
+ function headersToObject(headers) {
372
+ const out = {};
373
+ headers.forEach((value, key) => {
374
+ out[key] = value;
375
+ });
376
+ return out;
377
+ }
378
+
379
+ export { BkashWebhookVerifier, createBkashCallbackHandler, createBkashWebhookHandler };