n8n-nodes-lemonsqueezy 0.2.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
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/n8n-nodes-lemonsqueezy.svg)](https://www.npmjs.com/package/n8n-nodes-lemonsqueezy)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![CI](https://github.com/janmaaarc/n8n-nodes-lemonsqueezy/actions/workflows/ci.yml/badge.svg)](https://github.com/janmaaarc/n8n-nodes-lemonsqueezy/actions/workflows/ci.yml)
5
6
 
6
7
  An [n8n](https://n8n.io/) community node for [Lemon Squeezy](https://lemonsqueezy.com) - a platform for selling digital products, subscriptions, and software licenses.
7
8
 
@@ -12,7 +13,11 @@ An [n8n](https://n8n.io/) community node for [Lemon Squeezy](https://lemonsqueez
12
13
  - **License Key Management** - Validate, activate, and deactivate license keys
13
14
  - **Checkout Links** - Create dynamic checkout URLs with custom options
14
15
  - **Rate Limiting** - Built-in retry logic with exponential backoff
16
+ - **Input Validation** - RFC 5322 compliant email validation, secure URL validation (blocks internal networks)
17
+ - **Detailed Error Messages** - Descriptive error messages with field-level details
15
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
16
21
 
17
22
  ## Installation
18
23
 
@@ -51,11 +56,17 @@ The main node for interacting with the Lemon Squeezy API.
51
56
  | **Checkout** | Create, Get, Get Many |
52
57
  | **Customer** | Create, Update, Delete, Get, Get Many |
53
58
  | **Discount** | Create, Delete, Get, Get Many |
59
+ | **Discount Redemption** | Get, Get Many |
54
60
  | **License Key** | Get, Get Many, Update, Validate, Activate, Deactivate |
61
+ | **License Key Instance** | Get, Get Many |
55
62
  | **Order** | Get, Get Many, Refund |
63
+ | **Order Item** | Get, Get Many |
56
64
  | **Product** | Get, Get Many |
57
65
  | **Store** | Get, Get Many |
58
66
  | **Subscription** | Get, Get Many, Update, Cancel, Resume |
67
+ | **Subscription Invoice** | Get, Get Many |
68
+ | **Usage Record** | Get, Get Many |
69
+ | **User** | Get Current |
59
70
  | **Variant** | Get, Get Many |
60
71
  | **Webhook** | Create, Update, Delete, Get, Get Many |
61
72
 
@@ -129,19 +140,87 @@ Most "Get Many" operations support filtering:
129
140
  | Filter | Description | Available On |
130
141
  |--------|-------------|--------------|
131
142
  | `storeId` | Filter by store | All resources |
132
- | `status` | Filter by status | Orders, Subscriptions, Customers, License Keys |
143
+ | `status` | Filter by status | Orders, Subscriptions, Customers, License Keys, Subscription Invoices |
133
144
  | `email` | Filter by email | Orders, Customers |
134
- | `productId` | Filter by product | Subscriptions, License Keys, Variants |
135
- | `variantId` | Filter by variant | Subscriptions, Checkouts |
136
- | `orderId` | Filter by order | Subscriptions, License Keys |
145
+ | `productId` | Filter by product | Subscriptions, License Keys, Variants, Order Items |
146
+ | `variantId` | Filter by variant | Subscriptions, Checkouts, Order Items |
147
+ | `orderId` | Filter by order | Subscriptions, License Keys, Order Items, Discount Redemptions |
148
+ | `subscriptionId` | Filter by subscription | Subscription Invoices |
149
+ | `licenseKeyId` | Filter by license key | License Key Instances |
150
+ | `discountId` | Filter by discount | Discount Redemptions |
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
137
202
 
138
203
  ## Error Handling
139
204
 
140
- The node includes built-in error handling:
205
+ The node includes built-in error handling with detailed messages:
141
206
 
142
207
  - **Rate Limiting**: Automatically waits and retries when rate limited (429 errors)
143
208
  - **Retry Logic**: Retries failed requests with exponential backoff for 5xx errors
144
209
  - **Continue on Fail**: Enable to process remaining items even if some fail
210
+ - **Detailed Errors**: Field-level error details for validation failures
211
+
212
+ ### Error Code Reference
213
+
214
+ | Status Code | Description |
215
+ |-------------|-------------|
216
+ | 400 | Bad Request - Invalid or malformed request |
217
+ | 401 | Unauthorized - Invalid or missing API key |
218
+ | 403 | Forbidden - No permission to access resource |
219
+ | 404 | Not Found - Resource does not exist |
220
+ | 409 | Conflict - Resource already exists |
221
+ | 422 | Unprocessable Entity - Invalid request data |
222
+ | 429 | Rate Limited - Too many requests |
223
+ | 500+ | Server Error - Something went wrong on the server |
145
224
 
146
225
  ## Troubleshooting
147
226
 
@@ -166,11 +245,28 @@ The node includes built-in error handling:
166
245
 
167
246
  ### Rate Limiting Issues
168
247
 
169
- The node handles rate limiting automatically, but if you're hitting limits frequently:
248
+ The node handles rate limiting automatically with the following defaults:
249
+
250
+ | Setting | Value | Description |
251
+ |---------|-------|-------------|
252
+ | Max Retries | 3 | Maximum retry attempts for failed requests |
253
+ | Retry Delay | 1 second | Initial delay between retries (exponential backoff) |
254
+ | Rate Limit Wait | 60 seconds | Wait time when rate limited (429 response) |
255
+
256
+ If you're hitting rate limits frequently:
170
257
 
171
258
  1. Reduce the frequency of API calls
172
259
  2. Use "Return All" sparingly for large datasets
173
260
  3. Consider caching responses where appropriate
261
+ 4. Space out bulk operations with delays
262
+
263
+ ### Validation Errors
264
+
265
+ If you receive validation errors:
266
+
267
+ 1. Check email fields contain valid email addresses
268
+ 2. Verify URLs are complete (including https://)
269
+ 3. Ensure dates are in ISO 8601 format (e.g., 2024-01-15T10:30:00Z)
174
270
 
175
271
  ## Development
176
272
 
@@ -184,11 +280,17 @@ npm run build
184
280
  # Run tests
185
281
  npm test
186
282
 
283
+ # Run tests with coverage
284
+ npm run test:coverage
285
+
187
286
  # Run linter
188
287
  npm run lint
189
288
 
190
289
  # Format code
191
290
  npm run format
291
+
292
+ # Type check
293
+ npm run typecheck
192
294
  ```
193
295
 
194
296
  ## Contributing
@@ -207,6 +309,68 @@ Contributions are welcome! Please feel free to submit a Pull Request.
207
309
  - [n8n Community Nodes Documentation](https://docs.n8n.io/integrations/community-nodes/)
208
310
  - [n8n Community Forum](https://community.n8n.io/)
209
311
 
312
+ ## Changelog
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
+
350
+ ### v0.4.0
351
+
352
+ - Added User resource for fetching authenticated user information (`getCurrent` operation)
353
+ - Expanded test suite to 130 tests with 85%+ statement coverage
354
+ - Added comprehensive tests for credentials, node descriptions, and helpers
355
+ - Fixed TypeScript strict mode warnings in test files
356
+ - Updated coverage thresholds to 70%
357
+
358
+ ### v0.3.0
359
+
360
+ - Added new resources: Order Items, Subscription Invoices, License Key Instances, Discount Redemptions, Usage Records
361
+ - Added input validation for emails, URLs, and dates
362
+ - Improved error messages with field-level details
363
+ - Added advanced filtering with sorting support
364
+ - Added relationship expansion helpers
365
+ - Added security audit in CI pipeline
366
+ - Added coverage reporting with lcov output
367
+
368
+ ### v0.2.0
369
+
370
+ - Initial release with full Lemon Squeezy API support
371
+ - Webhook trigger node
372
+ - Rate limiting and retry logic
373
+
210
374
  ## License
211
375
 
212
376
  [MIT](LICENSE)
@@ -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
  }
@@ -338,6 +367,9 @@ class LemonSqueezy {
338
367
  });
339
368
  }
340
369
  }
370
+ else if (resource === 'user' && operation === 'getCurrent') {
371
+ responseData = await helpers_1.lemonSqueezyApiRequest.call(this, 'GET', '/users/me');
372
+ }
341
373
  const executionData = this.helpers.constructExecutionMetaData(this.helpers.returnJsonArray(responseData), { itemData: { item: i } });
342
374
  returnData.push(...executionData);
343
375
  }
@@ -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)) {
@@ -15,15 +15,20 @@ exports.RATE_LIMIT_DELAY_MS = 60000;
15
15
  exports.RESOURCE_ENDPOINTS = {
16
16
  product: 'products',
17
17
  order: 'orders',
18
+ orderItem: 'order-items',
18
19
  subscription: 'subscriptions',
20
+ subscriptionInvoice: 'subscription-invoices',
19
21
  customer: 'customers',
20
22
  licenseKey: 'license-keys',
23
+ licenseKeyInstance: 'license-key-instances',
21
24
  discount: 'discounts',
25
+ discountRedemption: 'discount-redemptions',
22
26
  store: 'stores',
23
27
  variant: 'variants',
24
28
  checkout: 'checkouts',
25
29
  webhook: 'webhooks',
26
- licenseKeyInstance: 'license-key-instances',
30
+ usageRecord: 'usage-records',
31
+ user: 'users',
27
32
  };
28
33
  /**
29
34
  * Resource to ID parameter mapping
@@ -31,15 +36,19 @@ exports.RESOURCE_ENDPOINTS = {
31
36
  exports.RESOURCE_ID_PARAMS = {
32
37
  product: 'productId',
33
38
  order: 'orderId',
39
+ orderItem: 'orderItemId',
34
40
  subscription: 'subscriptionId',
41
+ subscriptionInvoice: 'subscriptionInvoiceId',
35
42
  customer: 'customerId',
36
43
  licenseKey: 'licenseKeyId',
44
+ licenseKeyInstance: 'licenseKeyInstanceId',
37
45
  discount: 'discountId',
46
+ discountRedemption: 'discountRedemptionId',
38
47
  store: 'storeId',
39
48
  variant: 'variantId',
40
49
  checkout: 'checkoutId',
41
50
  webhook: 'webhookId',
42
- licenseKeyInstance: 'licenseKeyInstanceId',
51
+ usageRecord: 'usageRecordId',
43
52
  };
44
53
  /**
45
54
  * Webhook event types with descriptions