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 +90 -1
- package/dist/nodes/LemonSqueezy/LemonSqueezy.node.js +34 -5
- package/dist/nodes/LemonSqueezy/LemonSqueezyTrigger.node.js +78 -33
- package/dist/nodes/LemonSqueezy/helpers.d.ts +312 -17
- package/dist/nodes/LemonSqueezy/helpers.js +360 -24
- package/dist/nodes/LemonSqueezy/resources/index.js +9 -0
- package/dist/nodes/LemonSqueezy/resources/shared.d.ts +64 -0
- package/dist/nodes/LemonSqueezy/resources/shared.js +196 -0
- package/dist/nodes/LemonSqueezy/types.d.ts +63 -7
- package/package.json +1 -1
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** -
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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: '
|
|
78
|
-
name: '
|
|
79
|
-
type: '
|
|
80
|
-
default:
|
|
81
|
-
description: '
|
|
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
|
|
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
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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)) {
|