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 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
+ }