@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.
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:
@@ -285,7 +312,22 @@ The handlers read/write a single `orders` collection (plus `promotions` for coup
285
312
 
286
313
  Public, supported entry points: the package root (SDK), `./handlers`, `./adapters`, `./payment-links`, `./ui`, and `./ui/styles.css`. Everything else (e.g. internal `http.ts`) is private and may change without notice.
287
314
 
288
- Pre-1.0 the package follows semver with the caveat that **minor versions may make small breaking changes to handler `*HandlerDeps` interfaces** as the design settles. From **1.0.0** onward the public API is frozen under semver. See `CHANGELOG.md`.
315
+ **As of `1.0.0` the public API is frozen under semver** — breaking changes to the
316
+ exported factories, their `*HandlerDeps` interfaces, the SDK functions, or the UI
317
+ component props require a major version bump. See `CHANGELOG.md`.
318
+
319
+ Design notes consumers should know:
320
+
321
+ - **Order shape is generic.** `MinimalOrder` extends `Record<string, unknown>` and
322
+ declares only fields the package itself reads/writes. Your business-specific
323
+ order fields are yours to read off the order inside your callbacks
324
+ (`onPaymentSucceeded`, `validateCustomOrder`, `getRetryUrl`) — narrow them there.
325
+ - **Payment links are primitives only.** The package ships token/expiry/price-bound
326
+ helpers; the payment-link *document shape* (what it points to) is yours to define.
327
+ - **The webhook secret is optional but strongly recommended.** It's left optional so
328
+ the package never forces a specific webhook-URL scheme, but an unconfigured
329
+ webhook is forgeable — always set `webhookSecret` in production (see "Securing the
330
+ webhook" above).
289
331
 
290
332
  ## Migration from `pauhenriques-website`
291
333
 
@@ -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;