@bernierllc/contentful-webhook-handler 1.0.13 → 1.2.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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /*
4
+ Copyright (c) 2025 Bernier LLC
5
+ */
6
+ const errors_1 = require("../errors");
7
+ describe('ContentfulWebhookHandlerError', () => {
8
+ it('uses default code when none provided', () => {
9
+ const err = new errors_1.ContentfulWebhookHandlerError('boom');
10
+ expect(err.name).toBe('ContentfulWebhookHandlerError');
11
+ expect(err.code).toBe('CONTENTFUL_WEBHOOK_HANDLER_ERROR');
12
+ expect(err.message).toBe('boom');
13
+ expect(err.cause).toBeUndefined();
14
+ expect(err.context).toBeUndefined();
15
+ });
16
+ it('preserves cause and context', () => {
17
+ const cause = new Error('root');
18
+ const err = new errors_1.ContentfulWebhookHandlerError('wrap', { cause, code: 'OPERATION_FAILED', context: { foo: 'bar' } });
19
+ expect(err.cause).toBe(cause);
20
+ expect(err.code).toBe('OPERATION_FAILED');
21
+ expect(err.context).toEqual({ foo: 'bar' });
22
+ });
23
+ it('omits context when not provided', () => {
24
+ const err = new errors_1.ContentfulWebhookHandlerError('msg', { code: 'INVALID_INPUT' });
25
+ expect(err.code).toBe('INVALID_INPUT');
26
+ expect(err.context).toBeUndefined();
27
+ });
28
+ });
@@ -0,0 +1,11 @@
1
+ export type ContentfulWebhookHandlerErrorCode = 'CONTENTFUL_WEBHOOK_HANDLER_ERROR' | 'INVALID_INPUT' | 'OPERATION_FAILED';
2
+ export interface ContentfulWebhookHandlerErrorOptions {
3
+ cause?: Error;
4
+ code?: ContentfulWebhookHandlerErrorCode;
5
+ context?: Record<string, unknown>;
6
+ }
7
+ export declare class ContentfulWebhookHandlerError extends Error {
8
+ readonly code: ContentfulWebhookHandlerErrorCode;
9
+ readonly context?: Record<string, unknown>;
10
+ constructor(message: string, options?: ContentfulWebhookHandlerErrorOptions);
11
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ /*
3
+ Copyright (c) 2025 Bernier LLC
4
+
5
+ This file is licensed to the client under a limited-use license.
6
+ The client may use and modify this code only within the scope of the project it was delivered for.
7
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.ContentfulWebhookHandlerError = void 0;
11
+ class ContentfulWebhookHandlerError extends Error {
12
+ code;
13
+ context;
14
+ constructor(message, options) {
15
+ super(message, options?.cause ? { cause: options.cause } : undefined);
16
+ this.name = 'ContentfulWebhookHandlerError';
17
+ this.code = options?.code ?? 'CONTENTFUL_WEBHOOK_HANDLER_ERROR';
18
+ if (options?.context !== undefined) {
19
+ this.context = options.context;
20
+ }
21
+ Error.captureStackTrace?.(this, this.constructor);
22
+ }
23
+ }
24
+ exports.ContentfulWebhookHandlerError = ContentfulWebhookHandlerError;
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export { ContentfulWebhookHandler, WebhookValidationResult, EventType, ContentfulWebhookHandlerOptions } from './ContentfulWebhookHandler';
2
+ export { ContentfulWebhookHandlerError, ContentfulWebhookHandlerErrorCode, ContentfulWebhookHandlerErrorOptions, } from './errors';
2
3
  export type { ContentfulWebhookPayload, ContentfulWebhookTopic, ContentfulWebhookHeaders } from '@bernierllc/contentful-types';
package/dist/index.js CHANGED
@@ -7,6 +7,8 @@ The client may use and modify this code *only within the scope of the project it
7
7
  Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.ContentfulWebhookHandler = void 0;
10
+ exports.ContentfulWebhookHandlerError = exports.ContentfulWebhookHandler = void 0;
11
11
  var ContentfulWebhookHandler_1 = require("./ContentfulWebhookHandler");
12
12
  Object.defineProperty(exports, "ContentfulWebhookHandler", { enumerable: true, get: function () { return ContentfulWebhookHandler_1.ContentfulWebhookHandler; } });
13
+ var errors_1 = require("./errors");
14
+ Object.defineProperty(exports, "ContentfulWebhookHandlerError", { enumerable: true, get: function () { return errors_1.ContentfulWebhookHandlerError; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bernierllc/contentful-webhook-handler",
3
- "version": "1.0.13",
3
+ "version": "1.2.0",
4
4
  "description": "Validates Contentful webhook signatures using HMAC-SHA256, parses webhook payloads, and provides type-safe interfaces for all webhook event types",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -19,8 +19,8 @@
19
19
  "directory": "packages/core/contentful-webhook-handler"
20
20
  },
21
21
  "dependencies": {
22
- "@bernierllc/logger": "1.4.3",
23
- "@bernierllc/contentful-types": "1.2.2"
22
+ "@bernierllc/contentful-types": "1.4.0",
23
+ "@bernierllc/logger": "1.6.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/jest": "^29.5.12",
@@ -32,6 +32,11 @@
32
32
  "ts-jest": "^29.1.2",
33
33
  "typescript": "^5.4.2"
34
34
  },
35
+ "files": [
36
+ "dist/**/*",
37
+ "LICENSE.md",
38
+ "README.md"
39
+ ],
35
40
  "publishConfig": {
36
41
  "access": "public",
37
42
  "registry": "https://registry.npmjs.org/"
package/.eslintrc.cjs DELETED
@@ -1,36 +0,0 @@
1
- /*
2
- Copyright (c) 2025 Bernier LLC
3
-
4
- This file is licensed to the client under a limited-use license.
5
- The client may use and modify this code *only within the scope of the project it was delivered for*.
6
- Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
- */
8
-
9
- module.exports = {
10
- parser: '@typescript-eslint/parser',
11
- parserOptions: {
12
- ecmaVersion: 2022,
13
- sourceType: 'module',
14
- project: './tsconfig.json',
15
- },
16
- plugins: ['@typescript-eslint'],
17
- extends: [
18
- 'eslint:recommended',
19
- 'plugin:@typescript-eslint/recommended',
20
- 'plugin:@typescript-eslint/recommended-requiring-type-checking',
21
- ],
22
- rules: {
23
- '@typescript-eslint/no-explicit-any': 'error',
24
- '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
25
- '@typescript-eslint/explicit-function-return-type': 'off',
26
- '@typescript-eslint/explicit-module-boundary-types': 'off',
27
- '@typescript-eslint/no-unsafe-assignment': 'warn',
28
- '@typescript-eslint/no-unsafe-member-access': 'warn',
29
- '@typescript-eslint/no-unsafe-call': 'warn',
30
- '@typescript-eslint/no-unsafe-return': 'warn',
31
- },
32
- env: {
33
- node: true,
34
- jest: true,
35
- },
36
- };
package/CHANGELOG.md DELETED
@@ -1,85 +0,0 @@
1
- # Change Log
2
-
3
- All notable changes to this project will be documented in this file.
4
- See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
-
6
- ## [1.0.13](https://github.com/bernierllc/tools/compare/@bernierllc/contentful-webhook-handler@1.0.12...@bernierllc/contentful-webhook-handler@1.0.13) (2026-05-19)
7
-
8
- **Note:** Version bump only for package @bernierllc/contentful-webhook-handler
9
-
10
-
11
-
12
-
13
-
14
- ## [1.0.12](https://github.com/bernierllc/tools/compare/@bernierllc/contentful-webhook-handler@1.0.11...@bernierllc/contentful-webhook-handler@1.0.12) (2026-05-19)
15
-
16
- **Note:** Version bump only for package @bernierllc/contentful-webhook-handler
17
-
18
-
19
-
20
-
21
-
22
- ## [1.0.11](https://github.com/bernierllc/tools/compare/@bernierllc/contentful-webhook-handler@1.0.10...@bernierllc/contentful-webhook-handler@1.0.11) (2026-03-30)
23
-
24
- **Note:** Version bump only for package @bernierllc/contentful-webhook-handler
25
-
26
-
27
-
28
-
29
-
30
- ## [1.0.10](https://github.com/bernierllc/tools/compare/@bernierllc/contentful-webhook-handler@1.0.9...@bernierllc/contentful-webhook-handler@1.0.10) (2026-03-09)
31
-
32
- **Note:** Version bump only for package @bernierllc/contentful-webhook-handler
33
-
34
-
35
-
36
-
37
-
38
- ## [1.0.9](https://github.com/bernierllc/tools/compare/@bernierllc/contentful-webhook-handler@1.0.8...@bernierllc/contentful-webhook-handler@1.0.9) (2026-03-03)
39
-
40
- **Note:** Version bump only for package @bernierllc/contentful-webhook-handler
41
-
42
-
43
-
44
-
45
-
46
- ## 1.0.8 (2025-12-25)
47
-
48
-
49
- ### Bug Fixes
50
-
51
- * **ci:** add lerna version step before publish ([3d20300](https://github.com/bernierllc/tools/commit/3d203002143bf353fffafe4f8a78a99009567347))
52
- * **ci:** fix release workflow + convert internal deps to workspace:* ([01c078b](https://github.com/bernierllc/tools/commit/01c078b49d6025f7eef750f79207a1c71c8d85dc))
53
- * update neverhub-adapter deps to workspace:* and publish 0.1.2 ([f0e3d04](https://github.com/bernierllc/tools/commit/f0e3d04d8d4f094e3bb899ddf81e93243d16e2c2))
54
-
55
-
56
-
57
-
58
-
59
- ## 1.0.7 (2025-12-25)
60
-
61
-
62
- ### Bug Fixes
63
-
64
- * **ci:** fix release workflow + convert internal deps to workspace:* ([01c078b](https://github.com/bernierllc/tools/commit/01c078b49d6025f7eef750f79207a1c71c8d85dc))
65
- * update neverhub-adapter deps to workspace:* and publish 0.1.2 ([f0e3d04](https://github.com/bernierllc/tools/commit/f0e3d04d8d4f094e3bb899ddf81e93243d16e2c2))
66
-
67
-
68
-
69
-
70
-
71
- # @bernierllc/contentful-webhook-handler
72
-
73
- ## 1.0.6
74
-
75
- ### Patch Changes
76
-
77
- - Updated dependencies [[`73c1d1e`](https://github.com/bernierllc/tools/commit/73c1d1e46114cf2ccfd5b74b83743361247f4502)]:
78
- - @bernierllc/logger@1.1.0
79
-
80
- ## 1.0.5
81
-
82
- ### Patch Changes
83
-
84
- - Updated dependencies [[`24899fa`](https://github.com/bernierllc/tools/commit/24899fa4eaf80ae329f7bd74483cf97a132f5d4c)]:
85
- - @bernierllc/logger@1.0.4
package/jest.config.cjs DELETED
@@ -1,33 +0,0 @@
1
- /*
2
- Copyright (c) 2025 Bernier LLC
3
-
4
- This file is licensed to the client under a limited-use license.
5
- The client may use and modify this code *only within the scope of the project it was delivered for*.
6
- Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
- */
8
-
9
- module.exports = {
10
- preset: 'ts-jest',
11
- testEnvironment: '<rootDir>/../../../jest-environment-node-fixed.cjs',
12
- roots: ['<rootDir>/src'],
13
- testMatch: ['**/__tests__/**/*.test.ts'],
14
- collectCoverageFrom: [
15
- 'src/**/*.ts',
16
- '!src/**/*.test.ts',
17
- '!src/**/__tests__/**',
18
- '!src/index.ts'
19
- ],
20
- coverageThreshold: {
21
- global: {
22
- branches: 90,
23
- functions: 90,
24
- lines: 90,
25
- statements: 90
26
- }
27
- },
28
- coveragePathIgnorePatterns: [
29
- '/node_modules/',
30
- '/dist/',
31
- '/__tests__/'
32
- ]
33
- };
@@ -1,260 +0,0 @@
1
- /*
2
- Copyright (c) 2025 Bernier LLC
3
-
4
- This file is licensed to the client under a limited-use license.
5
- The client may use and modify this code *only within the scope of the project it was delivered for*.
6
- Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
- */
8
-
9
- import crypto from 'crypto';
10
- import {
11
- ContentfulWebhookPayload,
12
- ContentfulWebhookTopic,
13
- ContentfulWebhookHeaders
14
- } from '@bernierllc/contentful-types';
15
- import { Logger, createLogger } from '@bernierllc/logger';
16
-
17
- export interface WebhookValidationResult {
18
- valid: boolean;
19
- error?: string;
20
- payload?: ContentfulWebhookPayload;
21
- topic?: ContentfulWebhookTopic;
22
- }
23
-
24
- export interface EventType {
25
- resource: 'Entry' | 'Asset' | 'ContentType';
26
- action: 'create' | 'save' | 'auto_save' | 'publish' | 'unpublish' | 'delete' | 'archive' | 'unarchive';
27
- }
28
-
29
- export interface ContentfulWebhookHandlerOptions {
30
- webhookSecret: string;
31
- logger?: Logger;
32
- enableTimestampValidation?: boolean;
33
- maxTimestampDeltaSeconds?: number;
34
- }
35
-
36
- /**
37
- * Validates Contentful webhook signatures using HMAC-SHA256,
38
- * parses webhook payloads, and provides type-safe interfaces
39
- * for all webhook event types.
40
- */
41
- export class ContentfulWebhookHandler {
42
- private webhookSecret: string;
43
- private logger: Logger;
44
- private enableTimestampValidation: boolean;
45
- private maxTimestampDeltaSeconds: number;
46
-
47
- /**
48
- * Creates a new ContentfulWebhookHandler instance
49
- *
50
- * @param options Configuration options
51
- */
52
- constructor(options: ContentfulWebhookHandlerOptions) {
53
- this.webhookSecret = options.webhookSecret;
54
- this.logger = options.logger || createLogger({
55
- context: {
56
- service: 'ContentfulWebhookHandler',
57
- version: '1.0.0',
58
- environment: process.env.NODE_ENV || 'development'
59
- }
60
- });
61
- this.enableTimestampValidation = options.enableTimestampValidation ?? false;
62
- this.maxTimestampDeltaSeconds = options.maxTimestampDeltaSeconds ?? 300; // 5 minutes default
63
- }
64
-
65
- /**
66
- * Validates a webhook signature using HMAC-SHA256
67
- *
68
- * @param body Raw webhook body as string
69
- * @param signature Signature from x-contentful-signature header
70
- * @returns True if signature is valid
71
- */
72
- validateSignature(body: string, signature: string): boolean {
73
- try {
74
- const hmac = crypto.createHmac('sha256', this.webhookSecret);
75
- hmac.update(body);
76
- const computedSignature = hmac.digest('base64');
77
-
78
- // Use timing-safe comparison to prevent timing attacks
79
- const signatureBuffer = Buffer.from(signature);
80
- const computedBuffer = Buffer.from(computedSignature);
81
-
82
- // Ensure buffers are the same length for timingSafeEqual
83
- if (signatureBuffer.length !== computedBuffer.length) {
84
- this.logger.warn('Invalid webhook signature (length mismatch)');
85
- return false;
86
- }
87
-
88
- // TypeScript requires explicit cast for Buffer to ArrayBufferView
89
- const isValid = crypto.timingSafeEqual(
90
- signatureBuffer as unknown as Uint8Array,
91
- computedBuffer as unknown as Uint8Array
92
- );
93
-
94
- if (!isValid) {
95
- this.logger.warn('Invalid webhook signature');
96
- }
97
-
98
- return isValid;
99
- } catch (err) {
100
- // Handle buffer length mismatch or other crypto errors
101
- if (err instanceof Error) {
102
- this.logger.error('Signature validation error', err);
103
- } else {
104
- this.logger.error('Signature validation error', new Error(String(err)));
105
- }
106
- return false;
107
- }
108
- }
109
-
110
- /**
111
- * Validates webhook timestamp to prevent replay attacks
112
- *
113
- * @param payload Parsed webhook payload
114
- * @returns True if timestamp is within acceptable range
115
- */
116
- private validateTimestamp(payload: ContentfulWebhookPayload): boolean {
117
- if (!this.enableTimestampValidation) {
118
- return true;
119
- }
120
-
121
- const updatedAt = payload.sys.updatedAt;
122
- if (!updatedAt) {
123
- this.logger.warn('No timestamp in webhook payload');
124
- return false;
125
- }
126
-
127
- const payloadTime = new Date(updatedAt).getTime();
128
- const currentTime = Date.now();
129
- const deltaSeconds = Math.abs(currentTime - payloadTime) / 1000;
130
-
131
- if (deltaSeconds > this.maxTimestampDeltaSeconds) {
132
- this.logger.warn('Webhook timestamp outside acceptable range', {
133
- deltaSeconds,
134
- maxAllowed: this.maxTimestampDeltaSeconds
135
- });
136
- return false;
137
- }
138
-
139
- return true;
140
- }
141
-
142
- /**
143
- * Parses and validates a Contentful webhook
144
- *
145
- * @param body Raw webhook body as string
146
- * @param headers Webhook headers
147
- * @returns Validation result with parsed payload if valid
148
- */
149
- parseWebhook(
150
- body: string,
151
- headers: Partial<ContentfulWebhookHeaders>
152
- ): WebhookValidationResult {
153
- try {
154
- // Validate required headers
155
- const signature = headers['x-contentful-signature'];
156
- if (!signature) {
157
- return {
158
- valid: false,
159
- error: 'Missing signature header (x-contentful-signature)'
160
- };
161
- }
162
-
163
- const topic = headers['x-contentful-topic'];
164
- if (!topic) {
165
- return {
166
- valid: false,
167
- error: 'Missing topic header (x-contentful-topic)'
168
- };
169
- }
170
-
171
- // Validate signature
172
- if (!this.validateSignature(body, signature)) {
173
- return {
174
- valid: false,
175
- error: 'Invalid signature'
176
- };
177
- }
178
-
179
- // Parse payload
180
- const payload: ContentfulWebhookPayload = JSON.parse(body) as ContentfulWebhookPayload;
181
-
182
- // Validate timestamp for replay attack prevention
183
- if (!this.validateTimestamp(payload)) {
184
- return {
185
- valid: false,
186
- error: 'Invalid timestamp (possible replay attack)'
187
- };
188
- }
189
-
190
- this.logger.info('Webhook validated successfully', {
191
- topic,
192
- id: payload.sys.id,
193
- type: payload.sys.type
194
- });
195
-
196
- return {
197
- valid: true,
198
- payload,
199
- topic
200
- };
201
- } catch (err) {
202
- if (err instanceof Error) {
203
- this.logger.error('Failed to parse webhook', err);
204
- return {
205
- valid: false,
206
- error: err.message
207
- };
208
- } else {
209
- const errorMessage = String(err);
210
- this.logger.error('Failed to parse webhook', new Error(errorMessage));
211
- return {
212
- valid: false,
213
- error: errorMessage
214
- };
215
- }
216
- }
217
- }
218
-
219
- /**
220
- * Extracts resource type and action from a topic string
221
- *
222
- * @param topic Contentful webhook topic (e.g., "Entry.publish")
223
- * @returns Object with resource type and action
224
- */
225
- getEventType(topic: ContentfulWebhookTopic): EventType {
226
- const parts = topic.split('.');
227
-
228
- if (parts.length !== 2) {
229
- throw new Error(`Invalid topic format: ${topic}`);
230
- }
231
-
232
- const [resource, action] = parts;
233
-
234
- // Validate resource type
235
- if (!['Entry', 'Asset', 'ContentType'].includes(resource)) {
236
- throw new Error(`Invalid resource type: ${resource}`);
237
- }
238
-
239
- // Validate action type
240
- const validActions = ['create', 'save', 'auto_save', 'publish', 'unpublish', 'delete', 'archive', 'unarchive'];
241
- if (!validActions.includes(action)) {
242
- throw new Error(`Invalid action type: ${action}`);
243
- }
244
-
245
- return {
246
- resource: resource as EventType['resource'],
247
- action: action as EventType['action']
248
- };
249
- }
250
-
251
- /**
252
- * Updates the webhook secret
253
- *
254
- * @param newSecret New webhook secret
255
- */
256
- updateSecret(newSecret: string): void {
257
- this.webhookSecret = newSecret;
258
- this.logger.info('Webhook secret updated');
259
- }
260
- }
@@ -1,682 +0,0 @@
1
- /*
2
- Copyright (c) 2025 Bernier LLC
3
-
4
- This file is licensed to the client under a limited-use license.
5
- The client may use and modify this code *only within the scope of the project it was delivered for*.
6
- Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
- */
8
-
9
- import crypto from 'crypto';
10
- import { ContentfulWebhookHandler } from '../ContentfulWebhookHandler';
11
- import type {
12
- ContentfulWebhookPayload,
13
- ContentfulWebhookTopic,
14
- ContentfulWebhookHeaders
15
- } from '@bernierllc/contentful-types';
16
- import { Logger, LogLevel, ConsoleTransport } from '@bernierllc/logger';
17
-
18
- describe('ContentfulWebhookHandler', () => {
19
- const TEST_SECRET = 'test-webhook-secret';
20
- let handler: ContentfulWebhookHandler;
21
-
22
- beforeEach(() => {
23
- handler = new ContentfulWebhookHandler({
24
- webhookSecret: TEST_SECRET
25
- });
26
- });
27
-
28
- describe('constructor', () => {
29
- it('should create handler with required options', () => {
30
- expect(handler).toBeInstanceOf(ContentfulWebhookHandler);
31
- });
32
-
33
- it('should accept custom logger', () => {
34
- const customLogger = new Logger({
35
- level: LogLevel.INFO,
36
- transports: [new ConsoleTransport()],
37
- context: {
38
- service: 'CustomContext',
39
- version: '1.0.0',
40
- environment: 'test'
41
- }
42
- });
43
- const customHandler = new ContentfulWebhookHandler({
44
- webhookSecret: TEST_SECRET,
45
- logger: customLogger
46
- });
47
- expect(customHandler).toBeInstanceOf(ContentfulWebhookHandler);
48
- });
49
-
50
- it('should accept timestamp validation options', () => {
51
- const timestampHandler = new ContentfulWebhookHandler({
52
- webhookSecret: TEST_SECRET,
53
- enableTimestampValidation: true,
54
- maxTimestampDeltaSeconds: 600
55
- });
56
- expect(timestampHandler).toBeInstanceOf(ContentfulWebhookHandler);
57
- });
58
- });
59
-
60
- describe('validateSignature', () => {
61
- it('should validate correct signature', () => {
62
- const body = '{"test":"data"}';
63
- const hmac = crypto.createHmac('sha256', TEST_SECRET);
64
- hmac.update(body);
65
- const signature = hmac.digest('base64');
66
-
67
- const isValid = handler.validateSignature(body, signature);
68
- expect(isValid).toBe(true);
69
- });
70
-
71
- it('should reject invalid signature', () => {
72
- const body = '{"test":"data"}';
73
- const invalidSignature = 'invalid-signature-base64';
74
-
75
- const isValid = handler.validateSignature(body, invalidSignature);
76
- expect(isValid).toBe(false);
77
- });
78
-
79
- it('should reject tampered body', () => {
80
- const originalBody = '{"test":"data"}';
81
- const tamperedBody = '{"test":"tampered"}';
82
- const hmac = crypto.createHmac('sha256', TEST_SECRET);
83
- hmac.update(originalBody);
84
- const signature = hmac.digest('base64');
85
-
86
- const isValid = handler.validateSignature(tamperedBody, signature);
87
- expect(isValid).toBe(false);
88
- });
89
-
90
- it('should reject signature with different secret', () => {
91
- const body = '{"test":"data"}';
92
- const hmac = crypto.createHmac('sha256', 'different-secret');
93
- hmac.update(body);
94
- const signature = hmac.digest('base64');
95
-
96
- const isValid = handler.validateSignature(body, signature);
97
- expect(isValid).toBe(false);
98
- });
99
-
100
- it('should handle empty body', () => {
101
- const body = '';
102
- const hmac = crypto.createHmac('sha256', TEST_SECRET);
103
- hmac.update(body);
104
- const signature = hmac.digest('base64');
105
-
106
- const isValid = handler.validateSignature(body, signature);
107
- expect(isValid).toBe(true);
108
- });
109
-
110
- it('should return false for buffer length mismatch', () => {
111
- const body = '{"test":"data"}';
112
- const shortSignature = 'short';
113
-
114
- const isValid = handler.validateSignature(body, shortSignature);
115
- expect(isValid).toBe(false);
116
- });
117
-
118
- it('should handle crypto errors gracefully', () => {
119
- const body = '{"test":"data"}';
120
- // Intentionally malformed base64 that will cause issues
121
- const badSignature = '!!!invalid-base64!!!';
122
-
123
- const isValid = handler.validateSignature(body, badSignature);
124
- expect(isValid).toBe(false);
125
- });
126
-
127
- it('should handle non-Error exceptions in signature validation', () => {
128
- const testHandler = new ContentfulWebhookHandler({
129
- webhookSecret: TEST_SECRET
130
- });
131
-
132
- const body = '{"test":"data"}';
133
-
134
- // Mock crypto.createHmac to throw a non-Error
135
- const originalCreateHmac = crypto.createHmac;
136
- crypto.createHmac = jest.fn().mockImplementationOnce(() => {
137
- // eslint-disable-next-line @typescript-eslint/no-throw-literal
138
- throw 'crypto error';
139
- });
140
-
141
- try {
142
- const isValid = testHandler.validateSignature(body, 'signature');
143
- expect(isValid).toBe(false);
144
- } finally {
145
- // Restore original
146
- crypto.createHmac = originalCreateHmac;
147
- }
148
- });
149
- });
150
-
151
- describe('parseWebhook', () => {
152
- const createValidPayload = (): ContentfulWebhookPayload => ({
153
- sys: {
154
- type: 'Entry',
155
- id: 'test-entry-123',
156
- space: {
157
- sys: {
158
- type: 'Link',
159
- linkType: 'Space',
160
- id: 'space-123'
161
- }
162
- },
163
- environment: {
164
- sys: {
165
- type: 'Link',
166
- linkType: 'Environment',
167
- id: 'master'
168
- }
169
- },
170
- contentType: {
171
- sys: {
172
- type: 'Link',
173
- linkType: 'ContentType',
174
- id: 'blogPost'
175
- }
176
- },
177
- revision: 1,
178
- createdAt: new Date().toISOString(),
179
- updatedAt: new Date().toISOString()
180
- },
181
- fields: {
182
- title: { 'en-US': 'Test Title' },
183
- content: { 'en-US': 'Test content' }
184
- }
185
- });
186
-
187
- const signBody = (body: string): string => {
188
- const hmac = crypto.createHmac('sha256', TEST_SECRET);
189
- hmac.update(body);
190
- return hmac.digest('base64');
191
- };
192
-
193
- it('should parse valid webhook', () => {
194
- const payload = createValidPayload();
195
- const body = JSON.stringify(payload);
196
- const signature = signBody(body);
197
-
198
- const headers: Partial<ContentfulWebhookHeaders> = {
199
- 'x-contentful-signature': signature,
200
- 'x-contentful-topic': 'Entry.publish'
201
- };
202
-
203
- const result = handler.parseWebhook(body, headers);
204
-
205
- expect(result.valid).toBe(true);
206
- expect(result.error).toBeUndefined();
207
- expect(result.payload).toEqual(payload);
208
- expect(result.topic).toBe('Entry.publish');
209
- });
210
-
211
- it('should reject webhook without signature header', () => {
212
- const payload = createValidPayload();
213
- const body = JSON.stringify(payload);
214
-
215
- const headers: Partial<ContentfulWebhookHeaders> = {
216
- 'x-contentful-topic': 'Entry.publish'
217
- };
218
-
219
- const result = handler.parseWebhook(body, headers);
220
-
221
- expect(result.valid).toBe(false);
222
- expect(result.error).toBe('Missing signature header (x-contentful-signature)');
223
- expect(result.payload).toBeUndefined();
224
- });
225
-
226
- it('should reject webhook without topic header', () => {
227
- const payload = createValidPayload();
228
- const body = JSON.stringify(payload);
229
- const signature = signBody(body);
230
-
231
- const headers: Partial<ContentfulWebhookHeaders> = {
232
- 'x-contentful-signature': signature
233
- };
234
-
235
- const result = handler.parseWebhook(body, headers);
236
-
237
- expect(result.valid).toBe(false);
238
- expect(result.error).toBe('Missing topic header (x-contentful-topic)');
239
- expect(result.payload).toBeUndefined();
240
- });
241
-
242
- it('should reject webhook with invalid signature', () => {
243
- const payload = createValidPayload();
244
- const body = JSON.stringify(payload);
245
-
246
- const headers: Partial<ContentfulWebhookHeaders> = {
247
- 'x-contentful-signature': 'invalid-signature',
248
- 'x-contentful-topic': 'Entry.publish'
249
- };
250
-
251
- const result = handler.parseWebhook(body, headers);
252
-
253
- expect(result.valid).toBe(false);
254
- expect(result.error).toBe('Invalid signature');
255
- expect(result.payload).toBeUndefined();
256
- });
257
-
258
- it('should reject webhook with invalid JSON', () => {
259
- const body = 'invalid json {';
260
- const signature = signBody(body);
261
-
262
- const headers: Partial<ContentfulWebhookHeaders> = {
263
- 'x-contentful-signature': signature,
264
- 'x-contentful-topic': 'Entry.publish'
265
- };
266
-
267
- const result = handler.parseWebhook(body, headers);
268
-
269
- expect(result.valid).toBe(false);
270
- expect(result.error).toContain('Unexpected');
271
- expect(result.payload).toBeUndefined();
272
- });
273
-
274
- it('should handle non-Error exceptions in parsing', () => {
275
- // Create a new handler for this test to avoid affecting other tests
276
- const testHandler = new ContentfulWebhookHandler({
277
- webhookSecret: TEST_SECRET
278
- });
279
-
280
- const body = '{"test":"data"}';
281
- const hmac = crypto.createHmac('sha256', TEST_SECRET);
282
- hmac.update(body);
283
- const signature = hmac.digest('base64');
284
-
285
- // Mock JSON.parse to throw a non-Error
286
- const originalParse = global.JSON.parse;
287
- global.JSON.parse = jest.fn().mockImplementationOnce(() => {
288
- // eslint-disable-next-line @typescript-eslint/no-throw-literal
289
- throw 'string error';
290
- });
291
-
292
- try {
293
- const headers: Partial<ContentfulWebhookHeaders> = {
294
- 'x-contentful-signature': signature,
295
- 'x-contentful-topic': 'Entry.publish'
296
- };
297
-
298
- const result = testHandler.parseWebhook(body, headers);
299
-
300
- expect(result.valid).toBe(false);
301
- expect(result.error).toBe('string error');
302
- } finally {
303
- // Restore original
304
- global.JSON.parse = originalParse;
305
- }
306
- });
307
-
308
- it('should handle all webhook topics', () => {
309
- const topics: ContentfulWebhookTopic[] = [
310
- 'Entry.create',
311
- 'Entry.publish',
312
- 'Asset.delete',
313
- 'ContentType.save'
314
- ];
315
-
316
- topics.forEach(topic => {
317
- const payload = createValidPayload();
318
- const body = JSON.stringify(payload);
319
- const signature = signBody(body);
320
-
321
- const headers: Partial<ContentfulWebhookHeaders> = {
322
- 'x-contentful-signature': signature,
323
- 'x-contentful-topic': topic
324
- };
325
-
326
- const result = handler.parseWebhook(body, headers);
327
-
328
- expect(result.valid).toBe(true);
329
- expect(result.topic).toBe(topic);
330
- });
331
- });
332
- });
333
-
334
- describe('timestamp validation', () => {
335
- const createPayloadWithTimestamp = (timestamp: string): ContentfulWebhookPayload => ({
336
- sys: {
337
- type: 'Entry',
338
- id: 'test-entry-123',
339
- space: {
340
- sys: {
341
- type: 'Link',
342
- linkType: 'Space',
343
- id: 'space-123'
344
- }
345
- },
346
- environment: {
347
- sys: {
348
- type: 'Link',
349
- linkType: 'Environment',
350
- id: 'master'
351
- }
352
- },
353
- contentType: {
354
- sys: {
355
- type: 'Link',
356
- linkType: 'ContentType',
357
- id: 'blogPost'
358
- }
359
- },
360
- updatedAt: timestamp
361
- }
362
- });
363
-
364
- const signBody = (body: string): string => {
365
- const hmac = crypto.createHmac('sha256', TEST_SECRET);
366
- hmac.update(body);
367
- return hmac.digest('base64');
368
- };
369
-
370
- it('should accept recent timestamp when validation enabled', () => {
371
- const timestampHandler = new ContentfulWebhookHandler({
372
- webhookSecret: TEST_SECRET,
373
- enableTimestampValidation: true,
374
- maxTimestampDeltaSeconds: 300
375
- });
376
-
377
- const payload = createPayloadWithTimestamp(new Date().toISOString());
378
- const body = JSON.stringify(payload);
379
- const signature = signBody(body);
380
-
381
- const headers: Partial<ContentfulWebhookHeaders> = {
382
- 'x-contentful-signature': signature,
383
- 'x-contentful-topic': 'Entry.publish'
384
- };
385
-
386
- const result = timestampHandler.parseWebhook(body, headers);
387
-
388
- expect(result.valid).toBe(true);
389
- });
390
-
391
- it('should reject old timestamp when validation enabled', () => {
392
- const timestampHandler = new ContentfulWebhookHandler({
393
- webhookSecret: TEST_SECRET,
394
- enableTimestampValidation: true,
395
- maxTimestampDeltaSeconds: 300
396
- });
397
-
398
- // Create timestamp 10 minutes ago (600 seconds)
399
- const oldDate = new Date(Date.now() - 600000);
400
- const payload = createPayloadWithTimestamp(oldDate.toISOString());
401
- const body = JSON.stringify(payload);
402
- const signature = signBody(body);
403
-
404
- const headers: Partial<ContentfulWebhookHeaders> = {
405
- 'x-contentful-signature': signature,
406
- 'x-contentful-topic': 'Entry.publish'
407
- };
408
-
409
- const result = timestampHandler.parseWebhook(body, headers);
410
-
411
- expect(result.valid).toBe(false);
412
- expect(result.error).toBe('Invalid timestamp (possible replay attack)');
413
- });
414
-
415
- it('should accept any timestamp when validation disabled', () => {
416
- // Default handler has timestamp validation disabled
417
- const oldDate = new Date(Date.now() - 600000);
418
- const payload = createPayloadWithTimestamp(oldDate.toISOString());
419
- const body = JSON.stringify(payload);
420
- const signature = signBody(body);
421
-
422
- const headers: Partial<ContentfulWebhookHeaders> = {
423
- 'x-contentful-signature': signature,
424
- 'x-contentful-topic': 'Entry.publish'
425
- };
426
-
427
- const result = handler.parseWebhook(body, headers);
428
-
429
- expect(result.valid).toBe(true);
430
- });
431
-
432
- it('should reject payload without timestamp when validation enabled', () => {
433
- const timestampHandler = new ContentfulWebhookHandler({
434
- webhookSecret: TEST_SECRET,
435
- enableTimestampValidation: true,
436
- maxTimestampDeltaSeconds: 300
437
- });
438
-
439
- const payload: ContentfulWebhookPayload = {
440
- sys: {
441
- type: 'Entry',
442
- id: 'test-entry-123',
443
- space: {
444
- sys: {
445
- type: 'Link',
446
- linkType: 'Space',
447
- id: 'space-123'
448
- }
449
- },
450
- environment: {
451
- sys: {
452
- type: 'Link',
453
- linkType: 'Environment',
454
- id: 'master'
455
- }
456
- }
457
- }
458
- };
459
-
460
- const body = JSON.stringify(payload);
461
- const signature = signBody(body);
462
-
463
- const headers: Partial<ContentfulWebhookHeaders> = {
464
- 'x-contentful-signature': signature,
465
- 'x-contentful-topic': 'Entry.publish'
466
- };
467
-
468
- const result = timestampHandler.parseWebhook(body, headers);
469
-
470
- expect(result.valid).toBe(false);
471
- expect(result.error).toBe('Invalid timestamp (possible replay attack)');
472
- });
473
- });
474
-
475
- describe('getEventType', () => {
476
- it('should parse Entry.publish topic', () => {
477
- const result = handler.getEventType('Entry.publish');
478
- expect(result.resource).toBe('Entry');
479
- expect(result.action).toBe('publish');
480
- });
481
-
482
- it('should parse Asset.delete topic', () => {
483
- const result = handler.getEventType('Asset.delete');
484
- expect(result.resource).toBe('Asset');
485
- expect(result.action).toBe('delete');
486
- });
487
-
488
- it('should parse ContentType.save topic', () => {
489
- const result = handler.getEventType('ContentType.save');
490
- expect(result.resource).toBe('ContentType');
491
- expect(result.action).toBe('save');
492
- });
493
-
494
- it('should parse Entry.create topic', () => {
495
- const result = handler.getEventType('Entry.create');
496
- expect(result.resource).toBe('Entry');
497
- expect(result.action).toBe('create');
498
- });
499
-
500
- it('should parse Entry.auto_save topic', () => {
501
- const result = handler.getEventType('Entry.auto_save');
502
- expect(result.resource).toBe('Entry');
503
- expect(result.action).toBe('auto_save');
504
- });
505
-
506
- it('should parse Entry.archive topic', () => {
507
- const result = handler.getEventType('Entry.archive');
508
- expect(result.resource).toBe('Entry');
509
- expect(result.action).toBe('archive');
510
- });
511
-
512
- it('should parse Entry.unarchive topic', () => {
513
- const result = handler.getEventType('Entry.unarchive');
514
- expect(result.resource).toBe('Entry');
515
- expect(result.action).toBe('unarchive');
516
- });
517
-
518
- it('should parse Entry.unpublish topic', () => {
519
- const result = handler.getEventType('Entry.unpublish');
520
- expect(result.resource).toBe('Entry');
521
- expect(result.action).toBe('unpublish');
522
- });
523
-
524
- it('should throw error for invalid topic format', () => {
525
- expect(() => {
526
- handler.getEventType('InvalidTopic' as ContentfulWebhookTopic);
527
- }).toThrow('Invalid topic format: InvalidTopic');
528
- });
529
-
530
- it('should throw error for too many parts', () => {
531
- expect(() => {
532
- handler.getEventType('Entry.publish.extra' as ContentfulWebhookTopic);
533
- }).toThrow('Invalid topic format: Entry.publish.extra');
534
- });
535
-
536
- it('should throw error for invalid resource', () => {
537
- expect(() => {
538
- handler.getEventType('InvalidResource.publish' as ContentfulWebhookTopic);
539
- }).toThrow('Invalid resource type: InvalidResource');
540
- });
541
-
542
- it('should throw error for invalid action', () => {
543
- expect(() => {
544
- handler.getEventType('Entry.invalidAction' as ContentfulWebhookTopic);
545
- }).toThrow('Invalid action type: invalidAction');
546
- });
547
- });
548
-
549
- describe('updateSecret', () => {
550
- it('should update webhook secret', () => {
551
- const newSecret = 'new-test-secret';
552
- handler.updateSecret(newSecret);
553
-
554
- // Verify new secret works
555
- const body = '{"test":"data"}';
556
- const hmac = crypto.createHmac('sha256', newSecret);
557
- hmac.update(body);
558
- const signature = hmac.digest('base64');
559
-
560
- const isValid = handler.validateSignature(body, signature);
561
- expect(isValid).toBe(true);
562
- });
563
-
564
- it('should invalidate old secret after update', () => {
565
- const body = '{"test":"data"}';
566
- const hmac = crypto.createHmac('sha256', TEST_SECRET);
567
- hmac.update(body);
568
- const oldSignature = hmac.digest('base64');
569
-
570
- // Update to new secret
571
- handler.updateSecret('new-test-secret');
572
-
573
- // Old signature should no longer work
574
- const isValid = handler.validateSignature(body, oldSignature);
575
- expect(isValid).toBe(false);
576
- });
577
- });
578
-
579
- describe('integration scenarios', () => {
580
- it('should handle complete Entry.publish webhook', () => {
581
- const payload: ContentfulWebhookPayload = {
582
- sys: {
583
- type: 'Entry',
584
- id: 'blog-post-123',
585
- space: {
586
- sys: {
587
- type: 'Link',
588
- linkType: 'Space',
589
- id: 'my-space'
590
- }
591
- },
592
- environment: {
593
- sys: {
594
- type: 'Link',
595
- linkType: 'Environment',
596
- id: 'master'
597
- }
598
- },
599
- contentType: {
600
- sys: {
601
- type: 'Link',
602
- linkType: 'ContentType',
603
- id: 'blogPost'
604
- }
605
- },
606
- revision: 5,
607
- createdAt: '2025-01-01T00:00:00Z',
608
- updatedAt: new Date().toISOString()
609
- },
610
- fields: {
611
- title: { 'en-US': 'My Blog Post' },
612
- content: { 'en-US': 'Blog post content here' }
613
- }
614
- };
615
-
616
- const body = JSON.stringify(payload);
617
- const hmac = crypto.createHmac('sha256', TEST_SECRET);
618
- hmac.update(body);
619
- const signature = hmac.digest('base64');
620
-
621
- const headers: ContentfulWebhookHeaders = {
622
- 'x-contentful-signature': signature,
623
- 'x-contentful-topic': 'Entry.publish',
624
- 'x-contentful-webhook-name': 'test-webhook',
625
- 'x-contentful-crn': 'crn:contentful:::content:spaces/my-space'
626
- };
627
-
628
- const result = handler.parseWebhook(body, headers);
629
-
630
- expect(result.valid).toBe(true);
631
- expect(result.payload?.sys.id).toBe('blog-post-123');
632
- expect(result.topic).toBe('Entry.publish');
633
-
634
- if (result.topic) {
635
- const eventType = handler.getEventType(result.topic);
636
- expect(eventType.resource).toBe('Entry');
637
- expect(eventType.action).toBe('publish');
638
- }
639
- });
640
-
641
- it('should handle Asset.delete webhook', () => {
642
- const payload: ContentfulWebhookPayload = {
643
- sys: {
644
- type: 'Asset',
645
- id: 'asset-456',
646
- space: {
647
- sys: {
648
- type: 'Link',
649
- linkType: 'Space',
650
- id: 'my-space'
651
- }
652
- },
653
- environment: {
654
- sys: {
655
- type: 'Link',
656
- linkType: 'Environment',
657
- id: 'master'
658
- }
659
- },
660
- revision: 1,
661
- updatedAt: new Date().toISOString()
662
- }
663
- };
664
-
665
- const body = JSON.stringify(payload);
666
- const hmac = crypto.createHmac('sha256', TEST_SECRET);
667
- hmac.update(body);
668
- const signature = hmac.digest('base64');
669
-
670
- const headers: Partial<ContentfulWebhookHeaders> = {
671
- 'x-contentful-signature': signature,
672
- 'x-contentful-topic': 'Asset.delete'
673
- };
674
-
675
- const result = handler.parseWebhook(body, headers);
676
-
677
- expect(result.valid).toBe(true);
678
- expect(result.payload?.sys.type).toBe('Asset');
679
- expect(result.topic).toBe('Asset.delete');
680
- });
681
- });
682
- });
@@ -1,78 +0,0 @@
1
- /*
2
- Copyright (c) 2025 Bernier LLC
3
-
4
- This file is licensed to the client under a limited-use license.
5
- The client may use and modify this code *only within the scope of the project it was delivered for*.
6
- Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
- */
8
-
9
- import {
10
- ContentfulWebhookHandler,
11
- type WebhookValidationResult,
12
- type EventType,
13
- type ContentfulWebhookHandlerOptions,
14
- type ContentfulWebhookTopic,
15
- type ContentfulWebhookHeaders,
16
- type ContentfulWebhookPayload
17
- } from '../index';
18
-
19
- describe('index exports', () => {
20
- it('should export ContentfulWebhookHandler class', () => {
21
- expect(ContentfulWebhookHandler).toBeDefined();
22
- expect(typeof ContentfulWebhookHandler).toBe('function');
23
- });
24
-
25
- it('should create a ContentfulWebhookHandler instance', () => {
26
- const handler = new ContentfulWebhookHandler({
27
- webhookSecret: 'test-secret'
28
- });
29
- expect(handler).toBeInstanceOf(ContentfulWebhookHandler);
30
- });
31
-
32
- it('should export type definitions', () => {
33
- // Type check - these should compile
34
- const payload: ContentfulWebhookPayload = {
35
- sys: {
36
- type: 'Entry',
37
- id: 'test',
38
- space: {
39
- sys: { type: 'Link', linkType: 'Space', id: 'space' }
40
- },
41
- environment: {
42
- sys: { type: 'Link', linkType: 'Environment', id: 'master' }
43
- }
44
- }
45
- };
46
-
47
- const validationResult: WebhookValidationResult = {
48
- valid: true,
49
- payload,
50
- topic: 'Entry.publish'
51
- };
52
-
53
- const eventType: EventType = {
54
- resource: 'Entry',
55
- action: 'publish'
56
- };
57
-
58
- const options: ContentfulWebhookHandlerOptions = {
59
- webhookSecret: 'test'
60
- };
61
-
62
- const topic: ContentfulWebhookTopic = 'Entry.publish';
63
-
64
- const headers: ContentfulWebhookHeaders = {
65
- 'x-contentful-signature': 'sig',
66
- 'x-contentful-topic': 'Entry.publish',
67
- 'x-contentful-webhook-name': 'test',
68
- 'x-contentful-crn': 'crn:test'
69
- };
70
-
71
- // If these compile, the types are exported correctly
72
- expect(validationResult).toBeDefined();
73
- expect(eventType).toBeDefined();
74
- expect(options).toBeDefined();
75
- expect(topic).toBeDefined();
76
- expect(headers).toBeDefined();
77
- });
78
- });
package/src/index.ts DELETED
@@ -1,21 +0,0 @@
1
- /*
2
- Copyright (c) 2025 Bernier LLC
3
-
4
- This file is licensed to the client under a limited-use license.
5
- The client may use and modify this code *only within the scope of the project it was delivered for*.
6
- Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
- */
8
-
9
- export {
10
- ContentfulWebhookHandler,
11
- WebhookValidationResult,
12
- EventType,
13
- ContentfulWebhookHandlerOptions
14
- } from './ContentfulWebhookHandler';
15
-
16
- // Re-export commonly used types from contentful-types
17
- export type {
18
- ContentfulWebhookPayload,
19
- ContentfulWebhookTopic,
20
- ContentfulWebhookHeaders
21
- } from '@bernierllc/contentful-types';
package/tsconfig.json DELETED
@@ -1,22 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "commonjs",
5
- "lib": ["ES2022"],
6
- "declaration": true,
7
- "outDir": "./dist",
8
- "rootDir": "./src",
9
- "strict": true,
10
- "esModuleInterop": true,
11
- "skipLibCheck": true,
12
- "forceConsistentCasingInFileNames": true,
13
- "resolveJsonModule": true,
14
- "moduleResolution": "node",
15
- "noUnusedLocals": true,
16
- "noUnusedParameters": true,
17
- "noImplicitReturns": true,
18
- "noFallthroughCasesInSwitch": true
19
- },
20
- "include": ["src/**/*"],
21
- "exclude": ["node_modules", "dist"]
22
- }