@pandait.tech/payment-nuvei 0.4.0 → 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.
@@ -177,6 +177,19 @@ interface NuveiWebhookPayload {
177
177
  interface WebhookHandlerDeps {
178
178
  firebase: FirebaseDeps;
179
179
  email: Pick<EmailService, "sendPaymentConfirmation" | "sendPaymentPending">;
180
+ /**
181
+ * Shared secret used to authenticate the incoming webhook. Paymentez/Nuvei
182
+ * DMNs are NOT signed, so the only robust auth is a secret the merchant
183
+ * embeds in the DMN URL configured in the Nuvei dashboard, e.g.
184
+ * `https://shop.com/api/webhooks/nuvei?key=THE_SECRET` (also accepted as the
185
+ * `x-webhook-key` header). Falls back to `process.env.NUVEI_WEBHOOK_SECRET`.
186
+ *
187
+ * If set, requests without a matching key are rejected with 401. If unset,
188
+ * the handler logs a loud warning and processes anyway (the endpoint is then
189
+ * forgeable — anyone who knows an orderId could fake a "paid" notification).
190
+ * STRONGLY RECOMMENDED in production.
191
+ */
192
+ webhookSecret?: string;
180
193
  /** Optional post-success hook. Fires when the webhook transitions an order to "paid" (either via direct status_detail=3 or via verify BY_CRES). Used for enrollment, fulfillment triggers, paymentLink usage tracking, etc. */
181
194
  onPaymentSucceeded?: (order: MinimalOrder & {
182
195
  id: string;
@@ -177,6 +177,19 @@ interface NuveiWebhookPayload {
177
177
  interface WebhookHandlerDeps {
178
178
  firebase: FirebaseDeps;
179
179
  email: Pick<EmailService, "sendPaymentConfirmation" | "sendPaymentPending">;
180
+ /**
181
+ * Shared secret used to authenticate the incoming webhook. Paymentez/Nuvei
182
+ * DMNs are NOT signed, so the only robust auth is a secret the merchant
183
+ * embeds in the DMN URL configured in the Nuvei dashboard, e.g.
184
+ * `https://shop.com/api/webhooks/nuvei?key=THE_SECRET` (also accepted as the
185
+ * `x-webhook-key` header). Falls back to `process.env.NUVEI_WEBHOOK_SECRET`.
186
+ *
187
+ * If set, requests without a matching key are rejected with 401. If unset,
188
+ * the handler logs a loud warning and processes anyway (the endpoint is then
189
+ * forgeable — anyone who knows an orderId could fake a "paid" notification).
190
+ * STRONGLY RECOMMENDED in production.
191
+ */
192
+ webhookSecret?: string;
180
193
  /** Optional post-success hook. Fires when the webhook transitions an order to "paid" (either via direct status_detail=3 or via verify BY_CRES). Used for enrollment, fulfillment triggers, paymentLink usage tracking, etc. */
181
194
  onPaymentSucceeded?: (order: MinimalOrder & {
182
195
  id: string;
@@ -1,5 +1,5 @@
1
- import { FieldValue } from 'firebase-admin/firestore';
2
1
  import crypto from 'crypto';
2
+ import { FieldValue } from 'firebase-admin/firestore';
3
3
  import { z } from 'zod';
4
4
 
5
5
  // src/http.ts
@@ -10,6 +10,15 @@ function json(data, init) {
10
10
  }
11
11
  return new Response(JSON.stringify(data), { ...init, headers });
12
12
  }
13
+ function timingSafeEqualStr(a, b) {
14
+ const ab = Buffer.from(a, "utf8");
15
+ const bb = Buffer.from(b, "utf8");
16
+ if (ab.length !== bb.length) {
17
+ crypto.timingSafeEqual(ab, ab);
18
+ return false;
19
+ }
20
+ return crypto.timingSafeEqual(ab, bb);
21
+ }
13
22
  function getCookie(request, name) {
14
23
  const header = request.headers.get("cookie");
15
24
  if (!header) return void 0;
@@ -688,8 +697,20 @@ function extractCres(payload) {
688
697
  function createWebhookHandler(deps) {
689
698
  const logger = deps.logger ?? console;
690
699
  const { db } = deps.firebase;
700
+ const expectedSecret = deps.webhookSecret ?? process.env.NUVEI_WEBHOOK_SECRET;
691
701
  return async function POST(request) {
692
702
  try {
703
+ if (expectedSecret) {
704
+ const provided = new URL(request.url).searchParams.get("key") ?? request.headers.get("x-webhook-key") ?? "";
705
+ if (!timingSafeEqualStr(provided, expectedSecret)) {
706
+ logger.warn("[webhook] rejected: missing/invalid webhook secret");
707
+ return json({ error: "Unauthorized" }, { status: 401 });
708
+ }
709
+ } else {
710
+ logger.warn(
711
+ "[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."
712
+ );
713
+ }
693
714
  const payload = await request.json();
694
715
  logger.log("[webhook] Full payload:", JSON.stringify(payload));
695
716
  const { transaction } = payload;