@hookflo/tern 2.2.9 → 2.2.10

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.
@@ -1,5 +1,5 @@
1
- import { WebhookPlatform, WebhookVerificationResult, NormalizeOptions } from '../types';
2
- import { MinimalNodeRequest } from './shared';
1
+ import { WebhookPlatform, WebhookVerificationResult, NormalizeOptions } from "../types";
2
+ import { MinimalNodeRequest } from "./shared";
3
3
  export interface ExpressLikeResponse {
4
4
  status: (code: number) => ExpressLikeResponse;
5
5
  json: (payload: unknown) => unknown;
@@ -7,7 +7,7 @@ export interface ExpressLikeResponse {
7
7
  export interface ExpressLikeRequest extends MinimalNodeRequest {
8
8
  webhook?: WebhookVerificationResult;
9
9
  }
10
- export type ExpressLikeNext = () => void;
10
+ export type ExpressLikeNext = (err?: unknown) => void;
11
11
  export interface ExpressWebhookMiddlewareOptions {
12
12
  platform: WebhookPlatform;
13
13
  secret: string;
@@ -9,7 +9,12 @@ function createWebhookMiddleware(options) {
9
9
  const webRequest = await (0, shared_1.toWebRequest)(req);
10
10
  const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(webRequest, options.platform, options.secret, options.toleranceInSeconds, options.normalize);
11
11
  if (!result.isValid) {
12
- res.status(400).json({ error: result.error, errorCode: result.errorCode, platform: result.platform, metadata: result.metadata });
12
+ res.status(400).json({
13
+ error: result.error,
14
+ errorCode: result.errorCode,
15
+ platform: result.platform,
16
+ metadata: result.metadata,
17
+ });
13
18
  return;
14
19
  }
15
20
  req.webhook = result;
@@ -17,7 +22,7 @@ function createWebhookMiddleware(options) {
17
22
  }
18
23
  catch (error) {
19
24
  options.onError?.(error);
20
- res.status(500).json({ error: error.message });
25
+ next(error);
21
26
  }
22
27
  };
23
28
  }
@@ -6,8 +6,8 @@ export interface MinimalNodeRequest {
6
6
  get?: (name: string) => string | undefined;
7
7
  originalUrl?: string;
8
8
  url?: string;
9
- on?: (event: string, cb: (chunk?: any) => void) => void;
9
+ on?: (event: string, cb: (chunk?: unknown) => void) => void;
10
10
  }
11
- export declare function extractRawBody(request: MinimalNodeRequest): Promise<string>;
11
+ export declare function extractRawBody(request: MinimalNodeRequest): Promise<Uint8Array>;
12
12
  export declare function toHeadersInit(headers: Record<string, string | string[] | undefined>): HeadersInit;
13
13
  export declare function toWebRequest(request: MinimalNodeRequest): Promise<Request>;
@@ -6,39 +6,65 @@ exports.toWebRequest = toWebRequest;
6
6
  function getHeaderValue(headers, name) {
7
7
  const value = headers[name.toLowerCase()] ?? headers[name];
8
8
  if (Array.isArray(value)) {
9
- return value.join(',');
9
+ return value.join(",");
10
10
  }
11
11
  return value;
12
12
  }
13
- async function readIncomingMessageBody(request) {
14
- if (!request.on) {
15
- return '';
16
- }
13
+ async function readIncomingMessageBodyAsBuffer(request) {
14
+ if (!request.on)
15
+ return new Uint8Array(0);
17
16
  const chunks = [];
18
17
  return new Promise((resolve, reject) => {
19
- request.on?.('data', (chunk) => {
20
- if (typeof chunk === 'string') {
18
+ request.on?.("data", (chunk) => {
19
+ if (chunk instanceof Uint8Array) {
21
20
  chunks.push(chunk);
22
- return;
23
21
  }
24
- chunks.push(Buffer.from(chunk ?? '').toString('utf8'));
22
+ else if (typeof chunk === "string") {
23
+ chunks.push(new TextEncoder().encode(chunk));
24
+ }
25
+ else if (chunk != null) {
26
+ try {
27
+ chunks.push(new TextEncoder().encode(String(chunk)));
28
+ }
29
+ catch {
30
+ // skip unreadable chunk silently
31
+ }
32
+ }
25
33
  });
26
- request.on?.('end', () => resolve(chunks.join('')));
27
- request.on?.('error', reject);
34
+ request.on?.("end", () => {
35
+ const totalLength = chunks.reduce((sum, c) => sum + c.byteLength, 0);
36
+ const result = new Uint8Array(totalLength);
37
+ let offset = 0;
38
+ for (const chunk of chunks) {
39
+ result.set(chunk, offset);
40
+ offset += chunk.byteLength;
41
+ }
42
+ resolve(result);
43
+ });
44
+ request.on?.("error", (err) => reject(err));
28
45
  });
29
46
  }
30
47
  async function extractRawBody(request) {
31
48
  const body = request.body;
32
- if (typeof body === 'string') {
49
+ if (body instanceof Uint8Array) {
33
50
  return body;
34
51
  }
35
- if (Buffer.isBuffer(body)) {
36
- return body.toString('utf8');
52
+ if (typeof body === "string") {
53
+ return new TextEncoder().encode(body);
37
54
  }
38
- if (body && typeof body === 'object') {
39
- return JSON.stringify(body);
55
+ if (body !== null && body !== undefined && typeof body === "object") {
56
+ console.warn("[Tern] Warning: request body is already parsed as JSON. " +
57
+ "Signature verification may fail. " +
58
+ 'Add express.raw({ type: "*/*" }) before Tern on webhook routes, ' +
59
+ "or register webhook routes before app.use(express.json()).");
60
+ return new TextEncoder().encode(JSON.stringify(body));
40
61
  }
41
- return readIncomingMessageBody(request);
62
+ // nothing ran — read raw stream directly
63
+ return readIncomingMessageBodyAsBuffer(request);
64
+ }
65
+ // converts Uint8Array to a plain ArrayBuffer that all TS versions accept as BodyInit
66
+ function toArrayBuffer(uint8) {
67
+ return uint8.buffer.slice(uint8.byteOffset, uint8.byteOffset + uint8.byteLength);
42
68
  }
43
69
  function toHeadersInit(headers) {
44
70
  const normalized = new Headers();
@@ -47,7 +73,7 @@ function toHeadersInit(headers) {
47
73
  continue;
48
74
  }
49
75
  if (Array.isArray(value)) {
50
- normalized.set(key, value.join(','));
76
+ normalized.set(key, value.join(","));
51
77
  continue;
52
78
  }
53
79
  normalized.set(key, value);
@@ -55,13 +81,17 @@ function toHeadersInit(headers) {
55
81
  return normalized;
56
82
  }
57
83
  async function toWebRequest(request) {
58
- const protocol = request.protocol || 'https';
59
- const host = request.get?.('host') || getHeaderValue(request.headers, 'host') || 'localhost';
60
- const path = request.originalUrl || request.url || '/';
84
+ const protocol = request.protocol || "https";
85
+ const host = request.get?.("host") ||
86
+ getHeaderValue(request.headers, "host") ||
87
+ "localhost";
88
+ const path = request.originalUrl || request.url || "/";
61
89
  const rawBody = await extractRawBody(request);
62
- return new Request(`${protocol}://${host}${path}`, {
63
- method: request.method || 'POST',
90
+ const init = {
91
+ method: request.method || "POST",
64
92
  headers: toHeadersInit(request.headers),
65
- body: rawBody,
66
- });
93
+ body: toArrayBuffer(rawBody),
94
+ duplex: "half",
95
+ };
96
+ return new Request(`${protocol}://${host}${path}`, init);
67
97
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const crypto_1 = __importDefault(require("crypto"));
7
+ const shared_1 = require("./shared");
8
+ const __1 = __importDefault(require(".."));
9
+ const secret = "whsec_test_secret";
10
+ const payload = JSON.stringify({ type: "payment_intent.succeeded", data: { amount: 2000 } }) +
11
+ "\n";
12
+ function buildStripeSignature(body, secret) {
13
+ const timestamp = Math.floor(Date.now() / 1000);
14
+ const signed = `${timestamp}.${body}`;
15
+ const hmac = crypto_1.default
16
+ .createHmac("sha256", secret.replace("whsec_", ""))
17
+ .update(signed)
18
+ .digest("hex");
19
+ return `t=${timestamp},v1=${hmac}`;
20
+ }
21
+ // ── Test 1: raw stream — no middleware ran ───────────────────
22
+ async function testRawStream() {
23
+ const sig = buildStripeSignature(payload, secret); // ✓ fixed: was missing secret arg
24
+ const chunks = [Buffer.from(payload, "utf8")];
25
+ const req = {
26
+ method: "POST",
27
+ headers: {
28
+ "stripe-signature": sig,
29
+ "content-type": "application/json",
30
+ },
31
+ body: undefined,
32
+ on: (event, cb) => {
33
+ if (event === "data") {
34
+ for (const chunk of chunks)
35
+ cb(chunk);
36
+ }
37
+ if (event === "end") {
38
+ cb();
39
+ }
40
+ },
41
+ };
42
+ const webReq = await (0, shared_1.toWebRequest)(req);
43
+ const result = await __1.default.verifyWithPlatformConfig(webReq, "stripe", secret);
44
+ console.log("Test 1 Raw stream (no middleware):", result.isValid ? "✓ PASS" : `✗ FAIL — ${result.error}`);
45
+ }
46
+ // ── Test 2: Buffer body — express.raw() ran ──────────────────
47
+ async function testBufferBody() {
48
+ const sig = buildStripeSignature(payload, secret);
49
+ const req = {
50
+ method: "POST",
51
+ headers: {
52
+ "content-type": "application/json",
53
+ "stripe-signature": sig,
54
+ },
55
+ body: Buffer.from(payload, "utf8"),
56
+ };
57
+ const webReq = await (0, shared_1.toWebRequest)(req);
58
+ const result = await __1.default.verifyWithPlatformConfig(webReq, "stripe", secret);
59
+ console.log("Test 2 Buffer body (express.raw):", result.isValid ? "✓ PASS" : `✗ FAIL — ${result.error}`);
60
+ }
61
+ // ── Test 3: parsed object — express.json() ran ───────────────
62
+ async function testParsedObject() {
63
+ // signature computed against raw payload including \n
64
+ const sig = buildStripeSignature(payload, secret);
65
+ const req = {
66
+ method: "POST",
67
+ headers: {
68
+ "content-type": "application/json",
69
+ "stripe-signature": sig,
70
+ },
71
+ body: JSON.parse(payload), // \n gone, bytes lost
72
+ };
73
+ const webReq = await (0, shared_1.toWebRequest)(req);
74
+ const result = await __1.default.verifyWithPlatformConfig(webReq, "stripe", secret);
75
+ // should FAIL — \n was in original signature but lost after JSON.parse
76
+ console.log("Test 3 Parsed object (express.json):", !result.isValid ? "✓ FAILS AS EXPECTED" : "✗ SHOULD HAVE FAILED");
77
+ }
78
+ async function run() {
79
+ await testRawStream(); // no middleware → should PASS
80
+ await testBufferBody(); // express.raw() → should PASS
81
+ await testParsedObject(); // express.json() → should FAIL as expected
82
+ console.log("\nDone. Test 3 failing is correct behavior.");
83
+ }
84
+ run();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hookflo/tern",
3
- "version": "2.2.9",
3
+ "version": "2.2.10",
4
4
  "description": "A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",