@pandait.tech/payment-nuvei 0.4.1 → 1.0.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.
@@ -23,10 +23,9 @@ interface MinimalOrder extends FirestoreData {
23
23
  shippingAddress?: {
24
24
  fullName?: string;
25
25
  } & FirestoreData;
26
+ /** Optional note shown to the customer post-purchase; echoed into the
27
+ * confirmation email when present. */
26
28
  postPurchaseNote?: string;
27
- paymentLinkId?: string;
28
- tallerId?: string;
29
- courseId?: string;
30
29
  /** Set by charge.ts when the user opted out of saving the card AND the order
31
30
  * enters an intermediate status (3ds-pending / otp-pending). The downstream
32
31
  * handler that finalizes the payment (3ds-complete, webhook BY_CRES) reads
@@ -177,6 +176,19 @@ interface NuveiWebhookPayload {
177
176
  interface WebhookHandlerDeps {
178
177
  firebase: FirebaseDeps;
179
178
  email: Pick<EmailService, "sendPaymentConfirmation" | "sendPaymentPending">;
179
+ /**
180
+ * Shared secret used to authenticate the incoming webhook. Paymentez/Nuvei
181
+ * DMNs are NOT signed, so the only robust auth is a secret the merchant
182
+ * embeds in the DMN URL configured in the Nuvei dashboard, e.g.
183
+ * `https://shop.com/api/webhooks/nuvei?key=THE_SECRET` (also accepted as the
184
+ * `x-webhook-key` header). Falls back to `process.env.NUVEI_WEBHOOK_SECRET`.
185
+ *
186
+ * If set, requests without a matching key are rejected with 401. If unset,
187
+ * the handler logs a loud warning and processes anyway (the endpoint is then
188
+ * forgeable — anyone who knows an orderId could fake a "paid" notification).
189
+ * STRONGLY RECOMMENDED in production.
190
+ */
191
+ webhookSecret?: string;
180
192
  /** 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
193
  onPaymentSucceeded?: (order: MinimalOrder & {
182
194
  id: string;
@@ -23,10 +23,9 @@ interface MinimalOrder extends FirestoreData {
23
23
  shippingAddress?: {
24
24
  fullName?: string;
25
25
  } & FirestoreData;
26
+ /** Optional note shown to the customer post-purchase; echoed into the
27
+ * confirmation email when present. */
26
28
  postPurchaseNote?: string;
27
- paymentLinkId?: string;
28
- tallerId?: string;
29
- courseId?: string;
30
29
  /** Set by charge.ts when the user opted out of saving the card AND the order
31
30
  * enters an intermediate status (3ds-pending / otp-pending). The downstream
32
31
  * handler that finalizes the payment (3ds-complete, webhook BY_CRES) reads
@@ -177,6 +176,19 @@ interface NuveiWebhookPayload {
177
176
  interface WebhookHandlerDeps {
178
177
  firebase: FirebaseDeps;
179
178
  email: Pick<EmailService, "sendPaymentConfirmation" | "sendPaymentPending">;
179
+ /**
180
+ * Shared secret used to authenticate the incoming webhook. Paymentez/Nuvei
181
+ * DMNs are NOT signed, so the only robust auth is a secret the merchant
182
+ * embeds in the DMN URL configured in the Nuvei dashboard, e.g.
183
+ * `https://shop.com/api/webhooks/nuvei?key=THE_SECRET` (also accepted as the
184
+ * `x-webhook-key` header). Falls back to `process.env.NUVEI_WEBHOOK_SECRET`.
185
+ *
186
+ * If set, requests without a matching key are rejected with 401. If unset,
187
+ * the handler logs a loud warning and processes anyway (the endpoint is then
188
+ * forgeable — anyone who knows an orderId could fake a "paid" notification).
189
+ * STRONGLY RECOMMENDED in production.
190
+ */
191
+ webhookSecret?: string;
180
192
  /** 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
193
  onPaymentSucceeded?: (order: MinimalOrder & {
182
194
  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;