@ixiam/n8n-nodes-civicrm 2.1.8 → 3.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.
- package/README.md +143 -5
- package/dist/credentials/CiviCrmApi.credentials.js +55 -7
- package/dist/src/nodes/CiviCrm/CiviCrm.node.js +260 -27
- package/dist/src/nodes/CiviCrm/descriptions/resources.js +42 -1
- package/dist/src/nodes/transport/GenericFunctions.js +195 -12
- package/dist/src/nodes/transport/JwtAuth.js +141 -0
- package/package.json +10 -2
package/README.md
CHANGED
|
@@ -40,14 +40,119 @@ Download: https://civicrm.org/download
|
|
|
40
40
|
|
|
41
41
|
## 🔐 Credentials
|
|
42
42
|
|
|
43
|
-
The node
|
|
43
|
+
The node supports two authentication modes:
|
|
44
|
+
|
|
45
|
+
### **1. Standard API Key Authentication**
|
|
46
|
+
Simple and reliable for most use cases.
|
|
47
|
+
|
|
48
|
+
| Field | Description |
|
|
49
|
+
|-------|-------------|
|
|
50
|
+
| **Base URL** | Root URL of CiviCRM. Example: `https://crm.example.org` |
|
|
51
|
+
| **API Token** | Your API Key from CiviCRM (sent as `X-Civi-Auth: Bearer <token>`) |
|
|
52
|
+
|
|
53
|
+
### **2. JWT Authentication with Auto-Resolve (v3.0+)**
|
|
54
|
+
Time-bounded tokens for enhanced security. **Contact ID is automatically resolved** from your API Key.
|
|
44
55
|
|
|
45
56
|
| Field | Description |
|
|
46
57
|
|-------|-------------|
|
|
47
|
-
| **Base URL** |
|
|
48
|
-
| **API Token** |
|
|
58
|
+
| **Base URL** | Root URL of CiviCRM |
|
|
59
|
+
| **API Token** | Your API Key (used to resolve Contact ID & generate JWT) |
|
|
60
|
+
| **Enable JWT Auth** | Toggle to enable time-bounded JWT tokens |
|
|
61
|
+
| **JWT Header Mode** | Where to send JWT (X-Civi-Auth recommended) |
|
|
62
|
+
|
|
63
|
+
#### How JWT Auto-Resolve Works
|
|
64
|
+
|
|
65
|
+
1. When making an API call, the node automatically:
|
|
66
|
+
- Resolves your Contact ID from your API Key
|
|
67
|
+
- Generates a time-bounded JWT token (1 hour default)
|
|
68
|
+
- Caches both for efficiency
|
|
69
|
+
|
|
70
|
+
2. The JWT is used for API requests, with **automatic fallback to API Key** if:
|
|
71
|
+
- JWT generation fails
|
|
72
|
+
- JWT returns empty results
|
|
73
|
+
- JWT expires
|
|
74
|
+
|
|
75
|
+
3. **Zero manual configuration** - Contact ID is detected automatically
|
|
76
|
+
|
|
77
|
+
> **Note on warnings vs. results:** When JWT auth is enabled but the token can't be obtained (e.g. AuthX disabled or misconfigured on the CiviCRM side), the node still returns data — it silently retries the same request with the API Key. You'll see a **warning in the node's output pane** explaining why JWT wasn't used, but the workflow does not fail and the query results are still shown. This is intentional: JWT failures never break a workflow, they only fall back to API Key auth and surface a non-blocking warning.
|
|
78
|
+
|
|
79
|
+
#### JWT Setup Requirements
|
|
80
|
+
|
|
81
|
+
**CiviCRM Extensions & Settings:**
|
|
82
|
+
- ✓ AuthX extension enabled (CiviCRM 5.48+)
|
|
83
|
+
- ✓ API Key assigned to a Contact record
|
|
84
|
+
|
|
85
|
+
**CiviCRM Authentication Configuration**
|
|
86
|
+
|
|
87
|
+
The following settings must be enabled in CiviCRM's Authentication configuration:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
Administer → System Settings → Authentication
|
|
91
|
+
├─ AuthX Header Authentication: ✓ ENABLED
|
|
92
|
+
├─ AuthX XHeader Support: ✓ ENABLED
|
|
93
|
+
├─ Credential Types: Include 'jwt'
|
|
94
|
+
└─ XHeader Name: Set to 'X-Civi-Auth' (default)
|
|
95
|
+
```
|
|
49
96
|
|
|
50
|
-
|
|
97
|
+
**Settings in civicrm.settings.php:**
|
|
98
|
+
```php
|
|
99
|
+
// Enable AuthX for JWT tokens
|
|
100
|
+
define('CIVICRM_AUTHX_XHEADER_CRED', json_encode(['jwt']));
|
|
101
|
+
|
|
102
|
+
// Optional: Configure JWT signing keys (auto-generated)
|
|
103
|
+
// define('CIVICRM_AUTHX_SIGN_KEY', 'your-signing-key');
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**How to Enable:**
|
|
107
|
+
1. Go to **Administer → System Settings → Authentication**
|
|
108
|
+
2. Find **AuthX Settings** section
|
|
109
|
+
3. Check: `✓ Enable X-Header Authentication`
|
|
110
|
+
4. Check: `✓ JWT in X-Civi-Auth header`
|
|
111
|
+
5. Verify **Header name**: `X-Civi-Auth`
|
|
112
|
+
6. **Save** configuration
|
|
113
|
+
|
|
114
|
+
#### JWT Token Rotation & Security
|
|
115
|
+
|
|
116
|
+
**Token Lifetime & Auto-Refresh:**
|
|
117
|
+
- JWT tokens auto-expire after 1 hour (configurable in n8n credential)
|
|
118
|
+
- n8n automatically refreshes expired tokens (transparent to workflows)
|
|
119
|
+
- No manual intervention needed
|
|
120
|
+
|
|
121
|
+
**Security Best Practices:**
|
|
122
|
+
|
|
123
|
+
| Action | Frequency | Reason |
|
|
124
|
+
|--------|-----------|--------|
|
|
125
|
+
| **Rotate API Keys** | Every 90 days | Limits exposure window if compromised |
|
|
126
|
+
| **Review AuthX Logs** | Monthly | Detect unauthorized access attempts |
|
|
127
|
+
| **Monitor JWT Usage** | Real-time | Alert on unusual patterns |
|
|
128
|
+
| **Update CiviCRM** | As released | Includes security patches |
|
|
129
|
+
| **Audit n8n Workflows** | Quarterly | Verify only needed credentials used |
|
|
130
|
+
|
|
131
|
+
**When to Regenerate Credentials:**
|
|
132
|
+
- ✅ Regular security rotation (quarterly)
|
|
133
|
+
- ✅ Suspected credential compromise
|
|
134
|
+
- ✅ Staff member leaves organization
|
|
135
|
+
- ✅ n8n instance compromised
|
|
136
|
+
- ✅ Failed authentication attempts detected
|
|
137
|
+
|
|
138
|
+
**How to Revoke Access Immediately:**
|
|
139
|
+
```
|
|
140
|
+
Option 1: Disable JWT (n8n credential)
|
|
141
|
+
├─ Uncheck "Enable JWT Auth" toggle
|
|
142
|
+
└─ Node falls back to API Key (effective immediately)
|
|
143
|
+
|
|
144
|
+
Option 2: Regenerate API Key (CiviCRM)
|
|
145
|
+
├─ Go to Contact record
|
|
146
|
+
├─ Regenerate API Key field
|
|
147
|
+
└─ Old JWT & API Key become invalid instantly
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**→ See [JWT_QUICKSTART.md](JWT_QUICKSTART.md) for 5-minute setup**
|
|
151
|
+
**→ See [JWT_AUTORESOLVE_SETUP.md](JWT_AUTORESOLVE_SETUP.md) for detailed configuration**
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
After entering credentials, click **Test credentials** to validate the connection.
|
|
51
156
|
|
|
52
157
|
---
|
|
53
158
|
|
|
@@ -128,13 +233,46 @@ Example:
|
|
|
128
233
|
}
|
|
129
234
|
```
|
|
130
235
|
|
|
236
|
+
### **7. JWT Authentication with Auto-Resolve (v3.0+)**
|
|
237
|
+
Enhanced security with time-bounded tokens:
|
|
238
|
+
|
|
239
|
+
**Features:**
|
|
240
|
+
- ✨ **Automatic Contact ID Resolution** - No manual input needed
|
|
241
|
+
- ✨ **Time-Bounded Tokens** - 1 hour default (configurable)
|
|
242
|
+
- ✨ **Smart Fallback** - Automatically falls back to API Key if JWT insufficient
|
|
243
|
+
- ✨ **Efficient Caching** - Contact ID & JWT cached for performance
|
|
244
|
+
- ✨ **Zero Configuration** - Just enable "JWT Auth" toggle
|
|
245
|
+
|
|
246
|
+
**Example Workflow:**
|
|
247
|
+
```
|
|
248
|
+
Enable JWT in credential
|
|
249
|
+
↓
|
|
250
|
+
Make API call
|
|
251
|
+
↓
|
|
252
|
+
[Automatic]
|
|
253
|
+
├─ Resolve: Which Contact owns this API Key?
|
|
254
|
+
├─ Generate: Time-bounded JWT token
|
|
255
|
+
├─ Try: API call with JWT
|
|
256
|
+
└─ Fallback: If empty, retry with API Key
|
|
257
|
+
↓
|
|
258
|
+
Get results
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
**Security Benefits vs API Key Only:**
|
|
262
|
+
- ✓ Tokens expire automatically (1 hour)
|
|
263
|
+
- ✓ Leaked token has limited lifetime
|
|
264
|
+
- ✓ Can't be used outside CiviCRM
|
|
265
|
+
- ✓ Full audit trail available
|
|
266
|
+
|
|
267
|
+
→ **Learn more:** [JWT_AUTORESOLVE_SETUP.md](JWT_AUTORESOLVE_SETUP.md)
|
|
268
|
+
|
|
131
269
|
---
|
|
132
270
|
|
|
133
271
|
## Compatibility
|
|
134
272
|
|
|
135
273
|
- **n8n version:** 1.0.0 or higher
|
|
136
274
|
- **Node.js:** 18 or higher
|
|
137
|
-
- **CiviCRM:** API v4 compatible
|
|
275
|
+
- **CiviCRM:** API v4 compatible
|
|
138
276
|
|
|
139
277
|
---
|
|
140
278
|
|
|
@@ -26,15 +26,62 @@ class CiviCrmApi {
|
|
|
26
26
|
default: '',
|
|
27
27
|
required: true,
|
|
28
28
|
},
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
{
|
|
30
|
+
displayName: 'Enable JWT Authentication',
|
|
31
|
+
name: 'enableJwtAuth',
|
|
32
|
+
type: 'boolean',
|
|
33
|
+
default: false,
|
|
34
|
+
description: 'Enable server-issued JWT authentication for improved security (time-bound tokens, auto-resolved contact ID). When disabled, uses API key authentication.',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
displayName: 'JWT Header Mode',
|
|
38
|
+
name: 'jwtHeaderMode',
|
|
39
|
+
type: 'options',
|
|
40
|
+
default: 'xheader',
|
|
41
|
+
displayOptions: {
|
|
42
|
+
show: {
|
|
43
|
+
enableJwtAuth: [true],
|
|
44
|
+
},
|
|
35
45
|
},
|
|
46
|
+
options: [
|
|
47
|
+
{
|
|
48
|
+
name: 'X-Civi-Auth Header (Recommended)',
|
|
49
|
+
value: 'xheader',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'Authorization Header',
|
|
53
|
+
value: 'authorization',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'Both Headers',
|
|
57
|
+
value: 'both',
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
description: 'Where to send the JWT token.',
|
|
36
61
|
},
|
|
37
|
-
|
|
62
|
+
{
|
|
63
|
+
displayName: 'JWT Expiry (Seconds)',
|
|
64
|
+
name: 'jwtExpiry',
|
|
65
|
+
type: 'number',
|
|
66
|
+
default: 3600,
|
|
67
|
+
typeOptions: {
|
|
68
|
+
minValue: 60,
|
|
69
|
+
maxValue: 86400,
|
|
70
|
+
},
|
|
71
|
+
displayOptions: {
|
|
72
|
+
show: {
|
|
73
|
+
enableJwtAuth: [true],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
description: 'Lifetime of the server-issued JWT, in seconds, before it must be renewed. Default is 3600 (1 hour).',
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
// n8n's CredentialsTester always prefers a `test` defined directly here over the
|
|
80
|
+
// CiviCrm node's `testedBy: 'testCiviCrmApiConnection'` (see CiviCrm.node.ts), so
|
|
81
|
+
// that JWT-aware test never actually runs while this declarative test exists.
|
|
82
|
+
// It's kept as a plain API-key connectivity check so "Test credentials" always
|
|
83
|
+
// works reliably; the node's execution path (addExecutionHints) is what surfaces
|
|
84
|
+
// JWT-specific failures instead.
|
|
38
85
|
this.test = {
|
|
39
86
|
request: {
|
|
40
87
|
baseURL: '={{$credentials.baseUrl}}',
|
|
@@ -42,6 +89,7 @@ class CiviCrmApi {
|
|
|
42
89
|
method: 'POST',
|
|
43
90
|
headers: {
|
|
44
91
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
92
|
+
'X-Civi-Auth': '={{ "Bearer " + $credentials.apiToken }}',
|
|
45
93
|
},
|
|
46
94
|
body: {
|
|
47
95
|
params: JSON.stringify({ select: ['id'], limit: 1 }),
|
|
@@ -57,12 +57,9 @@ class CiviCrm {
|
|
|
57
57
|
icon: 'file:civicrm.svg',
|
|
58
58
|
group: ['transform'],
|
|
59
59
|
version: 1,
|
|
60
|
-
description: 'Interact with CiviCRM API v4
|
|
61
|
-
'Supports Contact, Membership, Group, Relationship, Activity entities, and Custom API Call.\n' +
|
|
62
|
-
'Includes dynamic mapping of email, phone, address and location types.\n' +
|
|
63
|
-
'Includes birth_date validation and JSON filters for GET MANY.\n',
|
|
60
|
+
description: 'Interact with CiviCRM API v4 to manage contacts, memberships, groups, and more.',
|
|
64
61
|
defaults: { name: 'CiviCRM' },
|
|
65
|
-
subtitle: '={{{"get":"Get","getMany":"Get Many","create":"Create","update":"Update","delete":"Delete"}[$parameter["operation"]] + ": " + {"contact":"Contact","membership":"Membership","group":"Group","relationship":"Relationship","activity":"Activity","customApi":"Custom API Call"}[$parameter["resource"]]}}',
|
|
62
|
+
subtitle: '={{{"get":"Get","getMany":"Get Many","create":"Create","update":"Update","delete":"Delete","raw":"Raw API Call","getFields":"List Fields","search":"Dynamic Search"}[$parameter["operation"]] + ": " + {"contact":"Contact","membership":"Membership","group":"Group","relationship":"Relationship","activity":"Activity","customApi":"Custom API Call"}[$parameter["resource"]]}}',
|
|
66
63
|
inputs: [n8n_workflow_1.NodeConnectionTypes.Main],
|
|
67
64
|
outputs: [n8n_workflow_1.NodeConnectionTypes.Main],
|
|
68
65
|
// @ts-ignore
|
|
@@ -406,6 +403,65 @@ class CiviCrm {
|
|
|
406
403
|
},
|
|
407
404
|
],
|
|
408
405
|
},
|
|
406
|
+
// ======================================================================
|
|
407
|
+
// CUSTOM API — LIST FIELDS / DYNAMIC SEARCH
|
|
408
|
+
// ======================================================================
|
|
409
|
+
{
|
|
410
|
+
displayName: 'List Fields (Any Entity)',
|
|
411
|
+
name: 'customApiGetFields',
|
|
412
|
+
action: 'List fields for any CiviCRM entity',
|
|
413
|
+
description: 'Call {Entity}/getFields on any CiviCRM APIv4 entity (Contact, Contribution, Event, Case, custom entities, etc.) and return field metadata (name, type, required, options...) as node output. Run this before a Dynamic Search or Custom API Call for an entity/field you have not verified yet on this installation.',
|
|
414
|
+
displayOptions: { show: { resource: ['customApi'], operation: ['getFields'] } },
|
|
415
|
+
properties: [
|
|
416
|
+
{
|
|
417
|
+
displayName: 'Action Context',
|
|
418
|
+
name: 'getFieldsAction',
|
|
419
|
+
type: 'string',
|
|
420
|
+
default: 'get',
|
|
421
|
+
description: 'CiviCRM action context passed to getFields (e.g. get, create, update). Affects which fields are reported as required/readonly for that context.',
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
displayName: 'Dynamic Search (Any Entity)',
|
|
427
|
+
name: 'customApiSearch',
|
|
428
|
+
action: 'Run a dynamic search on any CiviCRM entity',
|
|
429
|
+
description: 'Run {Entity}/get with a configurable Select and Where for any CiviCRM APIv4 entity, not limited to Contact/Membership/Group/Relationship/Activity. Verify field names first with List Fields (Any Entity).',
|
|
430
|
+
displayOptions: { show: { resource: ['customApi'], operation: ['search'] } },
|
|
431
|
+
properties: [
|
|
432
|
+
{
|
|
433
|
+
displayName: 'Select (JSON)',
|
|
434
|
+
name: 'searchSelectJson',
|
|
435
|
+
type: 'string',
|
|
436
|
+
default: '["id"]',
|
|
437
|
+
placeholder: '["id","display_name","custom_12"]',
|
|
438
|
+
description: 'JSON array of field names to return, verified beforehand via getFields.',
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
displayName: 'Where (JSON)',
|
|
442
|
+
name: 'searchWhereJson',
|
|
443
|
+
type: 'string',
|
|
444
|
+
default: '',
|
|
445
|
+
placeholder: '[["city","=","Bilbao"]]',
|
|
446
|
+
description: "JSON array of [field, operator, value] triples, same pattern as Get Many's Where (JSON).",
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
displayName: 'Return All',
|
|
450
|
+
name: 'searchReturnAll',
|
|
451
|
+
type: 'boolean',
|
|
452
|
+
default: false,
|
|
453
|
+
description: 'Whether to return all results or only up to a given limit.',
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
displayName: 'Limit',
|
|
457
|
+
name: 'searchLimit',
|
|
458
|
+
type: 'number',
|
|
459
|
+
typeOptions: { minValue: 1, maxValue: 1000 },
|
|
460
|
+
default: 100,
|
|
461
|
+
description: 'Max number of results to return. Defaults to 100.',
|
|
462
|
+
},
|
|
463
|
+
],
|
|
464
|
+
},
|
|
409
465
|
],
|
|
410
466
|
credentials: [{ name: 'civiCrmApi', required: true }],
|
|
411
467
|
properties: [
|
|
@@ -414,6 +470,7 @@ class CiviCrm {
|
|
|
414
470
|
//
|
|
415
471
|
resources_1.resourceProp,
|
|
416
472
|
resources_1.operationProp,
|
|
473
|
+
resources_1.customApiOperationProp,
|
|
417
474
|
//
|
|
418
475
|
// CONTACT TYPE
|
|
419
476
|
//
|
|
@@ -548,7 +605,15 @@ class CiviCrm {
|
|
|
548
605
|
default: 'get',
|
|
549
606
|
required: true,
|
|
550
607
|
description: 'CiviCRM API4 action, for example get, getFields, create, update, delete, getOne, etc.',
|
|
551
|
-
|
|
608
|
+
// Also shown for the legacy operation values a customApi node saved
|
|
609
|
+
// before `customApiOperationProp` existed may still carry (see that
|
|
610
|
+
// prop's comments) - execute() treats all of them as the raw path.
|
|
611
|
+
displayOptions: {
|
|
612
|
+
show: {
|
|
613
|
+
resource: ['customApi'],
|
|
614
|
+
operation: ['raw', 'get', 'getMany', 'create', 'update', 'delete'],
|
|
615
|
+
},
|
|
616
|
+
},
|
|
552
617
|
},
|
|
553
618
|
{
|
|
554
619
|
displayName: 'Params (JSON)',
|
|
@@ -559,7 +624,88 @@ class CiviCrm {
|
|
|
559
624
|
},
|
|
560
625
|
default: '{\n "limit": 25\n}',
|
|
561
626
|
description: 'Raw API4 params JSON passed as-is to CiviCRM. It must be a valid JSON object (no trailing commas).',
|
|
562
|
-
displayOptions: {
|
|
627
|
+
displayOptions: {
|
|
628
|
+
show: {
|
|
629
|
+
resource: ['customApi'],
|
|
630
|
+
operation: ['raw', 'get', 'getMany', 'create', 'update', 'delete'],
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
//
|
|
635
|
+
// CUSTOM API — LIST FIELDS (ANY ENTITY)
|
|
636
|
+
//
|
|
637
|
+
{
|
|
638
|
+
displayName: 'Action Context',
|
|
639
|
+
name: 'getFieldsAction',
|
|
640
|
+
type: 'string',
|
|
641
|
+
default: 'get',
|
|
642
|
+
description: 'CiviCRM action context passed to getFields (e.g. get, create, update). Affects which fields are reported as required/readonly for that context.',
|
|
643
|
+
displayOptions: { show: { resource: ['customApi'], operation: ['getFields'] } },
|
|
644
|
+
},
|
|
645
|
+
//
|
|
646
|
+
// CUSTOM API — DYNAMIC SEARCH (ANY ENTITY)
|
|
647
|
+
//
|
|
648
|
+
{
|
|
649
|
+
displayName: 'Select (JSON)',
|
|
650
|
+
name: 'searchSelectJson',
|
|
651
|
+
type: 'string',
|
|
652
|
+
default: '["id"]',
|
|
653
|
+
placeholder: '["id","display_name","custom_12"]',
|
|
654
|
+
description: 'JSON array of field names to return, verified beforehand via getFields.',
|
|
655
|
+
displayOptions: { show: { resource: ['customApi'], operation: ['search'] } },
|
|
656
|
+
},
|
|
657
|
+
{
|
|
658
|
+
displayName: 'Where (JSON)',
|
|
659
|
+
name: 'searchWhereJson',
|
|
660
|
+
type: 'string',
|
|
661
|
+
default: '',
|
|
662
|
+
placeholder: '[["city","=","Bilbao"]]',
|
|
663
|
+
description: "JSON array of [field, operator, value] triples, same pattern as Get Many's Where (JSON).",
|
|
664
|
+
displayOptions: { show: { resource: ['customApi'], operation: ['search'] } },
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
displayName: 'Return All',
|
|
668
|
+
name: 'searchReturnAll',
|
|
669
|
+
type: 'boolean',
|
|
670
|
+
default: false,
|
|
671
|
+
description: 'Whether to return all results or only up to a given limit.',
|
|
672
|
+
displayOptions: { show: { resource: ['customApi'], operation: ['search'] } },
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
displayName: 'Limit',
|
|
676
|
+
name: 'searchLimit',
|
|
677
|
+
type: 'number',
|
|
678
|
+
typeOptions: { minValue: 1, maxValue: 1000 },
|
|
679
|
+
default: 100,
|
|
680
|
+
description: 'Max number of results to return. Defaults to 100.',
|
|
681
|
+
displayOptions: {
|
|
682
|
+
show: { resource: ['customApi'], operation: ['search'], searchReturnAll: [false] },
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
//
|
|
686
|
+
// RUNTIME BEARER TOKEN (per-execution JWT, e.g. Authx JWT of the real
|
|
687
|
+
// logged-in CiviCRM user - see GenericFunctions.civicrmApiRequest)
|
|
688
|
+
//
|
|
689
|
+
{
|
|
690
|
+
displayName: 'Runtime Bearer Token (Optional)',
|
|
691
|
+
name: 'runtimeBearerToken',
|
|
692
|
+
type: 'string',
|
|
693
|
+
typeOptions: { password: true },
|
|
694
|
+
default: '',
|
|
695
|
+
description: 'A JWT already issued for a specific real user (e.g. a CiviCRM Authx JWT minted by ' +
|
|
696
|
+
'Drupal for the logged-in contact), usually set by expression such as ' +
|
|
697
|
+
'={{ $json.user_jwt }}. If set, it is used exactly as given as the ' +
|
|
698
|
+
'"Authorization: Bearer" header for this call, bypassing this node\'s ' +
|
|
699
|
+
"credential-based JWT auto-resolve and API key entirely. If the CiviCRM response " +
|
|
700
|
+
'is empty or an error with this token, it is returned/thrown as-is - the node does ' +
|
|
701
|
+
'NOT retry with the credential\'s API key, because an empty result is what a correct ' +
|
|
702
|
+
"permission check looks like when this user lacks access, not a failure to compensate " +
|
|
703
|
+
'for. Leave empty to use the credential (JWT auto-resolve or API key) as before.',
|
|
704
|
+
displayOptions: {
|
|
705
|
+
show: {
|
|
706
|
+
operation: ['get', 'getMany', 'getFields', 'search', 'raw'],
|
|
707
|
+
},
|
|
708
|
+
},
|
|
563
709
|
},
|
|
564
710
|
//
|
|
565
711
|
// DYNAMIC FIELDS
|
|
@@ -574,13 +720,13 @@ class CiviCrm {
|
|
|
574
720
|
this.methods = {
|
|
575
721
|
loadOptions: {
|
|
576
722
|
async loadOptionValues() {
|
|
577
|
-
const
|
|
578
|
-
const
|
|
723
|
+
const credentials = (await this.getCredentials('civiCrmApi'));
|
|
724
|
+
const baseUrl = credentials.baseUrl.replace(/\/$/, '');
|
|
725
|
+
const headers = (0, GenericFunctions_1.buildCiviAuthHeaders)(credentials, baseUrl);
|
|
726
|
+
const res = await this.helpers.httpRequest.call(this, {
|
|
579
727
|
method: 'POST',
|
|
580
|
-
url: `${baseUrl
|
|
581
|
-
headers
|
|
582
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
583
|
-
},
|
|
728
|
+
url: `${baseUrl}/civicrm/ajax/api4/OptionValue/get`,
|
|
729
|
+
headers,
|
|
584
730
|
body: { params: JSON.stringify({ limit: 50, select: ['id', 'label'] }) },
|
|
585
731
|
json: true,
|
|
586
732
|
});
|
|
@@ -597,7 +743,7 @@ class CiviCrm {
|
|
|
597
743
|
EXECUTE
|
|
598
744
|
============================================================================ */
|
|
599
745
|
async execute() {
|
|
600
|
-
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
|
|
746
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t;
|
|
601
747
|
const items = this.getInputData();
|
|
602
748
|
const out = [];
|
|
603
749
|
const resource = this.getNodeParameter('resource', 0);
|
|
@@ -605,9 +751,96 @@ class CiviCrm {
|
|
|
605
751
|
const entity = resource !== 'customApi' ? ENTITY_MAP[resource] : '';
|
|
606
752
|
for (let i = 0; i < items.length; i++) {
|
|
607
753
|
try {
|
|
608
|
-
//
|
|
754
|
+
// Per-execution JWT for a specific real user (issue #25 - permissions
|
|
755
|
+
// by real user via Authx), read once per item so it can come from an
|
|
756
|
+
// expression like ={{ $json.user_jwt }}. Only threaded into the
|
|
757
|
+
// read-only operations this parameter is exposed for (get/getMany/
|
|
758
|
+
// getFields/search/raw Custom API Call) - see civicrmApiRequest for
|
|
759
|
+
// why an empty result with this token must never fall back to the
|
|
760
|
+
// credential's API key.
|
|
761
|
+
const runtimeBearerToken = this.getNodeParameter('runtimeBearerToken', i, '').trim() || undefined;
|
|
762
|
+
// Custom API resource: raw passthrough, plus first-class List Fields /
|
|
763
|
+
// Dynamic Search operations for any API4 entity (not just the 5 fixed
|
|
764
|
+
// resources above).
|
|
609
765
|
if (resource === 'customApi') {
|
|
610
766
|
const customEntity = this.getNodeParameter('customEntity', i);
|
|
767
|
+
// Any value other than 'getFields'/'search' (including the new
|
|
768
|
+
// 'raw' default and every legacy value the shared operationProp
|
|
769
|
+
// used to allow here) resolves to the original raw passthrough.
|
|
770
|
+
const customOperation = this.getNodeParameter('operation', i, 'raw');
|
|
771
|
+
/* --------------------------------------------------------
|
|
772
|
+
LIST FIELDS: {Entity}/getFields
|
|
773
|
+
-------------------------------------------------------- */
|
|
774
|
+
if (customOperation === 'getFields') {
|
|
775
|
+
const actionContext = this.getNodeParameter('getFieldsAction', i, 'get');
|
|
776
|
+
const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${customEntity}/getFields`, { action: actionContext, loadOptions: true }, runtimeBearerToken);
|
|
777
|
+
const vals = ((_a = res === null || res === void 0 ? void 0 : res.values) !== null && _a !== void 0 ? _a : []);
|
|
778
|
+
if (Array.isArray(vals) && vals.length) {
|
|
779
|
+
for (const v of vals) {
|
|
780
|
+
out.push({ json: v, pairedItem: { item: i } });
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
else {
|
|
784
|
+
out.push({ json: (_b = res) !== null && _b !== void 0 ? _b : {}, pairedItem: { item: i } });
|
|
785
|
+
}
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
/* --------------------------------------------------------
|
|
789
|
+
DYNAMIC SEARCH: {Entity}/get with configurable select/where
|
|
790
|
+
-------------------------------------------------------- */
|
|
791
|
+
if (customOperation === 'search') {
|
|
792
|
+
const selectJson = this.getNodeParameter('searchSelectJson', i, '["id"]');
|
|
793
|
+
const whereJson = this.getNodeParameter('searchWhereJson', i, '');
|
|
794
|
+
const returnAll = this.getNodeParameter('searchReturnAll', i, false);
|
|
795
|
+
const limit = this.getNodeParameter('searchLimit', i, 100);
|
|
796
|
+
let select = ['id'];
|
|
797
|
+
if (selectJson) {
|
|
798
|
+
try {
|
|
799
|
+
select = JSON.parse(selectJson);
|
|
800
|
+
}
|
|
801
|
+
catch (error) {
|
|
802
|
+
throw new Error('Invalid JSON in "Select (JSON)"');
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
let where = [];
|
|
806
|
+
if (whereJson) {
|
|
807
|
+
try {
|
|
808
|
+
where = JSON.parse(whereJson);
|
|
809
|
+
}
|
|
810
|
+
catch (error) {
|
|
811
|
+
throw new Error('Invalid JSON in "Where (JSON)"');
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
if (returnAll) {
|
|
815
|
+
let offset = 0;
|
|
816
|
+
const page = 500;
|
|
817
|
+
let hasMore = true;
|
|
818
|
+
while (hasMore) {
|
|
819
|
+
const r = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${customEntity}/get`, { select, where, limit: page, offset }, runtimeBearerToken);
|
|
820
|
+
const vals = ((_c = r === null || r === void 0 ? void 0 : r.values) !== null && _c !== void 0 ? _c : []);
|
|
821
|
+
for (const v of vals) {
|
|
822
|
+
out.push({ json: v, pairedItem: { item: i } });
|
|
823
|
+
}
|
|
824
|
+
if (vals.length < page) {
|
|
825
|
+
hasMore = false;
|
|
826
|
+
}
|
|
827
|
+
else {
|
|
828
|
+
offset += page;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
const r = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${customEntity}/get`, { select, where, limit }, runtimeBearerToken);
|
|
834
|
+
const vals = ((_d = r === null || r === void 0 ? void 0 : r.values) !== null && _d !== void 0 ? _d : []);
|
|
835
|
+
for (const v of vals) {
|
|
836
|
+
out.push({ json: v, pairedItem: { item: i } });
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
/* --------------------------------------------------------
|
|
842
|
+
RAW API CALL (original behavior, unchanged)
|
|
843
|
+
-------------------------------------------------------- */
|
|
611
844
|
const action = this.getNodeParameter('customAction', i);
|
|
612
845
|
const paramsJson = this.getNodeParameter('customParamsJson', i, '');
|
|
613
846
|
let params = {};
|
|
@@ -619,7 +852,7 @@ class CiviCrm {
|
|
|
619
852
|
throw new Error('Invalid JSON in "Params (JSON)"');
|
|
620
853
|
}
|
|
621
854
|
}
|
|
622
|
-
const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${customEntity}/${action}`, params);
|
|
855
|
+
const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${customEntity}/${action}`, params, runtimeBearerToken);
|
|
623
856
|
// Return the raw API4 response so advanced users can work with values and metadata
|
|
624
857
|
out.push({
|
|
625
858
|
json: res,
|
|
@@ -668,9 +901,9 @@ class CiviCrm {
|
|
|
668
901
|
limit: 1,
|
|
669
902
|
select: ['id', 'name', 'title', 'subject', 'display_name'],
|
|
670
903
|
};
|
|
671
|
-
const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, params);
|
|
904
|
+
const res = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, params, runtimeBearerToken);
|
|
672
905
|
out.push({
|
|
673
|
-
json: (
|
|
906
|
+
json: (_f = (_e = res === null || res === void 0 ? void 0 : res.values) === null || _e === void 0 ? void 0 : _e[0]) !== null && _f !== void 0 ? _f : {},
|
|
674
907
|
pairedItem: { item: i },
|
|
675
908
|
});
|
|
676
909
|
continue;
|
|
@@ -726,8 +959,8 @@ class CiviCrm {
|
|
|
726
959
|
const page = 500;
|
|
727
960
|
let hasMore = true;
|
|
728
961
|
while (hasMore) {
|
|
729
|
-
const r = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, { ...params, limit: page, offset });
|
|
730
|
-
const vals = (
|
|
962
|
+
const r = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, { ...params, limit: page, offset }, runtimeBearerToken);
|
|
963
|
+
const vals = (_g = r === null || r === void 0 ? void 0 : r.values) !== null && _g !== void 0 ? _g : [];
|
|
731
964
|
for (const v of vals) {
|
|
732
965
|
out.push({
|
|
733
966
|
json: v,
|
|
@@ -743,8 +976,8 @@ class CiviCrm {
|
|
|
743
976
|
}
|
|
744
977
|
}
|
|
745
978
|
else {
|
|
746
|
-
const r = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, { ...params, limit });
|
|
747
|
-
const vals = (
|
|
979
|
+
const r = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', `/civicrm/ajax/api4/${entity}/get`, { ...params, limit }, runtimeBearerToken);
|
|
980
|
+
const vals = (_h = r === null || r === void 0 ? void 0 : r.values) !== null && _h !== void 0 ? _h : [];
|
|
748
981
|
for (const v of vals) {
|
|
749
982
|
out.push({
|
|
750
983
|
json: v,
|
|
@@ -898,7 +1131,7 @@ class CiviCrm {
|
|
|
898
1131
|
let contactId = id;
|
|
899
1132
|
if (isCreate) {
|
|
900
1133
|
const r = await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Contact/create', { values });
|
|
901
|
-
contactId = (
|
|
1134
|
+
contactId = (_k = (_j = r === null || r === void 0 ? void 0 : r.values) === null || _j === void 0 ? void 0 : _j[0]) === null || _k === void 0 ? void 0 : _k.id;
|
|
902
1135
|
if (!contactId)
|
|
903
1136
|
throw new Error('Failed to create contact.');
|
|
904
1137
|
}
|
|
@@ -954,7 +1187,7 @@ class CiviCrm {
|
|
|
954
1187
|
limit: 1,
|
|
955
1188
|
select: ['id'],
|
|
956
1189
|
});
|
|
957
|
-
const existingEmailId = (
|
|
1190
|
+
const existingEmailId = (_m = (_l = existingEmail === null || existingEmail === void 0 ? void 0 : existingEmail.values) === null || _l === void 0 ? void 0 : _l[0]) === null || _m === void 0 ? void 0 : _m.id;
|
|
958
1191
|
if (existingEmailId) {
|
|
959
1192
|
await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Email/update', {
|
|
960
1193
|
values: {
|
|
@@ -997,7 +1230,7 @@ class CiviCrm {
|
|
|
997
1230
|
limit: 1,
|
|
998
1231
|
select: ['id'],
|
|
999
1232
|
});
|
|
1000
|
-
const existingPhoneId = (
|
|
1233
|
+
const existingPhoneId = (_p = (_o = existingPhone === null || existingPhone === void 0 ? void 0 : existingPhone.values) === null || _o === void 0 ? void 0 : _o[0]) === null || _p === void 0 ? void 0 : _p.id;
|
|
1001
1234
|
if (existingPhoneId) {
|
|
1002
1235
|
await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Phone/update', {
|
|
1003
1236
|
values: {
|
|
@@ -1040,7 +1273,7 @@ class CiviCrm {
|
|
|
1040
1273
|
limit: 1,
|
|
1041
1274
|
select: ['id'],
|
|
1042
1275
|
});
|
|
1043
|
-
const existingAddressId = (
|
|
1276
|
+
const existingAddressId = (_r = (_q = existingAddress === null || existingAddress === void 0 ? void 0 : existingAddress.values) === null || _q === void 0 ? void 0 : _q[0]) === null || _r === void 0 ? void 0 : _r.id;
|
|
1044
1277
|
if (existingAddressId) {
|
|
1045
1278
|
await GenericFunctions_1.civicrmApiRequest.call(this, 'POST', '/civicrm/ajax/api4/Address/update', {
|
|
1046
1279
|
values: {
|
|
@@ -1090,7 +1323,7 @@ class CiviCrm {
|
|
|
1090
1323
|
: {}),
|
|
1091
1324
|
});
|
|
1092
1325
|
out.push({
|
|
1093
|
-
json: (
|
|
1326
|
+
json: (_t = (_s = res === null || res === void 0 ? void 0 : res.values) === null || _s === void 0 ? void 0 : _s[0]) !== null && _t !== void 0 ? _t : {},
|
|
1094
1327
|
pairedItem: { item: i },
|
|
1095
1328
|
});
|
|
1096
1329
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.operationProp = exports.resourceProp = void 0;
|
|
3
|
+
exports.customApiOperationProp = exports.operationProp = exports.resourceProp = void 0;
|
|
4
4
|
//
|
|
5
5
|
// =======================
|
|
6
6
|
// RESOURCE SELECTOR
|
|
@@ -27,6 +27,12 @@ exports.resourceProp = {
|
|
|
27
27
|
// OPERATION SELECTOR
|
|
28
28
|
// =======================
|
|
29
29
|
//
|
|
30
|
+
// Scoped to the 5 fixed resources only (Custom API Call has its own operation
|
|
31
|
+
// dropdown below, `customApiOperationProp`). n8n resolves which of the two
|
|
32
|
+
// same-named `operation` properties to render based on the current `resource`
|
|
33
|
+
// value, since their `displayOptions.show.resource` lists are mutually
|
|
34
|
+
// exclusive - this keeps the fixed-resource CRUD operations completely
|
|
35
|
+
// unchanged while giving Custom API Call room for its own operation set.
|
|
30
36
|
exports.operationProp = {
|
|
31
37
|
displayName: 'Operation',
|
|
32
38
|
name: 'operation',
|
|
@@ -34,6 +40,7 @@ exports.operationProp = {
|
|
|
34
40
|
default: 'getMany',
|
|
35
41
|
noDataExpression: true,
|
|
36
42
|
description: 'The action to perform on the selected resource.',
|
|
43
|
+
displayOptions: { show: { resource: ['contact', 'membership', 'group', 'relationship', 'activity'] } },
|
|
37
44
|
options: [
|
|
38
45
|
{ name: 'Create', value: 'create', description: 'Create a new record' },
|
|
39
46
|
{ name: 'Delete', value: 'delete', description: 'Delete a record by ID' },
|
|
@@ -42,3 +49,37 @@ exports.operationProp = {
|
|
|
42
49
|
{ name: 'Update', value: 'update', description: 'Update a record by ID' },
|
|
43
50
|
],
|
|
44
51
|
};
|
|
52
|
+
//
|
|
53
|
+
// =======================
|
|
54
|
+
// CUSTOM API OPERATION SELECTOR
|
|
55
|
+
// =======================
|
|
56
|
+
//
|
|
57
|
+
// Only shown when Resource = "Custom API Call". `raw` preserves the original
|
|
58
|
+
// hand-typed entity/action/params passthrough. `getFields` and `search` are
|
|
59
|
+
// new, structured, discoverable operations for any CiviCRM APIv4 entity (not
|
|
60
|
+
// just the 5 fixed resources) - see CiviCrm.node.ts for how they're executed.
|
|
61
|
+
//
|
|
62
|
+
// The option list intentionally also matches every legacy value the old,
|
|
63
|
+
// shared `operationProp` could have stored for a `customApi` resource node
|
|
64
|
+
// saved before this property existed (get/getMany/create/update/delete), so
|
|
65
|
+
// pre-existing saved workflows keep resolving to the `raw` execution path
|
|
66
|
+
// with their `customAction`/`customParamsJson` fields still visible/editable.
|
|
67
|
+
exports.customApiOperationProp = {
|
|
68
|
+
displayName: 'Operation',
|
|
69
|
+
name: 'operation',
|
|
70
|
+
type: 'options',
|
|
71
|
+
default: 'raw',
|
|
72
|
+
noDataExpression: true,
|
|
73
|
+
description: 'The action to perform via the Custom API Call resource.',
|
|
74
|
+
displayOptions: { show: { resource: ['customApi'] } },
|
|
75
|
+
options: [
|
|
76
|
+
{ name: 'Raw API Call', value: 'raw', description: 'Hand-typed entity/action/params passthrough to any CiviCRM APIv4 endpoint (advanced/escape hatch)' },
|
|
77
|
+
{ name: 'List Fields', value: 'getFields', description: 'Call {Entity}/getFields and return field metadata for any CiviCRM entity' },
|
|
78
|
+
{ name: 'Dynamic Search', value: 'search', description: 'Run {Entity}/get with a configurable Select and Where for any CiviCRM entity' },
|
|
79
|
+
{ name: 'Get (Legacy)', value: 'get', description: 'Legacy value, resolves to Raw API Call' },
|
|
80
|
+
{ name: 'Get Many (Legacy)', value: 'getMany', description: 'Legacy value, resolves to Raw API Call' },
|
|
81
|
+
{ name: 'Create (Legacy)', value: 'create', description: 'Legacy value, resolves to Raw API Call' },
|
|
82
|
+
{ name: 'Update (Legacy)', value: 'update', description: 'Legacy value, resolves to Raw API Call' },
|
|
83
|
+
{ name: 'Delete (Legacy)', value: 'delete', description: 'Legacy value, resolves to Raw API Call' },
|
|
84
|
+
],
|
|
85
|
+
};
|
|
@@ -1,36 +1,219 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildCiviAuthHeaders = buildCiviAuthHeaders;
|
|
3
4
|
exports.civicrmApiRequest = civicrmApiRequest;
|
|
4
5
|
exports.api4 = api4;
|
|
5
6
|
const n8n_workflow_1 = require("n8n-workflow");
|
|
7
|
+
const JwtAuth_1 = require("./JwtAuth");
|
|
8
|
+
function getJwtHeaderMode(credentials) {
|
|
9
|
+
var _a;
|
|
10
|
+
const rawMode = String((_a = credentials.jwtHeaderMode) !== null && _a !== void 0 ? _a : 'xheader');
|
|
11
|
+
if (rawMode === 'authorization' || rawMode === 'xheader' || rawMode === 'both') {
|
|
12
|
+
return rawMode;
|
|
13
|
+
}
|
|
14
|
+
return 'xheader';
|
|
15
|
+
}
|
|
16
|
+
function applyJwtHeaders(headers, jwtToken, headerMode) {
|
|
17
|
+
const bearer = `Bearer ${jwtToken}`;
|
|
18
|
+
if (headerMode === 'both' || headerMode === 'authorization') {
|
|
19
|
+
headers.Authorization = bearer;
|
|
20
|
+
}
|
|
21
|
+
if (headerMode === 'both' || headerMode === 'xheader') {
|
|
22
|
+
headers['X-Civi-Auth'] = bearer;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function getHttpErrorDetails(error) {
|
|
26
|
+
var _a, _b, _c;
|
|
27
|
+
const errorObj = error;
|
|
28
|
+
const status = (_a = errorObj === null || errorObj === void 0 ? void 0 : errorObj.response) === null || _a === void 0 ? void 0 : _a.status;
|
|
29
|
+
const data = (_b = errorObj === null || errorObj === void 0 ? void 0 : errorObj.response) === null || _b === void 0 ? void 0 : _b.data;
|
|
30
|
+
const message = (_c = errorObj === null || errorObj === void 0 ? void 0 : errorObj.message) !== null && _c !== void 0 ? _c : 'Request rejected by CiviCRM';
|
|
31
|
+
if (typeof data === 'string' && data.trim()) {
|
|
32
|
+
return status ? `${message} | HTTP ${status} body: ${data}` : `${message} | body: ${data}`;
|
|
33
|
+
}
|
|
34
|
+
if (data && typeof data === 'object') {
|
|
35
|
+
const serialized = JSON.stringify(data);
|
|
36
|
+
return status ? `${message} | HTTP ${status} body: ${serialized}` : `${message} | body: ${serialized}`;
|
|
37
|
+
}
|
|
38
|
+
return status ? `${message} | HTTP ${status}` : message;
|
|
39
|
+
}
|
|
40
|
+
function buildCiviAuthHeaders(credentials, baseUrl, jwtToken) {
|
|
41
|
+
const headers = {
|
|
42
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
43
|
+
};
|
|
44
|
+
// Use JWT if available and enabled; otherwise always fall back to API key
|
|
45
|
+
if (jwtToken && (0, JwtAuth_1.isJwtAuthEnabled)(credentials)) {
|
|
46
|
+
const headerMode = getJwtHeaderMode(credentials);
|
|
47
|
+
applyJwtHeaders(headers, jwtToken, headerMode);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// Always provide API key as fallback
|
|
51
|
+
headers['X-Civi-Auth'] = `Bearer ${credentials.apiToken}`;
|
|
52
|
+
}
|
|
53
|
+
return headers;
|
|
54
|
+
}
|
|
6
55
|
/**
|
|
7
|
-
* Executes a CiviCRM API v4 call (
|
|
8
|
-
*
|
|
56
|
+
* Executes a CiviCRM API v4 call with authentication (JWT or API Key).
|
|
57
|
+
* Falls back to API Key if JWT is unavailable, fails, or returns empty results.
|
|
58
|
+
*
|
|
59
|
+
* `runtimeBearerToken` (optional) is a per-execution JWT that the caller
|
|
60
|
+
* already holds for a specific real user (e.g. a CiviCRM Authx JWT minted by
|
|
61
|
+
* Drupal for the logged-in contact, passed into the node via the "Runtime
|
|
62
|
+
* Bearer Token" parameter/expression). When provided, it takes absolute
|
|
63
|
+
* priority: it is sent as-is and none of the credential-based logic below
|
|
64
|
+
* (JWT auto-resolve via `getServerIssuedJwt`/`resolveContactId`, or the
|
|
65
|
+
* empty-response/error fallback to the credential's API key) runs at all.
|
|
66
|
+
* See the early-return block right below for why.
|
|
9
67
|
*/
|
|
10
|
-
async function civicrmApiRequest(method, path, body) {
|
|
11
|
-
var _a;
|
|
12
|
-
const credentials = await this.getCredentials('civiCrmApi');
|
|
68
|
+
async function civicrmApiRequest(method, path, body, runtimeBearerToken) {
|
|
69
|
+
var _a, _b, _c, _d;
|
|
70
|
+
const credentials = (await this.getCredentials('civiCrmApi'));
|
|
13
71
|
const baseUrl = credentials.baseUrl.replace(/\/$/, '');
|
|
14
|
-
const
|
|
72
|
+
const apiToken = credentials.apiToken;
|
|
73
|
+
// Runtime bearer token path: used exactly as given, with no fallback.
|
|
74
|
+
//
|
|
75
|
+
// This exists for per-user permission enforcement (issue #25): the token
|
|
76
|
+
// here already belongs to a specific real CiviCRM contact (not the
|
|
77
|
+
// credential's own contact/API key owner), so an empty or denied response
|
|
78
|
+
// is a *correct* outcome - it means that real user lacks permission for
|
|
79
|
+
// the requested data - not a failure to silently "fix" by retrying with a
|
|
80
|
+
// more privileged identity. That is exactly the behavior the credential-based
|
|
81
|
+
// path below has (empty JWT response -> retry with the plaintext API key),
|
|
82
|
+
// and it is exactly what must NOT happen here, so this path never falls
|
|
83
|
+
// through into that logic.
|
|
84
|
+
if (runtimeBearerToken) {
|
|
85
|
+
const headers = {
|
|
86
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
87
|
+
Authorization: `Bearer ${runtimeBearerToken}`,
|
|
88
|
+
};
|
|
89
|
+
const options = {
|
|
90
|
+
method,
|
|
91
|
+
url: `${baseUrl}${path}`,
|
|
92
|
+
headers,
|
|
93
|
+
body: {
|
|
94
|
+
params: JSON.stringify((_a = body.params) !== null && _a !== void 0 ? _a : body),
|
|
95
|
+
},
|
|
96
|
+
json: true,
|
|
97
|
+
};
|
|
98
|
+
try {
|
|
99
|
+
// Whatever CiviCRM returns (including an empty `values: []`) is
|
|
100
|
+
// returned to the caller untouched - no retry, no fallback.
|
|
101
|
+
return await this.helpers.httpRequest.call(this, options);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
throw new n8n_workflow_1.NodeApiError(this.getNode(), error);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let jwtToken;
|
|
108
|
+
let useJwt = false;
|
|
109
|
+
// Attempt to obtain JWT if enabled
|
|
110
|
+
if ((0, JwtAuth_1.isJwtAuthEnabled)(credentials)) {
|
|
111
|
+
const ttl = Number((_b = credentials.jwtExpiry) !== null && _b !== void 0 ? _b : 3600);
|
|
112
|
+
try {
|
|
113
|
+
// Auto-resolve contact ID is built-in to getServerIssuedJwt
|
|
114
|
+
jwtToken = await (0, JwtAuth_1.getServerIssuedJwt)(this, baseUrl, apiToken, 0, ttl);
|
|
115
|
+
if (jwtToken) {
|
|
116
|
+
useJwt = true;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
console.warn('[CiviCRM] JWT generation returned no token, falling back to API key');
|
|
120
|
+
this.addExecutionHints({
|
|
121
|
+
message: 'JWT authentication could not be obtained (no token returned by CiviCRM). Falling back to API Key authentication.',
|
|
122
|
+
type: 'warning',
|
|
123
|
+
location: 'outputPane',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
const errorMsg = getHttpErrorDetails(error);
|
|
129
|
+
console.warn(`[CiviCRM] JWT generation failed: ${errorMsg}. Falling back to API key.`);
|
|
130
|
+
this.addExecutionHints({
|
|
131
|
+
message: `JWT authentication failed (${errorMsg}). Falling back to API Key authentication.`,
|
|
132
|
+
type: 'warning',
|
|
133
|
+
location: 'outputPane',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Try with JWT first (if available)
|
|
138
|
+
if (useJwt && jwtToken) {
|
|
139
|
+
const headers = buildCiviAuthHeaders(credentials, baseUrl, jwtToken);
|
|
140
|
+
const options = {
|
|
141
|
+
method,
|
|
142
|
+
url: `${baseUrl}${path}`,
|
|
143
|
+
headers,
|
|
144
|
+
body: {
|
|
145
|
+
params: JSON.stringify((_c = body.params) !== null && _c !== void 0 ? _c : body),
|
|
146
|
+
},
|
|
147
|
+
json: true,
|
|
148
|
+
};
|
|
149
|
+
try {
|
|
150
|
+
const response = await this.helpers.httpRequest.call(this, options);
|
|
151
|
+
// Check if response has data. If JWT returned empty but we expected data, fallback to API key
|
|
152
|
+
const hasData = hasResponseData(response);
|
|
153
|
+
if (hasData) {
|
|
154
|
+
return response;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
console.warn('[CiviCRM] JWT returned empty response. Retrying with API key (JWT may have limited permissions).');
|
|
158
|
+
this.addExecutionHints({
|
|
159
|
+
message: 'The JWT-authenticated request returned no data (JWT may have limited permissions). Retrying with API Key authentication.',
|
|
160
|
+
type: 'warning',
|
|
161
|
+
location: 'outputPane',
|
|
162
|
+
});
|
|
163
|
+
// Fall through to API key attempt below
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
const errorMsg = getHttpErrorDetails(error);
|
|
168
|
+
console.warn('[CiviCRM] JWT request failed. Retrying with API key.');
|
|
169
|
+
this.addExecutionHints({
|
|
170
|
+
message: `The JWT-authenticated request failed (${errorMsg}). Retrying with API Key authentication.`,
|
|
171
|
+
type: 'warning',
|
|
172
|
+
location: 'outputPane',
|
|
173
|
+
});
|
|
174
|
+
// Fall through to API key attempt below
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// Fallback to API Key
|
|
178
|
+
const apiKeyHeaders = buildCiviAuthHeaders(credentials, baseUrl, undefined);
|
|
179
|
+
const apiKeyOptions = {
|
|
15
180
|
method,
|
|
16
181
|
url: `${baseUrl}${path}`,
|
|
17
|
-
headers:
|
|
18
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
19
|
-
},
|
|
20
|
-
// flat body as expected by Civi-Go
|
|
182
|
+
headers: apiKeyHeaders,
|
|
21
183
|
body: {
|
|
22
|
-
params: JSON.stringify((
|
|
184
|
+
params: JSON.stringify((_d = body.params) !== null && _d !== void 0 ? _d : body),
|
|
23
185
|
},
|
|
24
186
|
json: true,
|
|
25
187
|
};
|
|
26
188
|
try {
|
|
27
|
-
const response = await this.helpers.
|
|
189
|
+
const response = await this.helpers.httpRequest.call(this, apiKeyOptions);
|
|
190
|
+
if (useJwt && jwtToken) {
|
|
191
|
+
console.log('[CiviCRM] API key request successful (JWT was insufficient, using API key as fallback)');
|
|
192
|
+
}
|
|
28
193
|
return response;
|
|
29
194
|
}
|
|
30
195
|
catch (error) {
|
|
31
196
|
throw new n8n_workflow_1.NodeApiError(this.getNode(), error);
|
|
32
197
|
}
|
|
33
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Check if API response contains actual data.
|
|
201
|
+
* Returns false if response is empty/no results, true if has data.
|
|
202
|
+
*/
|
|
203
|
+
function hasResponseData(response) {
|
|
204
|
+
if (!response)
|
|
205
|
+
return false;
|
|
206
|
+
// Check for APIv4 response format: { values: [...], count: N }
|
|
207
|
+
if (response.values !== undefined) {
|
|
208
|
+
return Array.isArray(response.values) && response.values.length > 0;
|
|
209
|
+
}
|
|
210
|
+
// Check for other formats
|
|
211
|
+
if (Array.isArray(response)) {
|
|
212
|
+
return response.length > 0;
|
|
213
|
+
}
|
|
214
|
+
// If response exists and isn't an empty array, consider it has data
|
|
215
|
+
return Object.keys(response).length > 0;
|
|
216
|
+
}
|
|
34
217
|
/**
|
|
35
218
|
* Returns the standard body for API4 calls (flat params).
|
|
36
219
|
*/
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isJwtAuthEnabled = isJwtAuthEnabled;
|
|
4
|
+
exports.resolveContactId = resolveContactId;
|
|
5
|
+
exports.getServerIssuedJwt = getServerIssuedJwt;
|
|
6
|
+
const serverIssuedCache = {};
|
|
7
|
+
const contactIdCache = {}; // Cache resolved contact IDs
|
|
8
|
+
function isJwtAuthEnabled(credentials) {
|
|
9
|
+
return (credentials === null || credentials === void 0 ? void 0 : credentials.enableJwtAuth) === true;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Extracts a readable message from an HTTP error, including the response body
|
|
13
|
+
* (e.g. CiviCRM's "Login not permitted. Must satisfy guard (perm, site_key)."),
|
|
14
|
+
* which the bare `error.message` (e.g. "Request failed with status code 401")
|
|
15
|
+
* does not include.
|
|
16
|
+
*/
|
|
17
|
+
function getErrorDetails(error) {
|
|
18
|
+
var _a, _b, _c;
|
|
19
|
+
const errorObj = error;
|
|
20
|
+
const status = (_a = errorObj === null || errorObj === void 0 ? void 0 : errorObj.response) === null || _a === void 0 ? void 0 : _a.status;
|
|
21
|
+
const data = (_b = errorObj === null || errorObj === void 0 ? void 0 : errorObj.response) === null || _b === void 0 ? void 0 : _b.data;
|
|
22
|
+
const message = (_c = errorObj === null || errorObj === void 0 ? void 0 : errorObj.message) !== null && _c !== void 0 ? _c : String(error);
|
|
23
|
+
const body = typeof data === 'string' && data.trim()
|
|
24
|
+
? data
|
|
25
|
+
: data && typeof data === 'object'
|
|
26
|
+
? JSON.stringify(data)
|
|
27
|
+
: undefined;
|
|
28
|
+
if (body) {
|
|
29
|
+
return status ? `${message} | HTTP ${status} body: ${body}` : `${message} | body: ${body}`;
|
|
30
|
+
}
|
|
31
|
+
return status ? `${message} | HTTP ${status}` : message;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the current authenticated user's contact ID from Contact/get.
|
|
35
|
+
* Finds the contact that owns the given api_key.
|
|
36
|
+
* Cached to avoid repeated lookups.
|
|
37
|
+
* Returns undefined if resolution fails (allowing caller to handle gracefully).
|
|
38
|
+
*/
|
|
39
|
+
async function resolveContactId(context, baseUrl, apiToken) {
|
|
40
|
+
const cacheKey = `${baseUrl}:${apiToken}`;
|
|
41
|
+
if (contactIdCache[cacheKey]) {
|
|
42
|
+
return contactIdCache[cacheKey];
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const response = await context.helpers.httpRequest.call(context, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
url: `${baseUrl}/civicrm/ajax/api4/Contact/get`,
|
|
48
|
+
headers: {
|
|
49
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
50
|
+
'X-Civi-Auth': `Bearer ${apiToken}`,
|
|
51
|
+
},
|
|
52
|
+
body: {
|
|
53
|
+
params: JSON.stringify({
|
|
54
|
+
select: ['id'],
|
|
55
|
+
where: [['api_key', '=', apiToken]],
|
|
56
|
+
limit: 1,
|
|
57
|
+
}),
|
|
58
|
+
},
|
|
59
|
+
json: true,
|
|
60
|
+
});
|
|
61
|
+
const contacts = (response === null || response === void 0 ? void 0 : response.values) || [];
|
|
62
|
+
if (contacts.length === 0) {
|
|
63
|
+
console.warn('[CiviCRM] Contact/get found no contact with this api_key - JWT auto-resolve will be skipped');
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const contactId = contacts[0].id;
|
|
67
|
+
contactIdCache[cacheKey] = contactId;
|
|
68
|
+
return contactId;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
const errorMsg = getErrorDetails(error);
|
|
72
|
+
console.warn(`[CiviCRM] Failed to auto-resolve contact ID: ${errorMsg}. Will attempt API key fallback.`);
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Fetch JWT from CiviCRM AuthxCredential/create using API key.
|
|
78
|
+
* If contactId is 0 or undefined, automatically resolves it from Contact/get.
|
|
79
|
+
* CiviCRM signs and issues the JWT, guaranteeing AuthX compatibility.
|
|
80
|
+
* Returns undefined if JWT generation fails or permission denied, allowing fallback to API key.
|
|
81
|
+
*/
|
|
82
|
+
async function getServerIssuedJwt(context, baseUrl, apiToken, contactId = 0, ttl = 3600) {
|
|
83
|
+
var _a, _b;
|
|
84
|
+
// Auto-resolve contact ID if not provided
|
|
85
|
+
let resolvedContactId = contactId;
|
|
86
|
+
if (resolvedContactId === 0 || resolvedContactId === undefined) {
|
|
87
|
+
const resolved = await resolveContactId(context, baseUrl, apiToken);
|
|
88
|
+
if (!resolved) {
|
|
89
|
+
// Auto-resolve failed, can't generate JWT
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
resolvedContactId = resolved;
|
|
93
|
+
}
|
|
94
|
+
const cacheKey = `${baseUrl}:${resolvedContactId}:${ttl}`;
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
const cached = serverIssuedCache[cacheKey];
|
|
97
|
+
// Return cached token if still valid (with 30s buffer)
|
|
98
|
+
if (cached && cached.expiresAt > now + 30000) {
|
|
99
|
+
return cached.token;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const response = await context.helpers.httpRequest.call(context, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
url: `${baseUrl}/civicrm/ajax/api4/AuthxCredential/create`,
|
|
105
|
+
headers: {
|
|
106
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
107
|
+
'X-Civi-Auth': `Bearer ${apiToken}`,
|
|
108
|
+
},
|
|
109
|
+
body: {
|
|
110
|
+
params: JSON.stringify({
|
|
111
|
+
contactId: resolvedContactId,
|
|
112
|
+
ttl: ttl,
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
json: true,
|
|
116
|
+
});
|
|
117
|
+
const cred = ((_b = (_a = response === null || response === void 0 ? void 0 : response.values) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.cred) || '';
|
|
118
|
+
if (!cred.startsWith('Bearer ')) {
|
|
119
|
+
console.warn('[CiviCRM] Invalid JWT response: missing Bearer token');
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
const token = cred.substring(7); // Remove "Bearer " prefix
|
|
123
|
+
serverIssuedCache[cacheKey] = { token, expiresAt: now + ttl * 1000 };
|
|
124
|
+
return token;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
const errorMsg = getErrorDetails(error);
|
|
128
|
+
const isPermissionDenied = errorMsg.includes('Authorization failed') ||
|
|
129
|
+
errorMsg.includes('403') ||
|
|
130
|
+
errorMsg.includes('Permission denied') ||
|
|
131
|
+
errorMsg.includes('Login not permitted') ||
|
|
132
|
+
errorMsg.includes('Must satisfy guard');
|
|
133
|
+
if (isPermissionDenied) {
|
|
134
|
+
// User lacks permissions for JWT. Fall back to API key auth.
|
|
135
|
+
console.warn(`[CiviCRM JWT] User lacks permissions for AuthxCredential/create (${errorMsg}). Will use API key authentication instead.`);
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
console.warn(`[CiviCRM JWT] Failed to obtain JWT: ${errorMsg}. Falling back to API key.`);
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ixiam/n8n-nodes-civicrm",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "Full-featured CiviCRM API v4 integration for n8n",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"copy:assets": "mkdir -p dist/src/nodes/CiviCrm dist/credentials && cp src/nodes/CiviCrm/civicrm.svg dist/src/nodes/CiviCrm/ && cp src/nodes/CiviCrm/civicrm.svg dist/credentials/",
|
|
22
22
|
"lint": "n8n-node lint",
|
|
23
23
|
"release": "n8n-node release",
|
|
24
|
-
"test": "npm run build"
|
|
24
|
+
"test": "npm run build",
|
|
25
|
+
"test:unit": "jest"
|
|
25
26
|
},
|
|
26
27
|
"repository": {
|
|
27
28
|
"type": "git",
|
|
@@ -50,12 +51,19 @@
|
|
|
50
51
|
},
|
|
51
52
|
"devDependencies": {
|
|
52
53
|
"@n8n/node-cli": "^0.23.1",
|
|
54
|
+
"@types/jest": "^30.0.0",
|
|
55
|
+
"@types/jsonwebtoken": "^9.0.10",
|
|
53
56
|
"@types/node": "^20.11.30",
|
|
54
57
|
"eslint": "9.32.0",
|
|
58
|
+
"jest": "^30.5.1",
|
|
55
59
|
"prettier": "3.6.2",
|
|
56
60
|
"release-it": "^19.0.6",
|
|
61
|
+
"ts-jest": "^29.4.12",
|
|
57
62
|
"typescript": "5.9.2"
|
|
58
63
|
},
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"jsonwebtoken": "^9.0.3"
|
|
66
|
+
},
|
|
59
67
|
"peerDependencies": {
|
|
60
68
|
"n8n-workflow": "*"
|
|
61
69
|
},
|