lokicms-plugin-webhooks 1.0.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/LICENSE +21 -0
- package/README.md +214 -0
- package/package.json +42 -0
- package/src/index.ts +838 -0
- package/src/types.ts +183 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 MauricioPerera
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Webhooks Plugin for LokiCMS
|
|
2
|
+
|
|
3
|
+
Send HTTP webhooks when events occur in LokiCMS. Compatible with n8n, Zapier, Make, and any webhook receiver.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Event-driven**: Trigger webhooks on content and user events
|
|
8
|
+
- **Multiple endpoints**: Configure different URLs for different events
|
|
9
|
+
- **Content type filtering**: Only trigger for specific content types
|
|
10
|
+
- **HMAC signatures**: Secure webhooks with SHA-256 signatures
|
|
11
|
+
- **Custom headers**: Add authentication or custom headers
|
|
12
|
+
- **Automatic retries**: Configurable retry on failure
|
|
13
|
+
- **Delivery logs**: Track all webhook deliveries
|
|
14
|
+
- **MCP tools**: AI-accessible webhook management
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install lokicms-plugin-webhooks
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Configuration
|
|
23
|
+
|
|
24
|
+
Add to `plugins.json`:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"name": "webhooks",
|
|
29
|
+
"enabled": true,
|
|
30
|
+
"source": "npm",
|
|
31
|
+
"package": "lokicms-plugin-webhooks",
|
|
32
|
+
"settings": {
|
|
33
|
+
"maxRetries": 3,
|
|
34
|
+
"retryDelay": 5000,
|
|
35
|
+
"timeout": 30000
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Supported Events
|
|
41
|
+
|
|
42
|
+
| Event | Description |
|
|
43
|
+
|-------|-------------|
|
|
44
|
+
| `entry:afterCreate` | Content entry created |
|
|
45
|
+
| `entry:afterUpdate` | Content entry updated |
|
|
46
|
+
| `entry:afterDelete` | Content entry deleted |
|
|
47
|
+
| `entry:afterPublish` | Content entry published |
|
|
48
|
+
| `entry:afterUnpublish` | Content entry unpublished |
|
|
49
|
+
| `user:afterCreate` | User account created |
|
|
50
|
+
| `user:afterUpdate` | User account updated |
|
|
51
|
+
| `user:afterDelete` | User account deleted |
|
|
52
|
+
| `media:afterUpload` | Media file uploaded |
|
|
53
|
+
| `media:afterDelete` | Media file deleted |
|
|
54
|
+
|
|
55
|
+
## API Endpoints
|
|
56
|
+
|
|
57
|
+
Base path: `/api/plugins/webhooks`
|
|
58
|
+
|
|
59
|
+
### Endpoints Management
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# List all endpoints
|
|
63
|
+
GET /endpoints
|
|
64
|
+
|
|
65
|
+
# Get endpoint details
|
|
66
|
+
GET /endpoints/:id
|
|
67
|
+
|
|
68
|
+
# Create endpoint
|
|
69
|
+
POST /endpoints
|
|
70
|
+
{
|
|
71
|
+
"name": "n8n Workflow",
|
|
72
|
+
"url": "https://n8n.example.com/webhook/abc123",
|
|
73
|
+
"events": ["entry:afterCreate", "entry:afterUpdate"],
|
|
74
|
+
"secret": "my-secret-key",
|
|
75
|
+
"headers": {
|
|
76
|
+
"X-Custom-Header": "value"
|
|
77
|
+
},
|
|
78
|
+
"enabled": true,
|
|
79
|
+
"contentTypeFilter": ["post", "page"]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# Update endpoint
|
|
83
|
+
PUT /endpoints/:id
|
|
84
|
+
|
|
85
|
+
# Delete endpoint
|
|
86
|
+
DELETE /endpoints/:id
|
|
87
|
+
|
|
88
|
+
# Test endpoint
|
|
89
|
+
POST /endpoints/:id/test
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Deliveries & Events
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# List delivery logs
|
|
96
|
+
GET /deliveries
|
|
97
|
+
GET /deliveries?endpointId=xxx&status=failed&limit=50
|
|
98
|
+
|
|
99
|
+
# List supported events
|
|
100
|
+
GET /events
|
|
101
|
+
|
|
102
|
+
# Reload endpoints (after manual changes)
|
|
103
|
+
POST /reload
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Webhook Payload
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
111
|
+
"event": "entry:afterCreate",
|
|
112
|
+
"timestamp": 1703980800000,
|
|
113
|
+
"contentType": "post",
|
|
114
|
+
"data": {
|
|
115
|
+
"id": "abc123",
|
|
116
|
+
"title": "My Post",
|
|
117
|
+
"slug": "my-post",
|
|
118
|
+
"content": { ... },
|
|
119
|
+
"status": "published",
|
|
120
|
+
"createdAt": 1703980800000
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Webhook Headers
|
|
126
|
+
|
|
127
|
+
Every webhook request includes:
|
|
128
|
+
|
|
129
|
+
| Header | Description |
|
|
130
|
+
|--------|-------------|
|
|
131
|
+
| `Content-Type` | `application/json` |
|
|
132
|
+
| `User-Agent` | `LokiCMS-Webhooks/1.0` |
|
|
133
|
+
| `X-Webhook-ID` | Unique delivery ID |
|
|
134
|
+
| `X-Webhook-Event` | Event name |
|
|
135
|
+
| `X-Webhook-Timestamp` | Unix timestamp |
|
|
136
|
+
| `X-Webhook-Signature` | HMAC-SHA256 signature (if secret configured) |
|
|
137
|
+
|
|
138
|
+
## Signature Verification
|
|
139
|
+
|
|
140
|
+
If you configure a secret, the plugin signs payloads with HMAC-SHA256:
|
|
141
|
+
|
|
142
|
+
```javascript
|
|
143
|
+
// Node.js verification example
|
|
144
|
+
const crypto = require('crypto');
|
|
145
|
+
|
|
146
|
+
function verifySignature(payload, signature, secret) {
|
|
147
|
+
const expected = 'sha256=' + crypto
|
|
148
|
+
.createHmac('sha256', secret)
|
|
149
|
+
.update(JSON.stringify(payload))
|
|
150
|
+
.digest('hex');
|
|
151
|
+
|
|
152
|
+
return crypto.timingSafeEqual(
|
|
153
|
+
Buffer.from(signature),
|
|
154
|
+
Buffer.from(expected)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Express middleware
|
|
159
|
+
app.post('/webhook', (req, res) => {
|
|
160
|
+
const signature = req.headers['x-webhook-signature'];
|
|
161
|
+
if (!verifySignature(req.body, signature, 'your-secret')) {
|
|
162
|
+
return res.status(401).send('Invalid signature');
|
|
163
|
+
}
|
|
164
|
+
// Process webhook...
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## n8n Integration
|
|
169
|
+
|
|
170
|
+
1. In n8n, create a new workflow with a **Webhook** trigger
|
|
171
|
+
2. Copy the webhook URL (e.g., `https://n8n.example.com/webhook/abc123`)
|
|
172
|
+
3. Create endpoint in LokiCMS:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
curl -X POST http://localhost:3005/api/plugins/webhooks/endpoints \
|
|
176
|
+
-H "Content-Type: application/json" \
|
|
177
|
+
-d '{
|
|
178
|
+
"name": "n8n Content Updates",
|
|
179
|
+
"url": "https://n8n.example.com/webhook/abc123",
|
|
180
|
+
"events": ["entry:afterCreate", "entry:afterUpdate", "entry:afterDelete"],
|
|
181
|
+
"enabled": true
|
|
182
|
+
}'
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Zapier Integration
|
|
186
|
+
|
|
187
|
+
1. Create a Zap with **Webhooks by Zapier** trigger
|
|
188
|
+
2. Choose "Catch Hook" and copy the webhook URL
|
|
189
|
+
3. Create endpoint in LokiCMS with the Zapier URL
|
|
190
|
+
|
|
191
|
+
## MCP Tools
|
|
192
|
+
|
|
193
|
+
Available tools for AI agents:
|
|
194
|
+
|
|
195
|
+
| Tool | Description |
|
|
196
|
+
|------|-------------|
|
|
197
|
+
| `webhooks_create_webhook` | Create a new endpoint |
|
|
198
|
+
| `webhooks_update_webhook` | Update an endpoint |
|
|
199
|
+
| `webhooks_delete_webhook` | Delete an endpoint |
|
|
200
|
+
| `webhooks_test_webhook` | Test an endpoint |
|
|
201
|
+
| `webhooks_list_webhooks` | List all endpoints |
|
|
202
|
+
| `webhooks_list_deliveries` | View delivery logs |
|
|
203
|
+
| `webhooks_list_webhook_events` | List supported events |
|
|
204
|
+
|
|
205
|
+
## Content Types
|
|
206
|
+
|
|
207
|
+
The plugin creates these content types:
|
|
208
|
+
|
|
209
|
+
- `webhooks-webhook-endpoint` - Endpoint configurations
|
|
210
|
+
- `webhooks-webhook-delivery` - Delivery logs
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
214
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lokicms-plugin-webhooks",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Outbound webhooks plugin for LokiCMS - Send HTTP notifications on CMS events",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsc --watch"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"lokicms",
|
|
13
|
+
"plugin",
|
|
14
|
+
"webhooks",
|
|
15
|
+
"n8n",
|
|
16
|
+
"zapier",
|
|
17
|
+
"make",
|
|
18
|
+
"automation",
|
|
19
|
+
"events",
|
|
20
|
+
"notifications"
|
|
21
|
+
],
|
|
22
|
+
"author": "MauricioPerera",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/MauricioPerera/lokicms-plugin-webhooks.git"
|
|
27
|
+
},
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/MauricioPerera/lokicms-plugin-webhooks/issues"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/MauricioPerera/lokicms-plugin-webhooks#readme",
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"hono": "^4.0.0",
|
|
34
|
+
"zod": "^3.0.0"
|
|
35
|
+
},
|
|
36
|
+
"lokicms": {
|
|
37
|
+
"displayName": "Webhooks",
|
|
38
|
+
"description": "Send HTTP webhooks when events occur (n8n, Zapier, Make compatible)",
|
|
39
|
+
"icon": "🔔",
|
|
40
|
+
"minVersion": "1.0.0"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhooks Plugin for LokiCMS
|
|
3
|
+
* Send HTTP webhooks when events occur in the CMS
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Hono } from 'hono';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import * as crypto from 'crypto';
|
|
9
|
+
import type {
|
|
10
|
+
PluginDefinition,
|
|
11
|
+
PluginAPI,
|
|
12
|
+
ContentTypeRegistration,
|
|
13
|
+
WebhookEvent,
|
|
14
|
+
WebhookEndpoint,
|
|
15
|
+
WebhookDelivery,
|
|
16
|
+
WebhookPayload,
|
|
17
|
+
WebhookConfig,
|
|
18
|
+
WEBHOOK_EVENTS,
|
|
19
|
+
} from './types';
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Constants
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
const CT_ENDPOINT = 'webhooks-webhook-endpoint';
|
|
26
|
+
const CT_DELIVERY = 'webhooks-webhook-delivery';
|
|
27
|
+
|
|
28
|
+
const DEFAULT_CONFIG: WebhookConfig = {
|
|
29
|
+
maxRetries: 3,
|
|
30
|
+
retryDelay: 5000,
|
|
31
|
+
timeout: 30000,
|
|
32
|
+
maxPayloadSize: 1024 * 1024, // 1MB
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const SUPPORTED_EVENTS: WebhookEvent[] = [
|
|
36
|
+
'entry:afterCreate',
|
|
37
|
+
'entry:afterUpdate',
|
|
38
|
+
'entry:afterDelete',
|
|
39
|
+
'entry:afterPublish',
|
|
40
|
+
'entry:afterUnpublish',
|
|
41
|
+
'user:afterCreate',
|
|
42
|
+
'user:afterUpdate',
|
|
43
|
+
'user:afterDelete',
|
|
44
|
+
'media:afterUpload',
|
|
45
|
+
'media:afterDelete',
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Content Types
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
const CONTENT_TYPES: ContentTypeRegistration[] = [
|
|
53
|
+
{
|
|
54
|
+
name: 'Webhook Endpoint',
|
|
55
|
+
slug: 'webhook-endpoint',
|
|
56
|
+
description: 'Webhook endpoint configuration',
|
|
57
|
+
fields: [
|
|
58
|
+
{ name: 'name', label: 'Name', type: 'text', required: true },
|
|
59
|
+
{ name: 'url', label: 'URL', type: 'url', required: true },
|
|
60
|
+
{ name: 'events', label: 'Events', type: 'json', required: true, description: 'Array of event names' },
|
|
61
|
+
{ name: 'secret', label: 'Secret', type: 'text', description: 'Secret for signing payloads' },
|
|
62
|
+
{ name: 'headers', label: 'Headers', type: 'json', description: 'Custom headers as JSON object' },
|
|
63
|
+
{ name: 'enabled', label: 'Enabled', type: 'boolean', defaultValue: true },
|
|
64
|
+
{ name: 'contentTypeFilter', label: 'Content Type Filter', type: 'json', description: 'Array of content type slugs' },
|
|
65
|
+
{ name: 'retryCount', label: 'Retry Count', type: 'number', defaultValue: 3 },
|
|
66
|
+
{ name: 'retryDelay', label: 'Retry Delay (ms)', type: 'number', defaultValue: 5000 },
|
|
67
|
+
],
|
|
68
|
+
titleField: 'name',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'Webhook Delivery',
|
|
72
|
+
slug: 'webhook-delivery',
|
|
73
|
+
description: 'Webhook delivery logs',
|
|
74
|
+
fields: [
|
|
75
|
+
{ name: 'endpointId', label: 'Endpoint ID', type: 'text', required: true },
|
|
76
|
+
{ name: 'endpointName', label: 'Endpoint Name', type: 'text' },
|
|
77
|
+
{ name: 'event', label: 'Event', type: 'text', required: true },
|
|
78
|
+
{ name: 'payload', label: 'Payload', type: 'json' },
|
|
79
|
+
{ name: 'status', label: 'Status', type: 'select', validation: { options: ['pending', 'success', 'failed'] } },
|
|
80
|
+
{ name: 'statusCode', label: 'Status Code', type: 'number' },
|
|
81
|
+
{ name: 'response', label: 'Response', type: 'textarea' },
|
|
82
|
+
{ name: 'error', label: 'Error', type: 'textarea' },
|
|
83
|
+
{ name: 'attempts', label: 'Attempts', type: 'number', defaultValue: 0 },
|
|
84
|
+
{ name: 'completedAt', label: 'Completed At', type: 'datetime' },
|
|
85
|
+
],
|
|
86
|
+
titleField: 'event',
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
// ============================================================================
|
|
91
|
+
// MCP Tool Schemas
|
|
92
|
+
// ============================================================================
|
|
93
|
+
|
|
94
|
+
const CreateEndpointSchema = z.object({
|
|
95
|
+
name: z.string().min(1).describe('Friendly name for the endpoint'),
|
|
96
|
+
url: z.string().url().describe('Webhook URL to call'),
|
|
97
|
+
events: z.array(z.string()).min(1).describe('Events to trigger this webhook'),
|
|
98
|
+
secret: z.string().optional().describe('Secret for HMAC signature'),
|
|
99
|
+
headers: z.record(z.string()).optional().describe('Custom headers'),
|
|
100
|
+
enabled: z.boolean().optional().default(true),
|
|
101
|
+
contentTypeFilter: z.array(z.string()).optional().describe('Filter by content type slugs'),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const UpdateEndpointSchema = z.object({
|
|
105
|
+
id: z.string().describe('Endpoint ID'),
|
|
106
|
+
name: z.string().optional(),
|
|
107
|
+
url: z.string().url().optional(),
|
|
108
|
+
events: z.array(z.string()).optional(),
|
|
109
|
+
secret: z.string().optional(),
|
|
110
|
+
headers: z.record(z.string()).optional(),
|
|
111
|
+
enabled: z.boolean().optional(),
|
|
112
|
+
contentTypeFilter: z.array(z.string()).optional(),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const DeleteEndpointSchema = z.object({
|
|
116
|
+
id: z.string().describe('Endpoint ID to delete'),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const TestEndpointSchema = z.object({
|
|
120
|
+
id: z.string().describe('Endpoint ID to test'),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const ListDeliveriesSchema = z.object({
|
|
124
|
+
endpointId: z.string().optional().describe('Filter by endpoint'),
|
|
125
|
+
status: z.enum(['pending', 'success', 'failed']).optional(),
|
|
126
|
+
limit: z.number().positive().max(100).optional().default(20),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ============================================================================
|
|
130
|
+
// Webhook Service
|
|
131
|
+
// ============================================================================
|
|
132
|
+
|
|
133
|
+
class WebhookService {
|
|
134
|
+
private api: PluginAPI;
|
|
135
|
+
private config: WebhookConfig;
|
|
136
|
+
private endpoints: Map<string, WebhookEndpoint> = new Map();
|
|
137
|
+
|
|
138
|
+
constructor(api: PluginAPI, config: WebhookConfig) {
|
|
139
|
+
this.api = api;
|
|
140
|
+
this.config = config;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async loadEndpoints(): Promise<void> {
|
|
144
|
+
try {
|
|
145
|
+
const result = await this.api.services.entries.findAll({
|
|
146
|
+
contentTypeSlug: CT_ENDPOINT,
|
|
147
|
+
status: 'published',
|
|
148
|
+
}) as { entries: Array<{ id: string; content: Record<string, unknown> }> };
|
|
149
|
+
|
|
150
|
+
this.endpoints.clear();
|
|
151
|
+
for (const entry of result.entries || []) {
|
|
152
|
+
const endpoint = this.mapEntryToEndpoint(entry);
|
|
153
|
+
if (endpoint.enabled) {
|
|
154
|
+
this.endpoints.set(endpoint.id, endpoint);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
this.api.logger.info(`Loaded ${this.endpoints.size} active webhook endpoints`);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.api.logger.error('Failed to load webhook endpoints:', error);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private mapEntryToEndpoint(entry: { id: string; content: Record<string, unknown> }): WebhookEndpoint {
|
|
165
|
+
const c = entry.content;
|
|
166
|
+
return {
|
|
167
|
+
id: entry.id,
|
|
168
|
+
name: (c.name as string) || '',
|
|
169
|
+
url: (c.url as string) || '',
|
|
170
|
+
events: (c.events as WebhookEvent[]) || [],
|
|
171
|
+
secret: c.secret as string | undefined,
|
|
172
|
+
headers: c.headers as Record<string, string> | undefined,
|
|
173
|
+
enabled: c.enabled !== false,
|
|
174
|
+
contentTypeFilter: c.contentTypeFilter as string[] | undefined,
|
|
175
|
+
retryCount: (c.retryCount as number) || this.config.maxRetries,
|
|
176
|
+
retryDelay: (c.retryDelay as number) || this.config.retryDelay,
|
|
177
|
+
createdAt: Date.now(),
|
|
178
|
+
updatedAt: Date.now(),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async createEndpoint(params: z.infer<typeof CreateEndpointSchema>): Promise<WebhookEndpoint> {
|
|
183
|
+
const slug = `webhook-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
184
|
+
|
|
185
|
+
const entry = await this.api.services.entries.create({
|
|
186
|
+
contentTypeSlug: CT_ENDPOINT,
|
|
187
|
+
title: params.name,
|
|
188
|
+
slug,
|
|
189
|
+
content: {
|
|
190
|
+
name: params.name,
|
|
191
|
+
url: params.url,
|
|
192
|
+
events: params.events,
|
|
193
|
+
secret: params.secret,
|
|
194
|
+
headers: params.headers,
|
|
195
|
+
enabled: params.enabled ?? true,
|
|
196
|
+
contentTypeFilter: params.contentTypeFilter,
|
|
197
|
+
retryCount: this.config.maxRetries,
|
|
198
|
+
retryDelay: this.config.retryDelay,
|
|
199
|
+
},
|
|
200
|
+
status: 'published',
|
|
201
|
+
}, 'system', 'Webhooks Plugin') as { id: string; content: Record<string, unknown> };
|
|
202
|
+
|
|
203
|
+
const endpoint = this.mapEntryToEndpoint(entry);
|
|
204
|
+
if (endpoint.enabled) {
|
|
205
|
+
this.endpoints.set(endpoint.id, endpoint);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return endpoint;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async updateEndpoint(params: z.infer<typeof UpdateEndpointSchema>): Promise<WebhookEndpoint | null> {
|
|
212
|
+
const existing = await this.api.services.entries.findById(params.id) as { id: string; content: Record<string, unknown> } | null;
|
|
213
|
+
if (!existing) return null;
|
|
214
|
+
|
|
215
|
+
const updatedContent = { ...existing.content };
|
|
216
|
+
if (params.name !== undefined) updatedContent.name = params.name;
|
|
217
|
+
if (params.url !== undefined) updatedContent.url = params.url;
|
|
218
|
+
if (params.events !== undefined) updatedContent.events = params.events;
|
|
219
|
+
if (params.secret !== undefined) updatedContent.secret = params.secret;
|
|
220
|
+
if (params.headers !== undefined) updatedContent.headers = params.headers;
|
|
221
|
+
if (params.enabled !== undefined) updatedContent.enabled = params.enabled;
|
|
222
|
+
if (params.contentTypeFilter !== undefined) updatedContent.contentTypeFilter = params.contentTypeFilter;
|
|
223
|
+
|
|
224
|
+
const entry = await this.api.services.entries.update(params.id, {
|
|
225
|
+
title: updatedContent.name as string,
|
|
226
|
+
content: updatedContent,
|
|
227
|
+
}) as { id: string; content: Record<string, unknown> };
|
|
228
|
+
|
|
229
|
+
const endpoint = this.mapEntryToEndpoint(entry);
|
|
230
|
+
|
|
231
|
+
if (endpoint.enabled) {
|
|
232
|
+
this.endpoints.set(endpoint.id, endpoint);
|
|
233
|
+
} else {
|
|
234
|
+
this.endpoints.delete(endpoint.id);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return endpoint;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async deleteEndpoint(id: string): Promise<boolean> {
|
|
241
|
+
try {
|
|
242
|
+
await this.api.services.entries.delete(id);
|
|
243
|
+
this.endpoints.delete(id);
|
|
244
|
+
return true;
|
|
245
|
+
} catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async getEndpoint(id: string): Promise<WebhookEndpoint | null> {
|
|
251
|
+
const cached = this.endpoints.get(id);
|
|
252
|
+
if (cached) return cached;
|
|
253
|
+
|
|
254
|
+
const entry = await this.api.services.entries.findById(id) as { id: string; content: Record<string, unknown> } | null;
|
|
255
|
+
if (!entry) return null;
|
|
256
|
+
|
|
257
|
+
return this.mapEntryToEndpoint(entry);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
getActiveEndpoints(): WebhookEndpoint[] {
|
|
261
|
+
return Array.from(this.endpoints.values());
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
getEndpointsForEvent(event: WebhookEvent, contentType?: string): WebhookEndpoint[] {
|
|
265
|
+
return Array.from(this.endpoints.values()).filter(endpoint => {
|
|
266
|
+
if (!endpoint.events.includes(event)) return false;
|
|
267
|
+
if (contentType && endpoint.contentTypeFilter?.length) {
|
|
268
|
+
return endpoint.contentTypeFilter.includes(contentType);
|
|
269
|
+
}
|
|
270
|
+
return true;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async dispatchWebhook(
|
|
275
|
+
event: WebhookEvent,
|
|
276
|
+
data: unknown,
|
|
277
|
+
contentType?: string
|
|
278
|
+
): Promise<void> {
|
|
279
|
+
const endpoints = this.getEndpointsForEvent(event, contentType);
|
|
280
|
+
|
|
281
|
+
if (endpoints.length === 0) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const payload: WebhookPayload = {
|
|
286
|
+
id: crypto.randomUUID(),
|
|
287
|
+
event,
|
|
288
|
+
timestamp: Date.now(),
|
|
289
|
+
data,
|
|
290
|
+
contentType,
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
for (const endpoint of endpoints) {
|
|
294
|
+
this.sendWebhook(endpoint, payload).catch(error => {
|
|
295
|
+
this.api.logger.error(`Webhook dispatch failed for ${endpoint.name}:`, error);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private async sendWebhook(
|
|
301
|
+
endpoint: WebhookEndpoint,
|
|
302
|
+
payload: WebhookPayload,
|
|
303
|
+
attempt: number = 1
|
|
304
|
+
): Promise<void> {
|
|
305
|
+
const deliveryId = crypto.randomUUID();
|
|
306
|
+
const bodyStr = JSON.stringify(payload);
|
|
307
|
+
|
|
308
|
+
// Create delivery log
|
|
309
|
+
const deliverySlug = `delivery-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
await this.api.services.entries.create({
|
|
313
|
+
contentTypeSlug: CT_DELIVERY,
|
|
314
|
+
title: `${payload.event} - ${endpoint.name}`,
|
|
315
|
+
slug: deliverySlug,
|
|
316
|
+
content: {
|
|
317
|
+
endpointId: endpoint.id,
|
|
318
|
+
endpointName: endpoint.name,
|
|
319
|
+
event: payload.event,
|
|
320
|
+
payload,
|
|
321
|
+
status: 'pending',
|
|
322
|
+
attempts: attempt,
|
|
323
|
+
},
|
|
324
|
+
status: 'published',
|
|
325
|
+
}, 'system', 'Webhooks Plugin');
|
|
326
|
+
} catch (error) {
|
|
327
|
+
this.api.logger.debug('Failed to create delivery log:', error);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Prepare headers
|
|
331
|
+
const headers: Record<string, string> = {
|
|
332
|
+
'Content-Type': 'application/json',
|
|
333
|
+
'User-Agent': 'LokiCMS-Webhooks/1.0',
|
|
334
|
+
'X-Webhook-ID': payload.id,
|
|
335
|
+
'X-Webhook-Event': payload.event,
|
|
336
|
+
'X-Webhook-Timestamp': payload.timestamp.toString(),
|
|
337
|
+
...endpoint.headers,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// Add signature if secret is configured
|
|
341
|
+
if (endpoint.secret) {
|
|
342
|
+
const signature = crypto
|
|
343
|
+
.createHmac('sha256', endpoint.secret)
|
|
344
|
+
.update(bodyStr)
|
|
345
|
+
.digest('hex');
|
|
346
|
+
headers['X-Webhook-Signature'] = `sha256=${signature}`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
const controller = new AbortController();
|
|
351
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
352
|
+
|
|
353
|
+
const response = await fetch(endpoint.url, {
|
|
354
|
+
method: 'POST',
|
|
355
|
+
headers,
|
|
356
|
+
body: bodyStr,
|
|
357
|
+
signal: controller.signal,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
clearTimeout(timeoutId);
|
|
361
|
+
|
|
362
|
+
const responseText = await response.text().catch(() => '');
|
|
363
|
+
|
|
364
|
+
// Update delivery log
|
|
365
|
+
await this.updateDeliveryLog(deliverySlug, {
|
|
366
|
+
status: response.ok ? 'success' : 'failed',
|
|
367
|
+
statusCode: response.status,
|
|
368
|
+
response: responseText.slice(0, 1000),
|
|
369
|
+
attempts: attempt,
|
|
370
|
+
completedAt: Date.now(),
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
if (!response.ok && attempt < endpoint.retryCount) {
|
|
374
|
+
this.api.logger.warn(
|
|
375
|
+
`Webhook to ${endpoint.name} failed (${response.status}), retrying in ${endpoint.retryDelay}ms...`
|
|
376
|
+
);
|
|
377
|
+
setTimeout(() => {
|
|
378
|
+
this.sendWebhook(endpoint, payload, attempt + 1);
|
|
379
|
+
}, endpoint.retryDelay);
|
|
380
|
+
} else if (response.ok) {
|
|
381
|
+
this.api.logger.debug(`Webhook delivered to ${endpoint.name}: ${payload.event}`);
|
|
382
|
+
} else {
|
|
383
|
+
this.api.logger.error(
|
|
384
|
+
`Webhook to ${endpoint.name} failed after ${attempt} attempts: ${response.status}`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
} catch (error) {
|
|
388
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
389
|
+
|
|
390
|
+
await this.updateDeliveryLog(deliverySlug, {
|
|
391
|
+
status: 'failed',
|
|
392
|
+
error: errorMessage,
|
|
393
|
+
attempts: attempt,
|
|
394
|
+
completedAt: Date.now(),
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
if (attempt < endpoint.retryCount) {
|
|
398
|
+
this.api.logger.warn(
|
|
399
|
+
`Webhook to ${endpoint.name} failed (${errorMessage}), retrying in ${endpoint.retryDelay}ms...`
|
|
400
|
+
);
|
|
401
|
+
setTimeout(() => {
|
|
402
|
+
this.sendWebhook(endpoint, payload, attempt + 1);
|
|
403
|
+
}, endpoint.retryDelay);
|
|
404
|
+
} else {
|
|
405
|
+
this.api.logger.error(
|
|
406
|
+
`Webhook to ${endpoint.name} failed after ${attempt} attempts: ${errorMessage}`
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private async updateDeliveryLog(
|
|
413
|
+
slug: string,
|
|
414
|
+
updates: Partial<{
|
|
415
|
+
status: string;
|
|
416
|
+
statusCode: number;
|
|
417
|
+
response: string;
|
|
418
|
+
error: string;
|
|
419
|
+
attempts: number;
|
|
420
|
+
completedAt: number;
|
|
421
|
+
}>
|
|
422
|
+
): Promise<void> {
|
|
423
|
+
try {
|
|
424
|
+
const entry = await this.api.services.entries.findBySlug(CT_DELIVERY, slug) as { id: string; content: Record<string, unknown> } | null;
|
|
425
|
+
if (entry) {
|
|
426
|
+
await this.api.services.entries.update(entry.id, {
|
|
427
|
+
content: { ...entry.content, ...updates },
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
} catch (error) {
|
|
431
|
+
this.api.logger.debug('Failed to update delivery log:', error);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async testEndpoint(id: string): Promise<{ success: boolean; statusCode?: number; error?: string }> {
|
|
436
|
+
const endpoint = await this.getEndpoint(id);
|
|
437
|
+
if (!endpoint) {
|
|
438
|
+
return { success: false, error: 'Endpoint not found' };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const testPayload: WebhookPayload = {
|
|
442
|
+
id: crypto.randomUUID(),
|
|
443
|
+
event: 'entry:afterCreate',
|
|
444
|
+
timestamp: Date.now(),
|
|
445
|
+
data: {
|
|
446
|
+
test: true,
|
|
447
|
+
message: 'This is a test webhook from LokiCMS',
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const bodyStr = JSON.stringify(testPayload);
|
|
452
|
+
const headers: Record<string, string> = {
|
|
453
|
+
'Content-Type': 'application/json',
|
|
454
|
+
'User-Agent': 'LokiCMS-Webhooks/1.0',
|
|
455
|
+
'X-Webhook-ID': testPayload.id,
|
|
456
|
+
'X-Webhook-Event': 'test',
|
|
457
|
+
'X-Webhook-Timestamp': testPayload.timestamp.toString(),
|
|
458
|
+
...endpoint.headers,
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
if (endpoint.secret) {
|
|
462
|
+
const signature = crypto
|
|
463
|
+
.createHmac('sha256', endpoint.secret)
|
|
464
|
+
.update(bodyStr)
|
|
465
|
+
.digest('hex');
|
|
466
|
+
headers['X-Webhook-Signature'] = `sha256=${signature}`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
const controller = new AbortController();
|
|
471
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
472
|
+
|
|
473
|
+
const response = await fetch(endpoint.url, {
|
|
474
|
+
method: 'POST',
|
|
475
|
+
headers,
|
|
476
|
+
body: bodyStr,
|
|
477
|
+
signal: controller.signal,
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
clearTimeout(timeoutId);
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
success: response.ok,
|
|
484
|
+
statusCode: response.status,
|
|
485
|
+
error: response.ok ? undefined : `HTTP ${response.status}`,
|
|
486
|
+
};
|
|
487
|
+
} catch (error) {
|
|
488
|
+
return {
|
|
489
|
+
success: false,
|
|
490
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async getDeliveries(params: {
|
|
496
|
+
endpointId?: string;
|
|
497
|
+
status?: string;
|
|
498
|
+
limit?: number;
|
|
499
|
+
}): Promise<WebhookDelivery[]> {
|
|
500
|
+
try {
|
|
501
|
+
const filters: Record<string, unknown> = {
|
|
502
|
+
contentTypeSlug: CT_DELIVERY,
|
|
503
|
+
limit: params.limit || 20,
|
|
504
|
+
orderBy: 'createdAt',
|
|
505
|
+
order: 'desc',
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const result = await this.api.services.entries.findAll(filters) as {
|
|
509
|
+
entries: Array<{ id: string; content: Record<string, unknown>; createdAt: number }>;
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
let deliveries = (result.entries || []).map(entry => ({
|
|
513
|
+
id: entry.id,
|
|
514
|
+
endpointId: entry.content.endpointId as string,
|
|
515
|
+
event: entry.content.event as string,
|
|
516
|
+
payload: entry.content.payload,
|
|
517
|
+
status: (entry.content.status as 'pending' | 'success' | 'failed') || 'pending',
|
|
518
|
+
statusCode: entry.content.statusCode as number | undefined,
|
|
519
|
+
response: entry.content.response as string | undefined,
|
|
520
|
+
error: entry.content.error as string | undefined,
|
|
521
|
+
attempts: (entry.content.attempts as number) || 0,
|
|
522
|
+
createdAt: entry.createdAt,
|
|
523
|
+
completedAt: entry.content.completedAt as number | undefined,
|
|
524
|
+
}));
|
|
525
|
+
|
|
526
|
+
if (params.endpointId) {
|
|
527
|
+
deliveries = deliveries.filter(d => d.endpointId === params.endpointId);
|
|
528
|
+
}
|
|
529
|
+
if (params.status) {
|
|
530
|
+
deliveries = deliveries.filter(d => d.status === params.status);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return deliveries;
|
|
534
|
+
} catch (error) {
|
|
535
|
+
this.api.logger.error('Failed to get deliveries:', error);
|
|
536
|
+
return [];
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ============================================================================
|
|
542
|
+
// Plugin Definition
|
|
543
|
+
// ============================================================================
|
|
544
|
+
|
|
545
|
+
let webhookService: WebhookService | null = null;
|
|
546
|
+
|
|
547
|
+
const webhooksPlugin: PluginDefinition = {
|
|
548
|
+
name: 'webhooks',
|
|
549
|
+
displayName: 'Webhooks',
|
|
550
|
+
version: '1.0.0',
|
|
551
|
+
description: 'Send HTTP webhooks when events occur in LokiCMS',
|
|
552
|
+
|
|
553
|
+
lifecycle: {
|
|
554
|
+
onLoad: () => {
|
|
555
|
+
console.log('[Webhooks] Plugin loaded');
|
|
556
|
+
},
|
|
557
|
+
onEnable: () => {
|
|
558
|
+
console.log('[Webhooks] Plugin enabled');
|
|
559
|
+
},
|
|
560
|
+
onDisable: () => {
|
|
561
|
+
console.log('[Webhooks] Plugin disabled');
|
|
562
|
+
webhookService = null;
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
|
|
566
|
+
setup: async (api: PluginAPI) => {
|
|
567
|
+
const config = api.config.getAll() as Partial<WebhookConfig>;
|
|
568
|
+
const mergedConfig: WebhookConfig = { ...DEFAULT_CONFIG, ...config };
|
|
569
|
+
|
|
570
|
+
// Initialize service
|
|
571
|
+
webhookService = new WebhookService(api, mergedConfig);
|
|
572
|
+
|
|
573
|
+
// Register content types
|
|
574
|
+
for (const contentType of CONTENT_TYPES) {
|
|
575
|
+
try {
|
|
576
|
+
await api.contentTypes.register(contentType);
|
|
577
|
+
api.logger.debug(`Registered content type: ${contentType.slug}`);
|
|
578
|
+
} catch (error) {
|
|
579
|
+
api.logger.debug(`Content type ${contentType.slug} may already exist`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Load existing endpoints
|
|
584
|
+
await webhookService.loadEndpoints();
|
|
585
|
+
|
|
586
|
+
// Register hooks
|
|
587
|
+
for (const event of SUPPORTED_EVENTS) {
|
|
588
|
+
api.hooks.on(event, async (payload) => {
|
|
589
|
+
if (!webhookService) return;
|
|
590
|
+
|
|
591
|
+
const data = payload as { contentType?: string; contentTypeSlug?: string };
|
|
592
|
+
const contentType = data.contentType || data.contentTypeSlug;
|
|
593
|
+
|
|
594
|
+
await webhookService.dispatchWebhook(event, payload, contentType);
|
|
595
|
+
});
|
|
596
|
+
api.logger.debug(`Registered hook: ${event}`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Register routes
|
|
600
|
+
const routes = createRoutes(api);
|
|
601
|
+
api.routes.register(routes);
|
|
602
|
+
api.logger.info(`Routes registered at ${api.routes.getBasePath()}`);
|
|
603
|
+
|
|
604
|
+
// Register MCP tools
|
|
605
|
+
registerMCPTools(api);
|
|
606
|
+
api.logger.info('MCP tools registered');
|
|
607
|
+
|
|
608
|
+
api.logger.info('Webhooks plugin setup complete');
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
// ============================================================================
|
|
613
|
+
// Routes
|
|
614
|
+
// ============================================================================
|
|
615
|
+
|
|
616
|
+
function createRoutes(api: PluginAPI): Hono {
|
|
617
|
+
const app = new Hono();
|
|
618
|
+
|
|
619
|
+
// Health check
|
|
620
|
+
app.get('/health', (c) => {
|
|
621
|
+
return c.json({
|
|
622
|
+
status: 'ok',
|
|
623
|
+
service: 'webhooks',
|
|
624
|
+
endpoints: webhookService?.getActiveEndpoints().length || 0,
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// List supported events
|
|
629
|
+
app.get('/events', (c) => {
|
|
630
|
+
return c.json({ events: SUPPORTED_EVENTS });
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
// List endpoints
|
|
634
|
+
app.get('/endpoints', async (c) => {
|
|
635
|
+
if (!webhookService) {
|
|
636
|
+
return c.json({ error: 'Service not initialized' }, 500);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const endpoints = webhookService.getActiveEndpoints();
|
|
640
|
+
return c.json({ endpoints });
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// Get endpoint
|
|
644
|
+
app.get('/endpoints/:id', async (c) => {
|
|
645
|
+
if (!webhookService) {
|
|
646
|
+
return c.json({ error: 'Service not initialized' }, 500);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const endpoint = await webhookService.getEndpoint(c.req.param('id'));
|
|
650
|
+
if (!endpoint) {
|
|
651
|
+
return c.json({ error: 'Endpoint not found' }, 404);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return c.json(endpoint);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// Create endpoint
|
|
658
|
+
app.post('/endpoints', async (c) => {
|
|
659
|
+
if (!webhookService) {
|
|
660
|
+
return c.json({ error: 'Service not initialized' }, 500);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
try {
|
|
664
|
+
const body = await c.req.json();
|
|
665
|
+
const parsed = CreateEndpointSchema.parse(body);
|
|
666
|
+
const endpoint = await webhookService.createEndpoint(parsed);
|
|
667
|
+
return c.json(endpoint, 201);
|
|
668
|
+
} catch (error) {
|
|
669
|
+
api.logger.error('Create endpoint error:', error);
|
|
670
|
+
return c.json({ error: 'Invalid request' }, 400);
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// Update endpoint
|
|
675
|
+
app.put('/endpoints/:id', async (c) => {
|
|
676
|
+
if (!webhookService) {
|
|
677
|
+
return c.json({ error: 'Service not initialized' }, 500);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
try {
|
|
681
|
+
const body = await c.req.json();
|
|
682
|
+
const parsed = UpdateEndpointSchema.parse({ ...body, id: c.req.param('id') });
|
|
683
|
+
const endpoint = await webhookService.updateEndpoint(parsed);
|
|
684
|
+
|
|
685
|
+
if (!endpoint) {
|
|
686
|
+
return c.json({ error: 'Endpoint not found' }, 404);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return c.json(endpoint);
|
|
690
|
+
} catch (error) {
|
|
691
|
+
api.logger.error('Update endpoint error:', error);
|
|
692
|
+
return c.json({ error: 'Invalid request' }, 400);
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
// Delete endpoint
|
|
697
|
+
app.delete('/endpoints/:id', async (c) => {
|
|
698
|
+
if (!webhookService) {
|
|
699
|
+
return c.json({ error: 'Service not initialized' }, 500);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const success = await webhookService.deleteEndpoint(c.req.param('id'));
|
|
703
|
+
if (!success) {
|
|
704
|
+
return c.json({ error: 'Endpoint not found' }, 404);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return c.json({ success: true });
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// Test endpoint
|
|
711
|
+
app.post('/endpoints/:id/test', async (c) => {
|
|
712
|
+
if (!webhookService) {
|
|
713
|
+
return c.json({ error: 'Service not initialized' }, 500);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const result = await webhookService.testEndpoint(c.req.param('id'));
|
|
717
|
+
return c.json(result);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// List deliveries
|
|
721
|
+
app.get('/deliveries', async (c) => {
|
|
722
|
+
if (!webhookService) {
|
|
723
|
+
return c.json({ error: 'Service not initialized' }, 500);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const endpointId = c.req.query('endpointId');
|
|
727
|
+
const status = c.req.query('status') as 'pending' | 'success' | 'failed' | undefined;
|
|
728
|
+
const limit = parseInt(c.req.query('limit') || '20');
|
|
729
|
+
|
|
730
|
+
const deliveries = await webhookService.getDeliveries({
|
|
731
|
+
endpointId,
|
|
732
|
+
status,
|
|
733
|
+
limit,
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
return c.json({ deliveries });
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
// Reload endpoints (after manual changes)
|
|
740
|
+
app.post('/reload', async (c) => {
|
|
741
|
+
if (!webhookService) {
|
|
742
|
+
return c.json({ error: 'Service not initialized' }, 500);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
await webhookService.loadEndpoints();
|
|
746
|
+
return c.json({
|
|
747
|
+
success: true,
|
|
748
|
+
endpoints: webhookService.getActiveEndpoints().length,
|
|
749
|
+
});
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
return app;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ============================================================================
|
|
756
|
+
// MCP Tools
|
|
757
|
+
// ============================================================================
|
|
758
|
+
|
|
759
|
+
function registerMCPTools(api: PluginAPI): void {
|
|
760
|
+
// Create webhook endpoint
|
|
761
|
+
api.mcp.registerTool('create_webhook', {
|
|
762
|
+
description: 'Create a new webhook endpoint to receive events',
|
|
763
|
+
inputSchema: CreateEndpointSchema,
|
|
764
|
+
handler: async (args) => {
|
|
765
|
+
if (!webhookService) throw new Error('Service not initialized');
|
|
766
|
+
const params = args as z.infer<typeof CreateEndpointSchema>;
|
|
767
|
+
return await webhookService.createEndpoint(params);
|
|
768
|
+
},
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
// Update webhook endpoint
|
|
772
|
+
api.mcp.registerTool('update_webhook', {
|
|
773
|
+
description: 'Update an existing webhook endpoint',
|
|
774
|
+
inputSchema: UpdateEndpointSchema,
|
|
775
|
+
handler: async (args) => {
|
|
776
|
+
if (!webhookService) throw new Error('Service not initialized');
|
|
777
|
+
const params = args as z.infer<typeof UpdateEndpointSchema>;
|
|
778
|
+
const result = await webhookService.updateEndpoint(params);
|
|
779
|
+
if (!result) throw new Error('Endpoint not found');
|
|
780
|
+
return result;
|
|
781
|
+
},
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
// Delete webhook endpoint
|
|
785
|
+
api.mcp.registerTool('delete_webhook', {
|
|
786
|
+
description: 'Delete a webhook endpoint',
|
|
787
|
+
inputSchema: DeleteEndpointSchema,
|
|
788
|
+
handler: async (args) => {
|
|
789
|
+
if (!webhookService) throw new Error('Service not initialized');
|
|
790
|
+
const params = args as z.infer<typeof DeleteEndpointSchema>;
|
|
791
|
+
const success = await webhookService.deleteEndpoint(params.id);
|
|
792
|
+
return { success };
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
// Test webhook endpoint
|
|
797
|
+
api.mcp.registerTool('test_webhook', {
|
|
798
|
+
description: 'Send a test payload to a webhook endpoint',
|
|
799
|
+
inputSchema: TestEndpointSchema,
|
|
800
|
+
handler: async (args) => {
|
|
801
|
+
if (!webhookService) throw new Error('Service not initialized');
|
|
802
|
+
const params = args as z.infer<typeof TestEndpointSchema>;
|
|
803
|
+
return await webhookService.testEndpoint(params.id);
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
// List webhook endpoints
|
|
808
|
+
api.mcp.registerTool('list_webhooks', {
|
|
809
|
+
description: 'List all active webhook endpoints',
|
|
810
|
+
inputSchema: z.object({}),
|
|
811
|
+
handler: async () => {
|
|
812
|
+
if (!webhookService) throw new Error('Service not initialized');
|
|
813
|
+
return webhookService.getActiveEndpoints();
|
|
814
|
+
},
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
// List deliveries
|
|
818
|
+
api.mcp.registerTool('list_deliveries', {
|
|
819
|
+
description: 'List recent webhook deliveries',
|
|
820
|
+
inputSchema: ListDeliveriesSchema,
|
|
821
|
+
handler: async (args) => {
|
|
822
|
+
if (!webhookService) throw new Error('Service not initialized');
|
|
823
|
+
const params = args as z.infer<typeof ListDeliveriesSchema>;
|
|
824
|
+
return await webhookService.getDeliveries(params);
|
|
825
|
+
},
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
// List supported events
|
|
829
|
+
api.mcp.registerTool('list_webhook_events', {
|
|
830
|
+
description: 'List all supported webhook events',
|
|
831
|
+
inputSchema: z.object({}),
|
|
832
|
+
handler: async () => {
|
|
833
|
+
return SUPPORTED_EVENTS;
|
|
834
|
+
},
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
export default webhooksPlugin;
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhooks Plugin Types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Hono } from 'hono';
|
|
6
|
+
import type { z } from 'zod';
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Plugin API Types (from LokiCMS)
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
export interface PluginLifecycle {
|
|
13
|
+
onLoad?: () => Promise<void> | void;
|
|
14
|
+
onEnable?: () => Promise<void> | void;
|
|
15
|
+
onDisable?: () => Promise<void> | void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MCPToolDefinition {
|
|
19
|
+
name?: string;
|
|
20
|
+
description: string;
|
|
21
|
+
inputSchema: z.ZodType;
|
|
22
|
+
handler: (args: unknown) => Promise<unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface HookRegistrar {
|
|
26
|
+
on(hookName: string, handler: (payload: unknown) => Promise<unknown> | void, priority?: number): void;
|
|
27
|
+
off(hookName: string, handler: (payload: unknown) => Promise<unknown> | void): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RouteRegistrar {
|
|
31
|
+
register(routes: Hono): void;
|
|
32
|
+
getBasePath(): string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MCPRegistrar {
|
|
36
|
+
registerTool(name: string, tool: MCPToolDefinition): void;
|
|
37
|
+
unregisterTool(name: string): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ContentTypeFieldDefinition {
|
|
41
|
+
name: string;
|
|
42
|
+
label: string;
|
|
43
|
+
type: string;
|
|
44
|
+
required?: boolean;
|
|
45
|
+
description?: string;
|
|
46
|
+
defaultValue?: unknown;
|
|
47
|
+
validation?: Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ContentTypeRegistration {
|
|
51
|
+
name: string;
|
|
52
|
+
slug: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
fields: ContentTypeFieldDefinition[];
|
|
55
|
+
titleField?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ContentTypeRegistrar {
|
|
59
|
+
register(contentType: ContentTypeRegistration): Promise<void>;
|
|
60
|
+
unregister(slug: string): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ConfigAccessor {
|
|
64
|
+
get<T = unknown>(key: string, defaultValue?: T): T;
|
|
65
|
+
getAll(): Record<string, unknown>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface PluginLogger {
|
|
69
|
+
debug(message: string, ...args: unknown[]): void;
|
|
70
|
+
info(message: string, ...args: unknown[]): void;
|
|
71
|
+
warn(message: string, ...args: unknown[]): void;
|
|
72
|
+
error(message: string, ...args: unknown[]): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface PluginServices {
|
|
76
|
+
entries: {
|
|
77
|
+
create: (input: unknown, authorId: string, authorName?: string) => Promise<unknown>;
|
|
78
|
+
findById: (id: string) => Promise<unknown>;
|
|
79
|
+
findBySlug: (contentType: string, slug: string) => Promise<unknown>;
|
|
80
|
+
findAll: (filters?: unknown) => Promise<unknown>;
|
|
81
|
+
update: (id: string, input: unknown) => Promise<unknown>;
|
|
82
|
+
delete: (id: string) => Promise<void>;
|
|
83
|
+
};
|
|
84
|
+
users: {
|
|
85
|
+
findById: (id: string) => Promise<unknown>;
|
|
86
|
+
findByEmail: (email: string) => Promise<unknown>;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface PluginAPI {
|
|
91
|
+
readonly pluginName: string;
|
|
92
|
+
readonly services: PluginServices;
|
|
93
|
+
hooks: HookRegistrar;
|
|
94
|
+
routes: RouteRegistrar;
|
|
95
|
+
mcp: MCPRegistrar;
|
|
96
|
+
database: unknown;
|
|
97
|
+
contentTypes: ContentTypeRegistrar;
|
|
98
|
+
config: ConfigAccessor;
|
|
99
|
+
logger: PluginLogger;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface PluginDefinition {
|
|
103
|
+
name: string;
|
|
104
|
+
displayName?: string;
|
|
105
|
+
version: string;
|
|
106
|
+
description?: string;
|
|
107
|
+
lifecycle?: PluginLifecycle;
|
|
108
|
+
setup: (api: PluginAPI) => Promise<void> | void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ============================================================================
|
|
112
|
+
// Webhooks Plugin Specific Types
|
|
113
|
+
// ============================================================================
|
|
114
|
+
|
|
115
|
+
export type WebhookEvent =
|
|
116
|
+
| 'entry:afterCreate'
|
|
117
|
+
| 'entry:afterUpdate'
|
|
118
|
+
| 'entry:afterDelete'
|
|
119
|
+
| 'entry:afterPublish'
|
|
120
|
+
| 'entry:afterUnpublish'
|
|
121
|
+
| 'user:afterCreate'
|
|
122
|
+
| 'user:afterUpdate'
|
|
123
|
+
| 'user:afterDelete'
|
|
124
|
+
| 'media:afterUpload'
|
|
125
|
+
| 'media:afterDelete';
|
|
126
|
+
|
|
127
|
+
export const WEBHOOK_EVENTS: WebhookEvent[] = [
|
|
128
|
+
'entry:afterCreate',
|
|
129
|
+
'entry:afterUpdate',
|
|
130
|
+
'entry:afterDelete',
|
|
131
|
+
'entry:afterPublish',
|
|
132
|
+
'entry:afterUnpublish',
|
|
133
|
+
'user:afterCreate',
|
|
134
|
+
'user:afterUpdate',
|
|
135
|
+
'user:afterDelete',
|
|
136
|
+
'media:afterUpload',
|
|
137
|
+
'media:afterDelete',
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
export interface WebhookEndpoint {
|
|
141
|
+
id: string;
|
|
142
|
+
name: string;
|
|
143
|
+
url: string;
|
|
144
|
+
events: WebhookEvent[];
|
|
145
|
+
secret?: string;
|
|
146
|
+
headers?: Record<string, string>;
|
|
147
|
+
enabled: boolean;
|
|
148
|
+
contentTypeFilter?: string[];
|
|
149
|
+
retryCount: number;
|
|
150
|
+
retryDelay: number;
|
|
151
|
+
createdAt: number;
|
|
152
|
+
updatedAt: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface WebhookDelivery {
|
|
156
|
+
id: string;
|
|
157
|
+
endpointId: string;
|
|
158
|
+
event: string;
|
|
159
|
+
payload: unknown;
|
|
160
|
+
status: 'pending' | 'success' | 'failed';
|
|
161
|
+
statusCode?: number;
|
|
162
|
+
response?: string;
|
|
163
|
+
error?: string;
|
|
164
|
+
attempts: number;
|
|
165
|
+
createdAt: number;
|
|
166
|
+
completedAt?: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface WebhookPayload {
|
|
170
|
+
id: string;
|
|
171
|
+
event: WebhookEvent;
|
|
172
|
+
timestamp: number;
|
|
173
|
+
data: unknown;
|
|
174
|
+
contentType?: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface WebhookConfig {
|
|
178
|
+
signingSecret?: string;
|
|
179
|
+
maxRetries: number;
|
|
180
|
+
retryDelay: number;
|
|
181
|
+
timeout: number;
|
|
182
|
+
maxPayloadSize: number;
|
|
183
|
+
}
|