@pandait.tech/payment-nuvei 0.4.1 → 0.5.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/README.md CHANGED
@@ -110,6 +110,33 @@ export const POST = createChargeHandler({
110
110
 
111
111
  Apply the same pattern to the remaining handlers — each one is documented inline with its own `XHandlerDeps` interface.
112
112
 
113
+ ### Securing the webhook (important)
114
+
115
+ Paymentez/Nuvei DMNs (webhooks) are **not signed**, so the webhook endpoint must be protected with a shared secret — otherwise anyone who knows an `orderId` could POST a fake "paid" notification.
116
+
117
+ 1. Set a secret and pass it to the handler (or via `NUVEI_WEBHOOK_SECRET`):
118
+
119
+ ```ts
120
+ export const POST = createWebhookHandler({
121
+ firebase: { db: dbAdmin, auth: authAdmin },
122
+ email: { sendPaymentConfirmation, sendPaymentPending },
123
+ webhookSecret: process.env.NUVEI_WEBHOOK_SECRET,
124
+ onPaymentSucceeded: async (order) => { /* enrollment, fulfillment, ... */ },
125
+ });
126
+ ```
127
+
128
+ 2. In the **Nuvei dashboard**, configure your DMN/callback URL with the secret as a query param:
129
+
130
+ ```
131
+ https://your-shop.com/api/webhooks/nuvei?key=YOUR_SECRET
132
+ ```
133
+
134
+ (The secret is also accepted as the `x-webhook-key` header.)
135
+
136
+ Requests without a matching key get `401` and are not processed (constant-time compared). If `webhookSecret` is **unset**, the handler still processes but logs a loud warning — treat that as not-production-ready.
137
+
138
+ > `onPaymentSucceeded` fires once, on the transition into `paid` (idempotent across Nuvei's webhook retries).
139
+
113
140
  ## Hosting & deployment (3DS callback + Nuvei egress)
114
141
 
115
142
  This package is **hosting-agnostic by design** — the payment logic doesn't care where it runs. But two parts of the Nuvei flow may need to run *outside your app's request edge* depending on **where you host**, because of how the host's infrastructure (not this package) handles certain traffic:
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
- var firestore = require('firebase-admin/firestore');
4
3
  var crypto = require('crypto');
4
+ var firestore = require('firebase-admin/firestore');
5
5
  var zod = require('zod');
6
6
 
7
7
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
@@ -16,6 +16,15 @@ function json(data, init) {
16
16
  }
17
17
  return new Response(JSON.stringify(data), { ...init, headers });
18
18
  }
19
+ function timingSafeEqualStr(a, b) {
20
+ const ab = Buffer.from(a, "utf8");
21
+ const bb = Buffer.from(b, "utf8");
22
+ if (ab.length !== bb.length) {
23
+ crypto__default.default.timingSafeEqual(ab, ab);
24
+ return false;
25
+ }
26
+ return crypto__default.default.timingSafeEqual(ab, bb);
27
+ }
19
28
  function getCookie(request, name) {
20
29
  const header = request.headers.get("cookie");
21
30
  if (!header) return void 0;
@@ -694,8 +703,20 @@ function extractCres(payload) {
694
703
  function createWebhookHandler(deps) {
695
704
  const logger = deps.logger ?? console;
696
705
  const { db } = deps.firebase;
706
+ const expectedSecret = deps.webhookSecret ?? process.env.NUVEI_WEBHOOK_SECRET;
697
707
  return async function POST(request) {
698
708
  try {
709
+ if (expectedSecret) {
710
+ const provided = new URL(request.url).searchParams.get("key") ?? request.headers.get("x-webhook-key") ?? "";
711
+ if (!timingSafeEqualStr(provided, expectedSecret)) {
712
+ logger.warn("[webhook] rejected: missing/invalid webhook secret");
713
+ return json({ error: "Unauthorized" }, { status: 401 });
714
+ }
715
+ } else {
716
+ logger.warn(
717
+ "[webhook] NUVEI_WEBHOOK_SECRET is not configured \u2014 this endpoint is UNAUTHENTICATED and forgeable. Set a secret (deps.webhookSecret or env) and append it to your DMN URL as ?key=... before going to production."
718
+ );
719
+ }
699
720
  const payload = await request.json();
700
721
  logger.log("[webhook] Full payload:", JSON.stringify(payload));
701
722
  const { transaction } = payload;