UNPKG

@gpmpay/sdk

Version:

Official Node.js SDK for GPM Pay — VietQR codes, transaction webhooks, and payment reconciliation.

265 lines (258 loc) • 9.16 kB
import { createHmac, timingSafeEqual } from '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 = createHmac("sha256", secret).update(Buffer.concat([Buffer.from(`${String(timestamp)}.`, "utf8"), raw])).digest(); const provided = Buffer.from(v1, "hex"); if (expected.length !== provided.length || !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 = 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 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; export { DEFAULT_TOLERANCE_SECONDS, EVENT_HEADER, GpmPayWebhookSignatureError, SIGNATURE_HEADER, WEBHOOK_DELIVERY_TIMEOUT_MS, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_RETRY_SCHEDULE_SECONDS, assertWebhookSignature, constructWebhookEvent, createNextWebhookHandler, gpmpayWebhook, readRawBody, signWebhookPayload, verifyApiKeyHeader, verifyNextRequest, verifyWebhookSignature }; //# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map