@emdash-cms/plugin-webhook-notifier 0.1.0 → 0.1.2

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.
@@ -1,602 +0,0 @@
1
- /**
2
- * Sandbox Entry Point -- Webhook Notifier
3
- *
4
- * Canonical plugin implementation using the standard format.
5
- * Runs in both trusted (in-process) and sandboxed (isolate) modes.
6
- */
7
-
8
- import { definePlugin } from "emdash";
9
- import type { PluginContext } from "emdash";
10
-
11
- interface ContentSaveEvent {
12
- content: Record<string, unknown>;
13
- collection: string;
14
- isNew: boolean;
15
- }
16
-
17
- interface ContentDeleteEvent {
18
- id: string;
19
- collection: string;
20
- }
21
-
22
- interface MediaUploadEvent {
23
- media: { id: string };
24
- }
25
-
26
- interface WebhookPayload {
27
- event: string;
28
- timestamp: string;
29
- collection?: string;
30
- resourceId: string;
31
- resourceType: "content" | "media";
32
- data?: Record<string, unknown>;
33
- metadata?: Record<string, unknown>;
34
- }
35
-
36
- // ── SSRF protection ──
37
-
38
- const IPV6_BRACKET_PATTERN = /^\[|\]$/g;
39
- const BLOCKED_HOSTNAMES = new Set(["localhost", "metadata.google.internal", "[::1]"]);
40
- const PRIVATE_RANGES = [
41
- { start: (127 << 24) >>> 0, end: ((127 << 24) | 0x00ffffff) >>> 0 },
42
- { start: (10 << 24) >>> 0, end: ((10 << 24) | 0x00ffffff) >>> 0 },
43
- {
44
- start: ((172 << 24) | (16 << 16)) >>> 0,
45
- end: ((172 << 24) | (31 << 16) | 0xffff) >>> 0,
46
- },
47
- {
48
- start: ((192 << 24) | (168 << 16)) >>> 0,
49
- end: ((192 << 24) | (168 << 16) | 0xffff) >>> 0,
50
- },
51
- {
52
- start: ((169 << 24) | (254 << 16)) >>> 0,
53
- end: ((169 << 24) | (254 << 16) | 0xffff) >>> 0,
54
- },
55
- { start: 0, end: 0x00ffffff },
56
- ];
57
-
58
- function validateWebhookUrl(url: string): void {
59
- let parsed: URL;
60
- try {
61
- parsed = new URL(url);
62
- } catch {
63
- throw new Error("Invalid webhook URL");
64
- }
65
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
66
- throw new Error(`Webhook URL scheme '${parsed.protocol}' is not allowed`);
67
- }
68
- const hostname = parsed.hostname.replace(IPV6_BRACKET_PATTERN, "");
69
- if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
70
- throw new Error("Webhook URLs targeting internal hosts are not allowed");
71
- }
72
- const parts = hostname.split(".");
73
- if (parts.length === 4) {
74
- const nums = parts.map(Number);
75
- if (nums.every((n) => !isNaN(n) && n >= 0 && n <= 255)) {
76
- const ip = ((nums[0]! << 24) | (nums[1]! << 16) | (nums[2]! << 8) | nums[3]!) >>> 0;
77
- if (PRIVATE_RANGES.some((r) => ip >= r.start && ip <= r.end)) {
78
- throw new Error("Webhook URLs targeting private IP addresses are not allowed");
79
- }
80
- }
81
- }
82
- if (
83
- hostname === "::1" ||
84
- hostname.startsWith("fe80:") ||
85
- hostname.startsWith("fc") ||
86
- hostname.startsWith("fd")
87
- ) {
88
- throw new Error("Webhook URLs targeting internal addresses are not allowed");
89
- }
90
- }
91
-
92
- // ── Webhook delivery ──
93
-
94
- type FetchFn = (url: string, init?: RequestInit) => Promise<Response>;
95
- type LogFn = PluginContext["log"];
96
-
97
- async function sendWebhook(
98
- fetchFn: FetchFn,
99
- log: LogFn,
100
- url: string,
101
- payload: WebhookPayload,
102
- token: string | undefined,
103
- maxRetries: number,
104
- ): Promise<{ success: boolean; status?: number; error?: string }> {
105
- validateWebhookUrl(url);
106
-
107
- let lastError: string | undefined;
108
- let lastStatus: number | undefined;
109
-
110
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
111
- try {
112
- const headers: Record<string, string> = {
113
- "Content-Type": "application/json",
114
- "X-EmDash-Event": payload.event,
115
- };
116
- if (token) headers["Authorization"] = `Bearer ${token}`;
117
-
118
- const response = await fetchFn(url, {
119
- method: "POST",
120
- headers,
121
- body: JSON.stringify(payload),
122
- });
123
-
124
- lastStatus = response.status;
125
- if (response.ok) {
126
- log.info(`Delivered ${payload.event} to ${url} (${response.status})`);
127
- return { success: true, status: response.status };
128
- }
129
-
130
- lastError = `HTTP ${response.status}: ${response.statusText}`;
131
- log.warn(`Attempt ${attempt}/${maxRetries} failed: ${lastError}`);
132
- } catch (error) {
133
- lastError = error instanceof Error ? error.message : "Unknown error";
134
- log.warn(`Attempt ${attempt}/${maxRetries} failed: ${lastError}`);
135
- }
136
-
137
- if (attempt < maxRetries) {
138
- await new Promise((resolve) => setTimeout(resolve, 100 * Math.pow(2, attempt - 1)));
139
- }
140
- }
141
-
142
- log.error(`Failed to deliver ${payload.event} after ${maxRetries} attempts`);
143
- return { success: false, status: lastStatus, error: lastError };
144
- }
145
-
146
- // ── Helpers ──
147
-
148
- function isRecord(value: unknown): value is Record<string, unknown> {
149
- return typeof value === "object" && value !== null && !Array.isArray(value);
150
- }
151
-
152
- function getString(value: unknown, key: string): string | undefined {
153
- if (!isRecord(value)) return undefined;
154
- const v = value[key];
155
- return typeof v === "string" ? v : undefined;
156
- }
157
-
158
- const MAX_RETRIES = 3;
159
-
160
- async function getConfig(ctx: PluginContext) {
161
- const url = await ctx.kv.get<string>("settings:webhookUrl");
162
- const token = await ctx.kv.get<string>("settings:secretToken");
163
- const enabled = await ctx.kv.get<boolean>("settings:enabled");
164
- return { url, token, enabled };
165
- }
166
-
167
- function getFetchFn(ctx: PluginContext): FetchFn {
168
- if (!ctx.http) {
169
- throw new Error("Webhook notifier requires network:fetch capability");
170
- }
171
- return ctx.http.fetch;
172
- }
173
-
174
- // ── Plugin definition ──
175
-
176
- export default definePlugin({
177
- hooks: {
178
- "content:afterSave": {
179
- priority: 210,
180
- timeout: 10000,
181
- dependencies: ["audit-log"],
182
- errorPolicy: "continue",
183
- handler: async (event: ContentSaveEvent, ctx: PluginContext) => {
184
- const { url, token, enabled } = await getConfig(ctx);
185
- if (enabled === false || !url) return;
186
-
187
- const contentId =
188
- typeof event.content.id === "string" ? event.content.id : String(event.content.id);
189
-
190
- const payload: WebhookPayload = {
191
- event: event.isNew ? "content:create" : "content:update",
192
- timestamp: new Date().toISOString(),
193
- collection: event.collection,
194
- resourceId: contentId,
195
- resourceType: "content",
196
- metadata: {
197
- slug: event.content.slug,
198
- status: event.content.status,
199
- },
200
- };
201
-
202
- await sendWebhook(getFetchFn(ctx), ctx.log, url, payload, token ?? undefined, MAX_RETRIES);
203
- },
204
- },
205
-
206
- "content:afterDelete": {
207
- priority: 210,
208
- timeout: 10000,
209
- dependencies: ["audit-log"],
210
- errorPolicy: "continue",
211
- handler: async (event: ContentDeleteEvent, ctx: PluginContext) => {
212
- const { url, token, enabled } = await getConfig(ctx);
213
- if (enabled === false || !url) return;
214
-
215
- const payload: WebhookPayload = {
216
- event: "content:delete",
217
- timestamp: new Date().toISOString(),
218
- collection: event.collection,
219
- resourceId: event.id,
220
- resourceType: "content",
221
- };
222
-
223
- await sendWebhook(getFetchFn(ctx), ctx.log, url, payload, token ?? undefined, MAX_RETRIES);
224
- },
225
- },
226
-
227
- "media:afterUpload": {
228
- priority: 210,
229
- timeout: 10000,
230
- errorPolicy: "continue",
231
- handler: async (event: MediaUploadEvent, ctx: PluginContext) => {
232
- const { url, token, enabled } = await getConfig(ctx);
233
- if (enabled === false || !url) return;
234
-
235
- const payload: WebhookPayload = {
236
- event: "media:upload",
237
- timestamp: new Date().toISOString(),
238
- resourceId: event.media.id,
239
- resourceType: "media",
240
- };
241
-
242
- await sendWebhook(getFetchFn(ctx), ctx.log, url, payload, token ?? undefined, MAX_RETRIES);
243
- },
244
- },
245
- },
246
-
247
- routes: {
248
- admin: {
249
- handler: async (
250
- routeCtx: { input: unknown; request: { url: string } },
251
- ctx: PluginContext,
252
- ) => {
253
- const interaction = routeCtx.input as {
254
- type: string;
255
- page?: string;
256
- action_id?: string;
257
- value?: string;
258
- values?: Record<string, unknown>;
259
- };
260
-
261
- if (interaction.type === "page_load" && interaction.page === "widget:webhook-status") {
262
- return buildStatusWidget(ctx);
263
- }
264
- if (interaction.type === "page_load" && interaction.page === "/settings") {
265
- return buildSettingsPage(ctx);
266
- }
267
- if (interaction.type === "form_submit" && interaction.action_id === "save_settings") {
268
- return saveSettings(ctx, interaction.values ?? {});
269
- }
270
- if (interaction.type === "block_action" && interaction.action_id === "test_webhook") {
271
- return testWebhook(ctx);
272
- }
273
- return { blocks: [] };
274
- },
275
- },
276
-
277
- status: {
278
- handler: async (_routeCtx: { input: unknown; request: unknown }, ctx: PluginContext) => {
279
- try {
280
- const url = await ctx.kv.get<string>("settings:webhookUrl");
281
- const enabled = await ctx.kv.get<boolean>("settings:enabled");
282
- const deliveries = ctx.storage.deliveries!;
283
- const successful = await deliveries.count({ status: "success" });
284
- const failed = await deliveries.count({ status: "failed" });
285
- const pending = await deliveries.count({ status: "pending" });
286
-
287
- return {
288
- configured: !!url,
289
- enabled: enabled ?? true,
290
- stats: { successful, failed, pending },
291
- };
292
- } catch (error) {
293
- ctx.log.error("Failed to get status", error);
294
- return {
295
- configured: false,
296
- enabled: true,
297
- stats: { successful: 0, failed: 0, pending: 0 },
298
- };
299
- }
300
- },
301
- },
302
-
303
- settings: {
304
- handler: async (_routeCtx: { input: unknown; request: unknown }, ctx: PluginContext) => {
305
- try {
306
- const settings = await ctx.kv.list("settings:");
307
- const map: Record<string, unknown> = {};
308
- for (const entry of settings) {
309
- map[entry.key.replace("settings:", "")] = entry.value;
310
- }
311
- return {
312
- webhookUrl: typeof map.webhookUrl === "string" ? map.webhookUrl : "",
313
- enabled: typeof map.enabled === "boolean" ? map.enabled : true,
314
- includeData: typeof map.includeData === "boolean" ? map.includeData : false,
315
- events: typeof map.events === "string" ? map.events : "all",
316
- };
317
- } catch (error) {
318
- ctx.log.error("Failed to get settings", error);
319
- return { webhookUrl: "", enabled: true, includeData: false, events: "all" };
320
- }
321
- },
322
- },
323
-
324
- "settings/save": {
325
- handler: async (routeCtx: { input: unknown; request: unknown }, ctx: PluginContext) => {
326
- try {
327
- const input = isRecord(routeCtx.input) ? routeCtx.input : {};
328
- if (typeof input.webhookUrl === "string")
329
- await ctx.kv.set("settings:webhookUrl", input.webhookUrl);
330
- if (typeof input.enabled === "boolean")
331
- await ctx.kv.set("settings:enabled", input.enabled);
332
- if (typeof input.includeData === "boolean")
333
- await ctx.kv.set("settings:includeData", input.includeData);
334
- if (typeof input.events === "string") await ctx.kv.set("settings:events", input.events);
335
- return { success: true };
336
- } catch (error) {
337
- ctx.log.error("Failed to save settings", error);
338
- return { success: false, error: String(error) };
339
- }
340
- },
341
- },
342
-
343
- test: {
344
- handler: async (routeCtx: { input: unknown; request: unknown }, ctx: PluginContext) => {
345
- const testUrl = getString(routeCtx.input, "url");
346
- if (!testUrl) return { success: false, error: "No webhook URL provided" };
347
-
348
- const token = await ctx.kv.get<string>("settings:secretToken");
349
-
350
- const testPayload: WebhookPayload = {
351
- event: "content:create",
352
- timestamp: new Date().toISOString(),
353
- resourceId: "test-" + Date.now(),
354
- resourceType: "content",
355
- metadata: { test: true, message: "Webhook test from EmDash CMS" },
356
- };
357
-
358
- const result = await sendWebhook(
359
- getFetchFn(ctx),
360
- ctx.log,
361
- testUrl,
362
- testPayload,
363
- token ?? undefined,
364
- 1,
365
- );
366
- return {
367
- success: result.success,
368
- status: result.status,
369
- error: result.error,
370
- payload: testPayload,
371
- };
372
- },
373
- },
374
- },
375
- });
376
-
377
- // ── Block Kit admin helpers ──
378
-
379
- async function buildStatusWidget(ctx: PluginContext) {
380
- try {
381
- const url = await ctx.kv.get<string>("settings:webhookUrl");
382
- const enabled = await ctx.kv.get<boolean>("settings:enabled");
383
- const isConfigured = !!url && enabled !== false;
384
-
385
- let successful = 0;
386
- let failed = 0;
387
- let pending = 0;
388
- try {
389
- const deliveries = ctx.storage.deliveries!;
390
- successful = await deliveries.count({ status: "success" });
391
- failed = await deliveries.count({ status: "failed" });
392
- pending = await deliveries.count({ status: "pending" });
393
- } catch {
394
- // Storage not available yet
395
- }
396
-
397
- const blocks: unknown[] = [
398
- {
399
- type: "fields",
400
- fields: [
401
- {
402
- label: "Status",
403
- value: isConfigured ? "Active" : "Not Configured",
404
- },
405
- {
406
- label: "Endpoint",
407
- value: url ? url : "None",
408
- },
409
- ],
410
- },
411
- ];
412
-
413
- if (isConfigured) {
414
- blocks.push({
415
- type: "stats",
416
- stats: [
417
- { label: "Delivered", value: String(successful) },
418
- { label: "Failed", value: String(failed) },
419
- { label: "Pending", value: String(pending) },
420
- ],
421
- });
422
- } else {
423
- blocks.push({
424
- type: "context",
425
- text: "Configure a webhook URL in settings to start sending events.",
426
- });
427
- }
428
-
429
- return { blocks };
430
- } catch (error) {
431
- ctx.log.error("Failed to build status widget", error);
432
- return { blocks: [{ type: "context", text: "Failed to load webhook status" }] };
433
- }
434
- }
435
-
436
- async function buildSettingsPage(ctx: PluginContext) {
437
- try {
438
- const webhookUrl = (await ctx.kv.get<string>("settings:webhookUrl")) ?? "";
439
- const enabled = (await ctx.kv.get<boolean>("settings:enabled")) ?? true;
440
- const includeData = (await ctx.kv.get<boolean>("settings:includeData")) ?? false;
441
- const events = (await ctx.kv.get<string>("settings:events")) ?? "all";
442
-
443
- const payloadPreview = JSON.stringify(
444
- {
445
- event: "content:create",
446
- timestamp: new Date().toISOString(),
447
- collection: "posts",
448
- resourceId: "abc123",
449
- resourceType: "content",
450
- ...(includeData && {
451
- data: { title: "Example Post", slug: "example-post" },
452
- }),
453
- metadata: { slug: "example-post", status: "published" },
454
- },
455
- null,
456
- 2,
457
- );
458
-
459
- return {
460
- blocks: [
461
- { type: "header", text: "Webhook Settings" },
462
- {
463
- type: "context",
464
- text: "Send notifications to external services when content changes.",
465
- },
466
- { type: "divider" },
467
- {
468
- type: "form",
469
- block_id: "webhook-settings",
470
- fields: [
471
- {
472
- type: "text_input",
473
- action_id: "webhookUrl",
474
- label: "Webhook URL",
475
- initial_value: webhookUrl,
476
- },
477
- {
478
- type: "secret_input",
479
- action_id: "secretToken",
480
- label: "Secret Token",
481
- },
482
- {
483
- type: "toggle",
484
- action_id: "enabled",
485
- label: "Enable Webhooks",
486
- initial_value: enabled,
487
- },
488
- {
489
- type: "select",
490
- action_id: "events",
491
- label: "Events to Send",
492
- options: [
493
- { label: "All events", value: "all" },
494
- { label: "Content changes only", value: "content" },
495
- { label: "Media uploads only", value: "media" },
496
- ],
497
- initial_value: events,
498
- },
499
- {
500
- type: "toggle",
501
- action_id: "includeData",
502
- label: "Include Content Data",
503
- initial_value: includeData,
504
- },
505
- ],
506
- submit: { label: "Save Settings", action_id: "save_settings" },
507
- },
508
- { type: "divider" },
509
- { type: "section", text: "**Payload Preview**" },
510
- { type: "code", code: payloadPreview, language: "json" },
511
- {
512
- type: "actions",
513
- elements: [
514
- {
515
- type: "button",
516
- text: "Test Webhook",
517
- action_id: "test_webhook",
518
- style: "primary",
519
- },
520
- ],
521
- },
522
- ],
523
- };
524
- } catch (error) {
525
- ctx.log.error("Failed to build settings page", error);
526
- return { blocks: [{ type: "context", text: "Failed to load settings" }] };
527
- }
528
- }
529
-
530
- async function saveSettings(ctx: PluginContext, values: Record<string, unknown>) {
531
- try {
532
- if (typeof values.webhookUrl === "string")
533
- await ctx.kv.set("settings:webhookUrl", values.webhookUrl);
534
- if (typeof values.secretToken === "string" && values.secretToken !== "")
535
- await ctx.kv.set("settings:secretToken", values.secretToken);
536
- if (typeof values.enabled === "boolean") await ctx.kv.set("settings:enabled", values.enabled);
537
- if (typeof values.events === "string") await ctx.kv.set("settings:events", values.events);
538
- if (typeof values.includeData === "boolean")
539
- await ctx.kv.set("settings:includeData", values.includeData);
540
-
541
- return {
542
- ...(await buildSettingsPage(ctx)),
543
- toast: { message: "Settings saved", type: "success" },
544
- };
545
- } catch (error) {
546
- ctx.log.error("Failed to save settings", error);
547
- return {
548
- blocks: [{ type: "banner", style: "error", text: "Failed to save settings" }],
549
- toast: { message: "Failed to save settings", type: "error" },
550
- };
551
- }
552
- }
553
-
554
- async function testWebhook(ctx: PluginContext) {
555
- const url = await ctx.kv.get<string>("settings:webhookUrl");
556
- if (!url) {
557
- return {
558
- blocks: [{ type: "banner", style: "warning", text: "Enter a webhook URL first." }],
559
- toast: { message: "No webhook URL configured", type: "error" },
560
- };
561
- }
562
-
563
- const token = await ctx.kv.get<string>("settings:secretToken");
564
- const testPayload: WebhookPayload = {
565
- event: "content:create",
566
- timestamp: new Date().toISOString(),
567
- resourceId: "test-" + Date.now(),
568
- resourceType: "content",
569
- metadata: { test: true, message: "Webhook test from EmDash CMS" },
570
- };
571
-
572
- try {
573
- const result = await sendWebhook(
574
- getFetchFn(ctx),
575
- ctx.log,
576
- url,
577
- testPayload,
578
- token ?? undefined,
579
- 1,
580
- );
581
-
582
- if (result.success) {
583
- return {
584
- ...(await buildSettingsPage(ctx)),
585
- toast: { message: `Test sent -- HTTP ${result.status}`, type: "success" },
586
- };
587
- }
588
- return {
589
- ...(await buildSettingsPage(ctx)),
590
- toast: {
591
- message: `Test failed: ${result.error ?? "Unknown error"}`,
592
- type: "error",
593
- },
594
- };
595
- } catch (error) {
596
- const msg = error instanceof Error ? error.message : String(error);
597
- return {
598
- ...(await buildSettingsPage(ctx)),
599
- toast: { message: `Test failed: ${msg}`, type: "error" },
600
- };
601
- }
602
- }