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.
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/dist/bkash/adapters/express.cjs +128 -0
- package/dist/bkash/adapters/express.d.cts +50 -0
- package/dist/bkash/adapters/express.d.ts +50 -0
- package/dist/bkash/adapters/express.js +125 -0
- package/dist/bkash/adapters/next.cjs +383 -0
- package/dist/bkash/adapters/next.d.cts +64 -0
- package/dist/bkash/adapters/next.d.ts +64 -0
- package/dist/bkash/adapters/next.js +379 -0
- package/dist/bkash/index.cjs +1335 -0
- package/dist/bkash/index.d.cts +104 -0
- package/dist/bkash/index.d.ts +104 -0
- package/dist/bkash/index.js +1315 -0
- package/dist/client-UNDtdWhu.d.cts +475 -0
- package/dist/client-Y9603twG.d.ts +475 -0
- package/dist/errors-CleuZfqT.d.cts +103 -0
- package/dist/errors-CleuZfqT.d.ts +103 -0
- package/dist/index.cjs +369 -0
- package/dist/index.d.cts +20 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +348 -0
- package/dist/token-store-C8IMMLPJ.d.cts +240 -0
- package/dist/token-store-C8IMMLPJ.d.ts +240 -0
- package/package.json +78 -0
|
@@ -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 };
|