n8n-nodes-lemonsqueezy 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.
package/README.md CHANGED
@@ -13,9 +13,11 @@ An [n8n](https://n8n.io/) community node for [Lemon Squeezy](https://lemonsqueez
13
13
  - **License Key Management** - Validate, activate, and deactivate license keys
14
14
  - **Checkout Links** - Create dynamic checkout URLs with custom options
15
15
  - **Rate Limiting** - Built-in retry logic with exponential backoff
16
- - **Input Validation** - Email, URL, and date format validation
16
+ - **Input Validation** - RFC 5322 compliant email validation, secure URL validation (blocks internal networks)
17
17
  - **Detailed Error Messages** - Descriptive error messages with field-level details
18
18
  - **Type Safety** - Full TypeScript support with comprehensive type definitions
19
+ - **Advanced Query Options** - Sorting and relationship expansion for "Get Many" operations
20
+ - **Security Hardened** - Mandatory webhook signature verification with replay attack protection
19
21
 
20
22
  ## Installation
21
23
 
@@ -147,6 +149,57 @@ Most "Get Many" operations support filtering:
147
149
  | `licenseKeyId` | Filter by license key | License Key Instances |
148
150
  | `discountId` | Filter by discount | Discount Redemptions |
149
151
 
152
+ ## Advanced Options
153
+
154
+ "Get Many" operations support advanced query options for sorting and including related resources.
155
+
156
+ ### Sorting
157
+
158
+ Sort results by any of the following fields:
159
+
160
+ | Sort Field | Description |
161
+ |------------|-------------|
162
+ | `created_at` | Sort by creation date |
163
+ | `updated_at` | Sort by last update date |
164
+
165
+ Choose ascending or descending order.
166
+
167
+ ### Relationship Expansion
168
+
169
+ Include related resources in a single request to reduce API calls:
170
+
171
+ | Resource | Available Relationships |
172
+ |----------|------------------------|
173
+ | **Order** | store, customer, order-items, subscriptions, license-keys, discount-redemptions |
174
+ | **Subscription** | store, customer, order, order-item, product, variant |
175
+ | **Customer** | store, orders, subscriptions, license-keys |
176
+ | **License Key** | store, customer, order, order-item, product, license-key-instances |
177
+ | **Product** | store, variants |
178
+ | **Variant** | product, files |
179
+ | **Checkout** | store, variant |
180
+ | **Discount** | store, discount-redemptions |
181
+
182
+ **Example:** When fetching orders, include `customer` and `order-items` to get all related data in one request.
183
+
184
+ ## Security
185
+
186
+ ### Webhook Security
187
+
188
+ The webhook trigger includes built-in security features:
189
+
190
+ - **Mandatory Signature Verification** - All webhooks are verified using HMAC-SHA256 signatures
191
+ - **Replay Attack Protection** - Events older than the configured threshold (default: 5 minutes) are rejected
192
+ - **Configurable Event Age** - Set `Max Event Age (Minutes)` option (0 to disable)
193
+
194
+ ### Input Validation
195
+
196
+ - **Email Validation** - RFC 5322 compliant validation
197
+ - **URL Validation** - Blocks internal/private network URLs to prevent SSRF attacks:
198
+ - localhost, 127.0.0.1, 0.0.0.0
199
+ - Private ranges: 10.x.x.x, 172.16-31.x.x, 192.168.x.x
200
+ - Link-local: 169.254.x.x (AWS metadata endpoint)
201
+ - Only allows http:// and https:// protocols
202
+
150
203
  ## Error Handling
151
204
 
152
205
  The node includes built-in error handling with detailed messages:
@@ -258,6 +311,42 @@ Contributions are welcome! Please feel free to submit a Pull Request.
258
311
 
259
312
  ## Changelog
260
313
 
314
+ ### v0.5.0
315
+
316
+ **Security & Stability Improvements:**
317
+ - Mandatory webhook signature verification (removed option to disable)
318
+ - Added replay attack protection with configurable event age threshold
319
+ - Improved email validation using RFC 5322 compliant regex
320
+ - Enhanced URL validation to block internal/private network URLs (SSRF protection)
321
+ - IPv6 localhost blocking (`[::1]`) for complete SSRF protection
322
+ - Fixed silent error catching - all errors now logged for debugging
323
+ - Added proper null checks and type safety for custom data handling
324
+
325
+ **New Features:**
326
+ - Added sorting support (created_at, updated_at) for "Get Many" operations
327
+ - Added relationship expansion (include) for fetching related resources in single requests
328
+ - Advanced options available for: Order, Subscription, Customer, License Key, Product, Variant, Checkout, Discount
329
+ - Added pagination timeout protection (default: 5 minutes) to prevent long-running requests
330
+ - Added maxItems limit support for memory optimization on large datasets
331
+
332
+ **Code Quality:**
333
+ - Added comprehensive JSDoc documentation to all helper functions
334
+ - Created shared field generators to reduce code duplication
335
+ - Added TypeScript types for webhooks, errors, and pagination (WebhookMeta, ApiError, PaginationOptions)
336
+ - Improved type safety throughout the codebase
337
+
338
+ **Documentation:**
339
+ - Added SECURITY.md with security policy and vulnerability reporting guidelines
340
+ - Added CONTRIBUTING.md with development setup and contribution guidelines
341
+
342
+ **Test Coverage:**
343
+ - Expanded test suite from 132 to 176 tests (+33%)
344
+ - Added tests for retry logic helpers (sleep, isRateLimitError, isRetryableError)
345
+ - Added webhook signature edge case tests (unicode, long payloads, special characters)
346
+ - Added shared resource options tests
347
+ - Added input validation edge case tests
348
+ - Overall coverage improved to 87%+ statements
349
+
261
350
  ### v0.4.0
262
351
 
263
352
  - Added User resource for fetching authenticated user information (`getCurrent` operation)
@@ -83,12 +83,27 @@ async function handleCreate(ctx, resource, itemIndex) {
83
83
  if (additionalOptions.receiptThankYouNote) {
84
84
  productOptions.receipt_thank_you_note = additionalOptions.receiptThankYouNote;
85
85
  }
86
- if (additionalOptions.customData) {
87
- try {
88
- checkoutData.custom = JSON.parse(additionalOptions.customData);
86
+ if (additionalOptions.customData !== undefined && additionalOptions.customData !== null) {
87
+ const customDataValue = additionalOptions.customData;
88
+ if (typeof customDataValue === 'string') {
89
+ try {
90
+ const parsed = JSON.parse(customDataValue);
91
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
92
+ checkoutData.custom = parsed;
93
+ }
94
+ else {
95
+ throw new Error('customData must be a valid JSON object');
96
+ }
97
+ }
98
+ catch (error) {
99
+ throw new Error(`Invalid customData: ${error instanceof Error ? error.message : 'must be valid JSON object'}`);
100
+ }
101
+ }
102
+ else if (typeof customDataValue === 'object' && !Array.isArray(customDataValue)) {
103
+ checkoutData.custom = customDataValue;
89
104
  }
90
- catch {
91
- checkoutData.custom = additionalOptions.customData;
105
+ else {
106
+ throw new Error('customData must be a JSON string or object');
92
107
  }
93
108
  }
94
109
  if (additionalOptions.expiresAt) {
@@ -279,7 +294,21 @@ class LemonSqueezy {
279
294
  else if (operation === 'getAll') {
280
295
  const returnAll = this.getNodeParameter('returnAll', i);
281
296
  const filters = this.getNodeParameter('filters', i, {});
297
+ const advancedOptions = this.getNodeParameter('advancedOptions', i, {});
282
298
  const qs = (0, helpers_1.buildFilterParams)(filters);
299
+ // Apply sorting if specified
300
+ if (advancedOptions.sortField) {
301
+ const sortDirection = advancedOptions.sortDirection || 'desc';
302
+ const sortPrefix = sortDirection === 'desc' ? '-' : '';
303
+ qs.sort = `${sortPrefix}${advancedOptions.sortField}`;
304
+ }
305
+ // Apply relationship expansion if specified
306
+ if (advancedOptions.include && Array.isArray(advancedOptions.include)) {
307
+ const includes = advancedOptions.include;
308
+ if (includes.length > 0) {
309
+ qs.include = includes.join(',');
310
+ }
311
+ }
283
312
  if (returnAll) {
284
313
  responseData = await helpers_1.lemonSqueezyApiRequestAllItems.call(this, 'GET', `/${endpoint}`, qs);
285
314
  }
@@ -74,11 +74,11 @@ class LemonSqueezyTrigger {
74
74
  description: 'Whether to only receive test mode events',
75
75
  },
76
76
  {
77
- displayName: 'Verify Signature',
78
- name: 'verifySignature',
79
- type: 'boolean',
80
- default: true,
81
- description: 'Whether to verify the webhook signature (recommended)',
77
+ displayName: 'Max Event Age (Minutes)',
78
+ name: 'maxEventAgeMinutes',
79
+ type: 'number',
80
+ default: 5,
81
+ description: 'Maximum age of webhook events in minutes. Events older than this will be rejected to prevent replay attacks. Set to 0 to disable.',
82
82
  },
83
83
  ],
84
84
  },
@@ -97,8 +97,11 @@ class LemonSqueezyTrigger {
97
97
  await helpers_1.lemonSqueezyApiRequest.call(this, 'GET', `/webhooks/${String(webhookData.webhookId)}`);
98
98
  return true;
99
99
  }
100
- catch {
101
- // Webhook doesn't exist anymore
100
+ catch (error) {
101
+ // Webhook doesn't exist anymore or API error occurred
102
+ // Log for debugging but don't fail - we'll recreate the webhook
103
+ // eslint-disable-next-line no-console
104
+ console.debug(`Webhook ${String(webhookData.webhookId)} check failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
102
105
  delete webhookData.webhookId;
103
106
  return false;
104
107
  }
@@ -118,8 +121,11 @@ class LemonSqueezyTrigger {
118
121
  }
119
122
  }
120
123
  }
121
- catch {
122
- // Error checking webhooks, assume doesn't exist
124
+ catch (error) {
125
+ // Error checking webhooks - log for debugging
126
+ // This could indicate API issues, but we'll try to create a new webhook
127
+ // eslint-disable-next-line no-console
128
+ console.debug(`Error checking existing webhooks: ${error instanceof Error ? error.message : 'Unknown error'}`);
123
129
  }
124
130
  return false;
125
131
  },
@@ -164,8 +170,11 @@ class LemonSqueezyTrigger {
164
170
  try {
165
171
  await helpers_1.lemonSqueezyApiRequest.call(this, 'DELETE', `/webhooks/${String(webhookData.webhookId)}`);
166
172
  }
167
- catch {
168
- // Webhook might already be deleted
173
+ catch (error) {
174
+ // Webhook might already be deleted or API error
175
+ // Log for debugging but don't fail - we're cleaning up anyway
176
+ // eslint-disable-next-line no-console
177
+ console.debug(`Webhook ${String(webhookData.webhookId)} deletion failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
169
178
  }
170
179
  delete webhookData.webhookId;
171
180
  }
@@ -177,32 +186,68 @@ class LemonSqueezyTrigger {
177
186
  async webhook() {
178
187
  const options = this.getNodeParameter('options');
179
188
  const webhookSecret = this.getNodeParameter('webhookSecret');
180
- // Verify signature if enabled
181
- if (options.verifySignature !== false) {
182
- const signature = this.getHeaderData()['x-signature'];
183
- if (!signature) {
184
- return {
185
- webhookResponse: {
186
- status: 401,
187
- body: { error: 'Missing signature header' },
188
- },
189
- };
190
- }
191
- const bodyData = this.getBodyData();
192
- const rawBody = JSON.stringify(bodyData);
193
- const isValid = (0, helpers_1.verifyWebhookSignature)(rawBody, signature, webhookSecret);
194
- if (!isValid) {
195
- return {
196
- webhookResponse: {
197
- status: 401,
198
- body: { error: 'Invalid signature' },
199
- },
200
- };
201
- }
189
+ // Always verify signature - this is a security requirement
190
+ const signature = this.getHeaderData()['x-signature'];
191
+ if (!signature) {
192
+ return {
193
+ webhookResponse: {
194
+ status: 401,
195
+ body: { error: 'Missing signature header' },
196
+ },
197
+ };
198
+ }
199
+ const bodyData = this.getBodyData();
200
+ const rawBody = JSON.stringify(bodyData);
201
+ const isValid = (0, helpers_1.verifyWebhookSignature)(rawBody, signature, webhookSecret);
202
+ if (!isValid) {
203
+ return {
204
+ webhookResponse: {
205
+ status: 401,
206
+ body: { error: 'Invalid signature' },
207
+ },
208
+ };
202
209
  }
203
210
  const body = this.getBodyData();
204
211
  const meta = body.meta;
205
212
  const eventName = meta === null || meta === void 0 ? void 0 : meta.event_name;
213
+ // Replay attack protection: check event timestamp
214
+ const maxEventAgeMinutes = typeof options.maxEventAgeMinutes === 'number' ? options.maxEventAgeMinutes : 5;
215
+ if (maxEventAgeMinutes > 0 && (meta === null || meta === void 0 ? void 0 : meta.custom_data)) {
216
+ const customData = meta.custom_data;
217
+ const eventTimestamp = customData.event_created_at;
218
+ if (eventTimestamp) {
219
+ const eventTime = new Date(eventTimestamp).getTime();
220
+ const now = Date.now();
221
+ const maxAgeMs = maxEventAgeMinutes * 60 * 1000;
222
+ if (now - eventTime > maxAgeMs) {
223
+ return {
224
+ webhookResponse: {
225
+ status: 400,
226
+ body: { error: 'Event too old - possible replay attack' },
227
+ },
228
+ };
229
+ }
230
+ }
231
+ }
232
+ // Also check the created_at field in the data payload if available
233
+ if (maxEventAgeMinutes > 0) {
234
+ const data = body.data;
235
+ const attributes = data === null || data === void 0 ? void 0 : data.attributes;
236
+ const createdAt = attributes === null || attributes === void 0 ? void 0 : attributes.created_at;
237
+ if (createdAt) {
238
+ const eventTime = new Date(createdAt).getTime();
239
+ const now = Date.now();
240
+ const maxAgeMs = maxEventAgeMinutes * 60 * 1000;
241
+ if (now - eventTime > maxAgeMs) {
242
+ return {
243
+ webhookResponse: {
244
+ status: 400,
245
+ body: { error: 'Event too old - possible replay attack' },
246
+ },
247
+ };
248
+ }
249
+ }
250
+ }
206
251
  // Check if we should process this event
207
252
  const subscribedEvents = this.getNodeParameter('events');
208
253
  if (!eventName || !subscribedEvents.includes(eventName)) {