@gpmpay/sdk
Version:
Official Node.js SDK for GPM Pay — VietQR codes, transaction webhooks, and payment reconciliation.
282 lines (274 loc) • 9.67 kB
JavaScript
;
var crypto = require('crypto');
// src/webhooks/verify.ts
// src/core/errors.ts
var BRAND = /* @__PURE__ */ Symbol.for("gpmpay.sdk.error");
var GpmPayError = class extends Error {
code;
/** @internal Cross-realm brand — `instanceof` breaks across CJS/ESM copies. */
[BRAND] = true;
constructor(message, code) {
super(message);
this.name = new.target.name;
this.code = code;
Object.setPrototypeOf(this, new.target.prototype);
}
/**
* Prefer this over `instanceof` when a dual CJS/ESM install could put two
* copies of the class in one process.
*/
static isGpmPayError(value) {
return typeof value === "object" && value !== null && value[BRAND] === true;
}
};
var GpmPayConfigError = class extends GpmPayError {
constructor(message, code = "invalid_argument") {
super(message, code);
}
};
var GpmPayWebhookSignatureError = class extends GpmPayError {
reason;
constructor(message, reason) {
super(message, "webhook_signature");
this.reason = reason;
}
};
// src/webhooks/verify.ts
var SIGNATURE_HEADER = "X-GPMPay-Signature";
var EVENT_HEADER = "X-GPMPay-Event";
var DEFAULT_TOLERANCE_SECONDS = 300;
function toBuffer(input) {
if (Buffer.isBuffer(input)) return input;
if (typeof input === "string") return Buffer.from(input, "utf8");
return Buffer.from(input);
}
function parseSignatureHeader(signature) {
const parts = {};
for (const segment of signature.split(",")) {
const index = segment.indexOf("=");
if (index > 0) {
parts[segment.slice(0, index).trim()] = segment.slice(index + 1).trim();
}
}
return parts;
}
function assertWebhookSignature(input) {
const { signature, secret, now = Date.now } = input;
const tolerance = input.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
if (typeof secret !== "string" || secret === "") {
throw new GpmPayWebhookSignatureError(
"Webhook secret is empty. Pass the secret you configured on the webhook setting (e.g. process.env.GPMPAY_WEBHOOK_SECRET).",
"missing_secret"
);
}
if (typeof signature !== "string" || signature.trim() === "") {
throw new GpmPayWebhookSignatureError(
`Missing ${SIGNATURE_HEADER} header.`,
"malformed_header"
);
}
const parts = parseSignatureHeader(signature);
const timestamp = Number(parts.t);
const v1 = parts.v1;
if (!Number.isFinite(timestamp) || timestamp <= 0 || !v1) {
throw new GpmPayWebhookSignatureError(
`Malformed ${SIGNATURE_HEADER}: "${signature}". Expected "t=<unix_seconds>,v1=<hex>".`,
"malformed_header"
);
}
if (tolerance > 0 && Math.abs(now() / 1e3 - timestamp) > tolerance) {
throw new GpmPayWebhookSignatureError(
`Webhook timestamp is outside the ${String(tolerance)}s tolerance (t=${String(timestamp)}). Check for clock skew between your server and GPM Pay.`,
"timestamp_skew"
);
}
const raw = toBuffer(input.rawBody);
const expected = crypto.createHmac("sha256", secret).update(Buffer.concat([Buffer.from(`${String(timestamp)}.`, "utf8"), raw])).digest();
const provided = Buffer.from(v1, "hex");
if (expected.length !== provided.length || !crypto.timingSafeEqual(expected, provided)) {
throw new GpmPayWebhookSignatureError(
"Webhook signature mismatch \u2014 wrong secret, or the body was modified in transit. Make sure you are verifying the raw request body, not a re-serialized object.",
"mismatch"
);
}
return { timestamp };
}
function verifyWebhookSignature(input) {
try {
assertWebhookSignature(input);
return true;
} catch {
return false;
}
}
function signWebhookPayload(params) {
const timestamp = params.timestamp ?? Math.floor(Date.now() / 1e3);
const raw = toBuffer(params.rawBody);
const digest = crypto.createHmac("sha256", params.secret).update(Buffer.concat([Buffer.from(`${String(timestamp)}.`, "utf8"), raw])).digest("hex");
return `t=${String(timestamp)},v1=${digest}`;
}
function headerValue(headers, name) {
if (!headers) return void 0;
const target = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() !== target) continue;
return Array.isArray(value) ? value[0] : value;
}
return void 0;
}
function constructWebhookEvent(input) {
const { timestamp } = assertWebhookSignature(input);
const rawBody = toBuffer(input.rawBody).toString("utf8");
return {
type: headerValue(input.headers, EVENT_HEADER) ?? "transaction.created",
timestamp,
payload: JSON.parse(rawBody),
rawBody
};
}
function verifyApiKeyHeader(received, expected) {
if (typeof received !== "string" || expected === "") return false;
const a = Buffer.from(received, "utf8");
const b = Buffer.from(expected, "utf8");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
// src/webhooks/express.ts
var BODY_PARSER_HINT = "The GPM Pay webhook handler received an already-parsed body.\nHMAC verification runs over the exact bytes GPM Pay sent, and re-serializing a\nparsed object changes them \u2014 verification will ALWAYS fail.\n\nMount the raw body parser on this route only:\n app.post('/webhooks/gpmpay',\n express.raw({ type: 'application/json' }),\n gpmpayWebhook({ secret, onEvent }));\n\nIf a global express.json() already ran, capture the raw body with its verify hook:\n app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));";
function resolveRawBody(req) {
if (Buffer.isBuffer(req.rawBody)) return req.rawBody;
if (typeof req.rawBody === "string") return req.rawBody;
if (Buffer.isBuffer(req.body)) return req.body;
if (typeof req.body === "string") return req.body;
throw new GpmPayConfigError(BODY_PARSER_HINT);
}
function gpmpayWebhook(options) {
const {
secret,
onEvent,
toleranceSeconds,
headerName = SIGNATURE_HEADER,
onError,
respondEarly = true
} = options;
return async function handler(req, res, next) {
let event;
try {
const rawBody = resolveRawBody(req);
const headerKey = Object.keys(req.headers).find(
(key) => key.toLowerCase() === headerName.toLowerCase()
);
const rawHeader = headerKey === void 0 ? void 0 : req.headers[headerKey];
const signature = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
event = constructWebhookEvent({
rawBody,
signature: signature ?? "",
secret,
headers: req.headers,
...toleranceSeconds === void 0 ? {} : { toleranceSeconds }
});
} catch (error) {
if (error instanceof GpmPayWebhookSignatureError) {
onError?.(error, req);
res.status(401).json({ error: error.message, reason: error.reason });
return;
}
next(error);
return;
}
if (respondEarly) {
res.status(200).json({ received: true });
try {
await onEvent(event, req);
} catch (error) {
next(error);
}
return;
}
try {
await onEvent(event, req);
res.status(200).json({ received: true });
} catch (error) {
next(error);
}
};
}
// src/webhooks/next.ts
async function verifyNextRequest(request, options) {
const { secret, toleranceSeconds, headerName = SIGNATURE_HEADER } = options;
const rawBody = await request.text();
const headers = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
return constructWebhookEvent({
rawBody,
signature: request.headers.get(headerName) ?? "",
secret,
headers,
...toleranceSeconds === void 0 ? {} : { toleranceSeconds }
});
}
function createNextWebhookHandler(options) {
const { onEvent, ...verifyOptions } = options;
return async function POST(request) {
let event;
try {
event = await verifyNextRequest(request, verifyOptions);
} catch (error) {
if (error instanceof GpmPayWebhookSignatureError) {
return Response.json(
{ error: error.message, reason: error.reason },
{ status: 401 }
);
}
throw error;
}
await onEvent(event);
return Response.json({ received: true });
};
}
async function readRawBody(stream, maxBytes = 1024 * 1024) {
const chunks = [];
let total = 0;
for await (const chunk of stream) {
const buf = Buffer.from(chunk);
total += buf.length;
if (total > maxBytes) {
throw new Error(
`Webhook body exceeded ${String(maxBytes)} bytes; refusing to buffer more.`
);
}
chunks.push(buf);
}
return Buffer.concat(chunks);
}
// src/types/enums.ts
var WEBHOOK_RETRY_SCHEDULE_SECONDS = [
10,
30,
120,
600,
3600,
21600
];
var WEBHOOK_MAX_ATTEMPTS = 6;
var WEBHOOK_DELIVERY_TIMEOUT_MS = 5e3;
exports.DEFAULT_TOLERANCE_SECONDS = DEFAULT_TOLERANCE_SECONDS;
exports.EVENT_HEADER = EVENT_HEADER;
exports.GpmPayWebhookSignatureError = GpmPayWebhookSignatureError;
exports.SIGNATURE_HEADER = SIGNATURE_HEADER;
exports.WEBHOOK_DELIVERY_TIMEOUT_MS = WEBHOOK_DELIVERY_TIMEOUT_MS;
exports.WEBHOOK_MAX_ATTEMPTS = WEBHOOK_MAX_ATTEMPTS;
exports.WEBHOOK_RETRY_SCHEDULE_SECONDS = WEBHOOK_RETRY_SCHEDULE_SECONDS;
exports.assertWebhookSignature = assertWebhookSignature;
exports.constructWebhookEvent = constructWebhookEvent;
exports.createNextWebhookHandler = createNextWebhookHandler;
exports.gpmpayWebhook = gpmpayWebhook;
exports.readRawBody = readRawBody;
exports.signWebhookPayload = signWebhookPayload;
exports.verifyApiKeyHeader = verifyApiKeyHeader;
exports.verifyNextRequest = verifyNextRequest;
exports.verifyWebhookSignature = verifyWebhookSignature;
//# sourceMappingURL=index.cjs.map
//# sourceMappingURL=index.cjs.map