chore: import upstream snapshot with attribution
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:23:53 +08:00
commit b3a7f98e5a
3020 changed files with 935484 additions and 0 deletions
@@ -0,0 +1,80 @@
# @emdash-cms/plugin-webhook-notifier
## 0.2.0
### Minor Changes
- [#1057](https://github.com/emdash-cms/emdash/pull/1057) [`c0ce915`](https://github.com/emdash-cms/emdash/commit/c0ce915c555b8658245d465255e2ec89b361c57f) Thanks [@ascorbic](https://github.com/ascorbic)! - **BREAKING:** Removes the `webhookNotifierPlugin` named export and the factory call shape. Import the default export and pass it directly into `plugins:` or `sandboxed:`.
```diff
- import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier";
+ import webhookNotifier from "@emdash-cms/plugin-webhook-notifier";
export default defineConfig({
integrations: [
emdash({
- sandboxed: [webhookNotifierPlugin()],
+ sandboxed: [webhookNotifier],
}),
],
});
```
Two changes: drop the `{ }` around the import, and drop the `()` after the plugin name. Per-install configuration moved to the admin UI's settings (KV-backed) when the sandboxed plugin redesign landed, so there's no longer a need for a factory call.
### Patch Changes
- Updated dependencies [[`6e62b90`](https://github.com/emdash-cms/emdash/commit/6e62b90e14615a2012a5885849e6b1d1062e7c0b), [`c0ce915`](https://github.com/emdash-cms/emdash/commit/c0ce915c555b8658245d465255e2ec89b361c57f), [`23597d0`](https://github.com/emdash-cms/emdash/commit/23597d017360673cf95eee8e5d24c873137fc215), [`883b75b`](https://github.com/emdash-cms/emdash/commit/883b75b992854a4e339d3896bbd73bec36180b9b), [`05440b1`](https://github.com/emdash-cms/emdash/commit/05440b11ef5df609ad7f800143fa96019da22101), [`94fb50b`](https://github.com/emdash-cms/emdash/commit/94fb50b0338d21037a6623de7f350a1621b1b811), [`0d5843f`](https://github.com/emdash-cms/emdash/commit/0d5843fc3378936667ab81c56001349198028ebb), [`0cd8c6d`](https://github.com/emdash-cms/emdash/commit/0cd8c6d4e0f0dc126d66f953afcfdc3d6201d00b), [`878a0b6`](https://github.com/emdash-cms/emdash/commit/878a0b689b9475e501f809d81d0fe494a040bfe4), [`121f173`](https://github.com/emdash-cms/emdash/commit/121f1735f06520468d1532efd9f9fba88ff5d295), [`f4a9711`](https://github.com/emdash-cms/emdash/commit/f4a9711d7e715b6f71129bf60665113052a52d60), [`dbaea9c`](https://github.com/emdash-cms/emdash/commit/dbaea9ccaef6ac48dda14b77c6b2adbe0dc0ff38), [`5681eb2`](https://github.com/emdash-cms/emdash/commit/5681eb2e43fbe57c535e5f828c1c8eba06b3eb89), [`ed917d9`](https://github.com/emdash-cms/emdash/commit/ed917d9d534751241dafb9126fd0beddbd5ed593), [`6e62b90`](https://github.com/emdash-cms/emdash/commit/6e62b90e14615a2012a5885849e6b1d1062e7c0b)]:
- emdash@0.13.0
## 0.1.3
### Patch Changes
- [#918](https://github.com/emdash-cms/emdash/pull/918) [`1e0cb76`](https://github.com/emdash-cms/emdash/commit/1e0cb76899ce442fbc99f498fd2b57cb254c7c8d) Thanks [@ascorbic](https://github.com/ascorbic)! - Updates declared capabilities to the current names (`content:read`, `content:write`, `media:read`, `media:write`, `network:request`, `network:request:unrestricted`) instead of the deprecated aliases. Plugin descriptors now report the package's own version instead of a stale hard-coded literal.
- Updated dependencies [[`a2d3658`](https://github.com/emdash-cms/emdash/commit/a2d3658e510f292bf1fbe6b0a9e8e4f02ebc1e03), [`c8a3a2c`](https://github.com/emdash-cms/emdash/commit/c8a3a2cce6bfdcdc6521556bcc507f88bd79ba31), [`699e1b3`](https://github.com/emdash-cms/emdash/commit/699e1b3d208a5ef4bca5dc3a40a39291e484f060), [`71f4e7d`](https://github.com/emdash-cms/emdash/commit/71f4e7d85b2568dbadd9dc6ff26160789cb24e47), [`7e32092`](https://github.com/emdash-cms/emdash/commit/7e32092596149ae2886bae34c8d2f4bad86dbe2f), [`2e2b8e9`](https://github.com/emdash-cms/emdash/commit/2e2b8e90c099f3422808f0e1da9c83a9ec533b64), [`9146931`](https://github.com/emdash-cms/emdash/commit/91469312df211304d51576c9aef621148707b6d3)]:
- emdash@0.10.0
## 0.1.2
### Patch Changes
- [#463](https://github.com/emdash-cms/emdash/pull/463) [`701d0ca`](https://github.com/emdash-cms/emdash/commit/701d0caee88c16e072c5c34b189bc1df3cb34fd0) Thanks [@sakibmd](https://github.com/sakibmd)! - fix: replace code block with section in webhook notifier payload preview
- Updated dependencies [[`156ba73`](https://github.com/emdash-cms/emdash/commit/156ba7350070400e5877e3a54d33486cd0d33640), [`80a895b`](https://github.com/emdash-cms/emdash/commit/80a895b1def1bf8794f56e151e5ad7675225fae4), [`da957ce`](https://github.com/emdash-cms/emdash/commit/da957ce8ec18953995e6e00e0a38e5d830f1a381), [`fcd8b7b`](https://github.com/emdash-cms/emdash/commit/fcd8b7bebbd4342de6ca1d782a3ae4d42d1be913), [`8ac15a4`](https://github.com/emdash-cms/emdash/commit/8ac15a4ee450552f763d3c6d9d097941c57b8300), [`ba2b020`](https://github.com/emdash-cms/emdash/commit/ba2b0204d274cf1bbf89f724a99797660733203c), [`0b108cf`](https://github.com/emdash-cms/emdash/commit/0b108cf6286e5b41c134bbeca8a6cc834756b190), [`1989e8b`](https://github.com/emdash-cms/emdash/commit/1989e8b4c432a05d022baf2196dec2680b2e2fd0), [`e190324`](https://github.com/emdash-cms/emdash/commit/e1903248e0fccb1b34d0620b33e4f06eccdfe2a6), [`724191c`](https://github.com/emdash-cms/emdash/commit/724191cf96d5d79b22528a167de8c45146fb0746), [`ed28089`](https://github.com/emdash-cms/emdash/commit/ed28089bd296e1633ea048c7ca667cb5341f6aa6), [`a293708`](https://github.com/emdash-cms/emdash/commit/a2937083f8f74e32ad1b0383d9f22b20e18d7237), [`c75cc5b`](https://github.com/emdash-cms/emdash/commit/c75cc5b82cb678c5678859b249d545e12be6fd97), [`6ebb797`](https://github.com/emdash-cms/emdash/commit/6ebb7975be00a4d756cdb56955c88395840e3fec), [`d421ee2`](https://github.com/emdash-cms/emdash/commit/d421ee2cedfe48748148912ac7766fd841757dd6), [`391caf4`](https://github.com/emdash-cms/emdash/commit/391caf4a0f404f323b97c5d7f54f4a4d96aef349), [`6474dae`](https://github.com/emdash-cms/emdash/commit/6474daee29b6d0be289c995755658755d93316b1), [`30c9a96`](https://github.com/emdash-cms/emdash/commit/30c9a96404e913ea8b3039ef4a5bc70541647eec), [`122c236`](https://github.com/emdash-cms/emdash/commit/122c2364fc4cfc9082f036f9affcee13d9b00511), [`5320321`](https://github.com/emdash-cms/emdash/commit/5320321f5ee1c1f456b1c8c054f2d0232be58ecd), [`8f44ec2`](https://github.com/emdash-cms/emdash/commit/8f44ec23a4b23f636f9689c075d29edfa4962c7c), [`b712ae3`](https://github.com/emdash-cms/emdash/commit/b712ae3e5d8aec45e4d7a0f20f273795f7122715), [`9cb5a28`](https://github.com/emdash-cms/emdash/commit/9cb5a28001cc8e6d650ec6b45c9ea091a4e9e3c2), [`7ee7d95`](https://github.com/emdash-cms/emdash/commit/7ee7d95ee32df2b1915144030569382fe97aef3d), [`e1014ef`](https://github.com/emdash-cms/emdash/commit/e1014eff18301ff68ac75d19157d3500ebe890c5), [`4d4ac53`](https://github.com/emdash-cms/emdash/commit/4d4ac536eeb664b7d0ca9f1895a51960a47ecafe), [`476cb3a`](https://github.com/emdash-cms/emdash/commit/476cb3a585d30acb2d4d172f94c5d2b4e5b6377b), [`87b0439`](https://github.com/emdash-cms/emdash/commit/87b0439927454a275833992de4244678b47b9aa3), [`dd708b1`](https://github.com/emdash-cms/emdash/commit/dd708b1c0c35d43761f89a87cba74b3c0ecb777e), [`befaeec`](https://github.com/emdash-cms/emdash/commit/befaeecfefd968d14693e96e3cdaa691ffabe7d3), [`c92e7e6`](https://github.com/emdash-cms/emdash/commit/c92e7e6907a575d134a69ebbeed531b99569d599), [`2ba1f1f`](https://github.com/emdash-cms/emdash/commit/2ba1f1f8d1ff773889f980af35391187e3705f17), [`a13c4ec`](https://github.com/emdash-cms/emdash/commit/a13c4ec6e362abecdae62abe64b1aebebc06aaae), [`a5e0603`](https://github.com/emdash-cms/emdash/commit/a5e0603b1910481d042f5a22dd19a60c76da7197)]:
- emdash@0.2.0
## 0.1.1
### Patch Changes
- [#363](https://github.com/emdash-cms/emdash/pull/363) [`91e31fb`](https://github.com/emdash-cms/emdash/commit/91e31fb2cab4c0470088c5d61bab6e2028821569) Thanks [@ascorbic](https://github.com/ascorbic)! - Fixes sandboxed plugin entries failing when package exports point to unbuilt TypeScript source. Adds build-time and bundle-time validation to catch misconfigured plugin exports early.
- Updated dependencies [[`422018a`](https://github.com/emdash-cms/emdash/commit/422018aeb227dffe3da7bfc772d86f9ce9c2bcd1), [`4221ba4`](https://github.com/emdash-cms/emdash/commit/4221ba48bc87ab9fa0b1bae144f6f2920beb4f5a), [`9269759`](https://github.com/emdash-cms/emdash/commit/9269759674bf254863f37d4cf1687fae56082063), [`d6cfc43`](https://github.com/emdash-cms/emdash/commit/d6cfc437f23e3e435a8862cab17d2c19363847d7), [`1bcfc50`](https://github.com/emdash-cms/emdash/commit/1bcfc502112d8756e34a720b8a170eb5486b425a), [`8c693b5`](https://github.com/emdash-cms/emdash/commit/8c693b582d7c5e29bd138161e81d9c8affb53689), [`5b3e33c`](https://github.com/emdash-cms/emdash/commit/5b3e33c26bc2eb30ab2a032960a5d57eb06f148a), [`9d10d27`](https://github.com/emdash-cms/emdash/commit/9d10d2791fe16be901d9d138e434bd79cf9335c4), [`91e31fb`](https://github.com/emdash-cms/emdash/commit/91e31fb2cab4c0470088c5d61bab6e2028821569), [`f112ac4`](https://github.com/emdash-cms/emdash/commit/f112ac48194d1c2302e93756d54b116d3d207c22), [`e9a6f7a`](https://github.com/emdash-cms/emdash/commit/e9a6f7ac3ceeaf5c2d0a557e4cf6cab5f3d7d764), [`b297fdd`](https://github.com/emdash-cms/emdash/commit/b297fdd88dadcabeb93f47abea9f24f70b7d4b71), [`d211452`](https://github.com/emdash-cms/emdash/commit/d2114523a55021f65ee46e44e11157b06334819e), [`8e28cfc`](https://github.com/emdash-cms/emdash/commit/8e28cfc5d66f58f0fb91aa35c02afdd426bb6555), [`38af118`](https://github.com/emdash-cms/emdash/commit/38af118ad517fd9aa83064368543bf64bc32c08a)]:
- emdash@0.1.1
## 0.1.0
### Minor Changes
- [#14](https://github.com/emdash-cms/emdash/pull/14) [`755b501`](https://github.com/emdash-cms/emdash/commit/755b5017906811f97f78f4c0b5a0b62e67b52ec4) Thanks [@ascorbic](https://github.com/ascorbic)! - First beta release
### Patch Changes
- Updated dependencies [[`755b501`](https://github.com/emdash-cms/emdash/commit/755b5017906811f97f78f4c0b5a0b62e67b52ec4)]:
- emdash@0.1.0
## 0.0.3
### Patch Changes
- Updated dependencies [[`3c319ed`](https://github.com/emdash-cms/emdash/commit/3c319ed6411a595e6974a86bc58c2a308b91c214)]:
- emdash@0.0.3
## 0.0.2
### Patch Changes
- Updated dependencies [[`b09bfd5`](https://github.com/emdash-cms/emdash/commit/b09bfd51cece5e88fe8314668a591ab11de36b4d)]:
- emdash@0.0.2
@@ -0,0 +1,26 @@
{
"$schema": "../../plugin-cli/schemas/emdash-plugin.schema.json",
"slug": "webhook-notifier",
"publisher": "did:plc:xyraubanwc5fwemkduw3upi6", // plugins.emdashcms.com
"license": "MIT",
"author": { "name": "Matt Kane" },
"security": { "url": "https://github.com/emdash-cms/emdash/security/advisories/new" },
"description": "Posts to user-configured external URLs when content or media changes.",
// Trust contract. Outbound HTTP is unrestricted because the
// webhook URLs are user-supplied at runtime — there's no fixed
// allow-list we could pin at publish time. Declares the
// `deliveries` storage collection for audit + retry state.
"capabilities": ["network:request:unrestricted"],
"allowedHosts": [],
"storage": {
"deliveries": { "indexes": ["timestamp", "webhookUrl", "status"] },
},
"admin": {
"pages": [{ "path": "/settings", "label": "Webhook Settings", "icon": "send" }],
"widgets": [{ "id": "status", "title": "Webhooks", "size": "third" }],
},
}
@@ -0,0 +1,46 @@
{
"name": "@emdash-cms/plugin-webhook-notifier",
"version": "0.2.0",
"description": "Webhook notification plugin for EmDash CMS - posts to external URLs on content changes",
"type": "module",
"main": "dist/index.mjs",
"exports": {
".": {
"import": "./dist/index.mjs",
"types": "./dist/index.d.mts"
},
"./sandbox": "./dist/plugin.mjs"
},
"files": [
"dist",
"emdash-plugin.jsonc"
],
"keywords": [
"emdash",
"cms",
"plugin",
"webhook",
"notifications",
"integration"
],
"author": "Matt Kane",
"license": "MIT",
"peerDependencies": {
"emdash": "workspace:>=0.15.0"
},
"devDependencies": {
"@emdash-cms/plugin-cli": "workspace:*",
"tsdown": "catalog:",
"typescript": "catalog:"
},
"scripts": {
"build": "node node_modules/@emdash-cms/plugin-cli/dist/index.mjs build",
"dev": "emdash-plugin dev",
"typecheck": "tsgo --noEmit"
},
"repository": {
"type": "git",
"url": "git+https://github.com/emdash-cms/emdash.git",
"directory": "packages/plugins/webhook-notifier"
}
}
@@ -0,0 +1,583 @@
/**
* Sandbox Entry Point -- Webhook Notifier
*
* Canonical plugin implementation using the standard format.
* Runs in both trusted (in-process) and sandboxed (isolate) modes.
*/
import type { PluginContext, SandboxedPlugin } from "emdash/plugin";
interface WebhookPayload {
event: string;
timestamp: string;
collection?: string;
resourceId: string;
resourceType: "content" | "media";
data?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}
// ── SSRF protection ──
const IPV6_BRACKET_PATTERN = /^\[|\]$/g;
const BLOCKED_HOSTNAMES = new Set(["localhost", "metadata.google.internal", "[::1]"]);
const PRIVATE_RANGES = [
{ start: (127 << 24) >>> 0, end: ((127 << 24) | 0x00ffffff) >>> 0 },
{ start: (10 << 24) >>> 0, end: ((10 << 24) | 0x00ffffff) >>> 0 },
{
start: ((172 << 24) | (16 << 16)) >>> 0,
end: ((172 << 24) | (31 << 16) | 0xffff) >>> 0,
},
{
start: ((192 << 24) | (168 << 16)) >>> 0,
end: ((192 << 24) | (168 << 16) | 0xffff) >>> 0,
},
{
start: ((169 << 24) | (254 << 16)) >>> 0,
end: ((169 << 24) | (254 << 16) | 0xffff) >>> 0,
},
{ start: 0, end: 0x00ffffff },
];
function validateWebhookUrl(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error("Invalid webhook URL");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`Webhook URL scheme '${parsed.protocol}' is not allowed`);
}
const hostname = parsed.hostname.replace(IPV6_BRACKET_PATTERN, "");
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new Error("Webhook URLs targeting internal hosts are not allowed");
}
const parts = hostname.split(".");
if (parts.length === 4) {
const nums = parts.map(Number);
if (nums.every((n) => !isNaN(n) && n >= 0 && n <= 255)) {
const ip = ((nums[0]! << 24) | (nums[1]! << 16) | (nums[2]! << 8) | nums[3]!) >>> 0;
if (PRIVATE_RANGES.some((r) => ip >= r.start && ip <= r.end)) {
throw new Error("Webhook URLs targeting private IP addresses are not allowed");
}
}
}
if (
hostname === "::1" ||
hostname.startsWith("fe80:") ||
hostname.startsWith("fc") ||
hostname.startsWith("fd")
) {
throw new Error("Webhook URLs targeting internal addresses are not allowed");
}
}
// ── Webhook delivery ──
type FetchFn = (url: string, init?: RequestInit) => Promise<Response>;
type LogFn = PluginContext["log"];
async function sendWebhook(
fetchFn: FetchFn,
log: LogFn,
url: string,
payload: WebhookPayload,
token: string | undefined,
maxRetries: number,
): Promise<{ success: boolean; status?: number; error?: string }> {
validateWebhookUrl(url);
let lastError: string | undefined;
let lastStatus: number | undefined;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-EmDash-Event": payload.event,
};
if (token) headers["Authorization"] = `Bearer ${token}`;
const response = await fetchFn(url, {
method: "POST",
headers,
body: JSON.stringify(payload),
});
lastStatus = response.status;
if (response.ok) {
log.info(`Delivered ${payload.event} to ${url} (${response.status})`);
return { success: true, status: response.status };
}
lastError = `HTTP ${response.status}: ${response.statusText}`;
log.warn(`Attempt ${attempt}/${maxRetries} failed: ${lastError}`);
} catch (error) {
lastError = error instanceof Error ? error.message : "Unknown error";
log.warn(`Attempt ${attempt}/${maxRetries} failed: ${lastError}`);
}
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, 100 * Math.pow(2, attempt - 1)));
}
}
log.error(`Failed to deliver ${payload.event} after ${maxRetries} attempts`);
return { success: false, status: lastStatus, error: lastError };
}
// ── Helpers ──
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function getString(value: unknown, key: string): string | undefined {
if (!isRecord(value)) return undefined;
const v = value[key];
return typeof v === "string" ? v : undefined;
}
const MAX_RETRIES = 3;
async function getConfig(ctx: PluginContext) {
const url = await ctx.kv.get<string>("settings:webhookUrl");
const token = await ctx.kv.get<string>("settings:secretToken");
const enabled = await ctx.kv.get<boolean>("settings:enabled");
return { url, token, enabled };
}
function getFetchFn(ctx: PluginContext): FetchFn {
if (!ctx.http) {
throw new Error("Webhook notifier requires network:request capability");
}
return ctx.http.fetch;
}
// ── Plugin definition ──
export default {
hooks: {
"content:afterSave": {
priority: 210,
timeout: 10000,
dependencies: ["audit-log"],
errorPolicy: "continue",
handler: async (event, ctx) => {
const { url, token, enabled } = await getConfig(ctx);
if (enabled === false || !url) return;
const contentId =
typeof event.content.id === "string" ? event.content.id : String(event.content.id);
const payload: WebhookPayload = {
event: event.isNew ? "content:create" : "content:update",
timestamp: new Date().toISOString(),
collection: event.collection,
resourceId: contentId,
resourceType: "content",
metadata: {
slug: event.content.slug,
status: event.content.status,
},
};
await sendWebhook(getFetchFn(ctx), ctx.log, url, payload, token ?? undefined, MAX_RETRIES);
},
},
"content:afterDelete": {
priority: 210,
timeout: 10000,
dependencies: ["audit-log"],
errorPolicy: "continue",
handler: async (event, ctx) => {
const { url, token, enabled } = await getConfig(ctx);
if (enabled === false || !url) return;
const payload: WebhookPayload = {
event: "content:delete",
timestamp: new Date().toISOString(),
collection: event.collection,
resourceId: event.id,
resourceType: "content",
};
await sendWebhook(getFetchFn(ctx), ctx.log, url, payload, token ?? undefined, MAX_RETRIES);
},
},
"media:afterUpload": {
priority: 210,
timeout: 10000,
errorPolicy: "continue",
handler: async (event, ctx) => {
const { url, token, enabled } = await getConfig(ctx);
if (enabled === false || !url) return;
const payload: WebhookPayload = {
event: "media:upload",
timestamp: new Date().toISOString(),
resourceId: event.media.id,
resourceType: "media",
};
await sendWebhook(getFetchFn(ctx), ctx.log, url, payload, token ?? undefined, MAX_RETRIES);
},
},
},
routes: {
admin: {
handler: async (routeCtx, ctx) => {
const interaction = routeCtx.input as {
type: string;
page?: string;
action_id?: string;
value?: string;
values?: Record<string, unknown>;
};
if (interaction.type === "page_load" && interaction.page === "widget:webhook-status") {
return buildStatusWidget(ctx);
}
if (interaction.type === "page_load" && interaction.page === "/settings") {
return buildSettingsPage(ctx);
}
if (interaction.type === "form_submit" && interaction.action_id === "save_settings") {
return saveSettings(ctx, interaction.values ?? {});
}
if (interaction.type === "block_action" && interaction.action_id === "test_webhook") {
return testWebhook(ctx);
}
return { blocks: [] };
},
},
status: {
handler: async (_routeCtx, ctx) => {
try {
const url = await ctx.kv.get<string>("settings:webhookUrl");
const enabled = await ctx.kv.get<boolean>("settings:enabled");
const deliveries = ctx.storage.deliveries!;
const successful = await deliveries.count({ status: "success" });
const failed = await deliveries.count({ status: "failed" });
const pending = await deliveries.count({ status: "pending" });
return {
configured: !!url,
enabled: enabled ?? true,
stats: { successful, failed, pending },
};
} catch (error) {
ctx.log.error("Failed to get status", error);
return {
configured: false,
enabled: true,
stats: { successful: 0, failed: 0, pending: 0 },
};
}
},
},
settings: {
handler: async (_routeCtx, ctx) => {
try {
const settings = await ctx.kv.list("settings:");
const map: Record<string, unknown> = {};
for (const entry of settings) {
map[entry.key.replace("settings:", "")] = entry.value;
}
return {
webhookUrl: typeof map.webhookUrl === "string" ? map.webhookUrl : "",
enabled: typeof map.enabled === "boolean" ? map.enabled : true,
includeData: typeof map.includeData === "boolean" ? map.includeData : false,
events: typeof map.events === "string" ? map.events : "all",
};
} catch (error) {
ctx.log.error("Failed to get settings", error);
return { webhookUrl: "", enabled: true, includeData: false, events: "all" };
}
},
},
"settings/save": {
handler: async (routeCtx, ctx) => {
try {
const input = isRecord(routeCtx.input) ? routeCtx.input : {};
if (typeof input.webhookUrl === "string")
await ctx.kv.set("settings:webhookUrl", input.webhookUrl);
if (typeof input.enabled === "boolean")
await ctx.kv.set("settings:enabled", input.enabled);
if (typeof input.includeData === "boolean")
await ctx.kv.set("settings:includeData", input.includeData);
if (typeof input.events === "string") await ctx.kv.set("settings:events", input.events);
return { success: true };
} catch (error) {
ctx.log.error("Failed to save settings", error);
return { success: false, error: String(error) };
}
},
},
test: {
handler: async (routeCtx, ctx) => {
const testUrl = getString(routeCtx.input, "url");
if (!testUrl) return { success: false, error: "No webhook URL provided" };
const token = await ctx.kv.get<string>("settings:secretToken");
const testPayload: WebhookPayload = {
event: "content:create",
timestamp: new Date().toISOString(),
resourceId: "test-" + Date.now(),
resourceType: "content",
metadata: { test: true, message: "Webhook test from EmDash CMS" },
};
const result = await sendWebhook(
getFetchFn(ctx),
ctx.log,
testUrl,
testPayload,
token ?? undefined,
1,
);
return {
success: result.success,
status: result.status,
error: result.error,
payload: testPayload,
};
},
},
},
} satisfies SandboxedPlugin;
// ── Block Kit admin helpers ──
async function buildStatusWidget(ctx: PluginContext) {
try {
const url = await ctx.kv.get<string>("settings:webhookUrl");
const enabled = await ctx.kv.get<boolean>("settings:enabled");
const isConfigured = !!url && enabled !== false;
let successful = 0;
let failed = 0;
let pending = 0;
try {
const deliveries = ctx.storage.deliveries!;
successful = await deliveries.count({ status: "success" });
failed = await deliveries.count({ status: "failed" });
pending = await deliveries.count({ status: "pending" });
} catch {
// Storage not available yet
}
const blocks: unknown[] = [
{
type: "fields",
fields: [
{
label: "Status",
value: isConfigured ? "Active" : "Not Configured",
},
{
label: "Endpoint",
value: url ? url : "None",
},
],
},
];
if (isConfigured) {
blocks.push({
type: "stats",
stats: [
{ label: "Delivered", value: String(successful) },
{ label: "Failed", value: String(failed) },
{ label: "Pending", value: String(pending) },
],
});
} else {
blocks.push({
type: "context",
text: "Configure a webhook URL in settings to start sending events.",
});
}
return { blocks };
} catch (error) {
ctx.log.error("Failed to build status widget", error);
return { blocks: [{ type: "context", text: "Failed to load webhook status" }] };
}
}
async function buildSettingsPage(ctx: PluginContext) {
try {
const webhookUrl = (await ctx.kv.get<string>("settings:webhookUrl")) ?? "";
const enabled = (await ctx.kv.get<boolean>("settings:enabled")) ?? true;
const includeData = (await ctx.kv.get<boolean>("settings:includeData")) ?? false;
const events = (await ctx.kv.get<string>("settings:events")) ?? "all";
const payloadPreview = JSON.stringify(
{
event: "content:create",
timestamp: new Date().toISOString(),
collection: "posts",
resourceId: "abc123",
resourceType: "content",
...(includeData && {
data: { title: "Example Post", slug: "example-post" },
}),
metadata: { slug: "example-post", status: "published" },
},
null,
2,
);
return {
blocks: [
{ type: "header", text: "Webhook Settings" },
{
type: "context",
text: "Send notifications to external services when content changes.",
},
{ type: "divider" },
{
type: "form",
block_id: "webhook-settings",
fields: [
{
type: "text_input",
action_id: "webhookUrl",
label: "Webhook URL",
initial_value: webhookUrl,
},
{
type: "secret_input",
action_id: "secretToken",
label: "Secret Token",
},
{
type: "toggle",
action_id: "enabled",
label: "Enable Webhooks",
initial_value: enabled,
},
{
type: "select",
action_id: "events",
label: "Events to Send",
options: [
{ label: "All events", value: "all" },
{ label: "Content changes only", value: "content" },
{ label: "Media uploads only", value: "media" },
],
initial_value: events,
},
{
type: "toggle",
action_id: "includeData",
label: "Include Content Data",
initial_value: includeData,
},
],
submit: { label: "Save Settings", action_id: "save_settings" },
},
{ type: "divider" },
{ type: "section", text: "**Payload Preview**" },
{ type: "section", text: "```json\n" + payloadPreview + "\n```" },
{
type: "actions",
elements: [
{
type: "button",
text: "Test Webhook",
action_id: "test_webhook",
style: "primary",
},
],
},
],
};
} catch (error) {
ctx.log.error("Failed to build settings page", error);
return { blocks: [{ type: "context", text: "Failed to load settings" }] };
}
}
async function saveSettings(ctx: PluginContext, values: Record<string, unknown>) {
try {
if (typeof values.webhookUrl === "string")
await ctx.kv.set("settings:webhookUrl", values.webhookUrl);
if (typeof values.secretToken === "string" && values.secretToken !== "")
await ctx.kv.set("settings:secretToken", values.secretToken);
if (typeof values.enabled === "boolean") await ctx.kv.set("settings:enabled", values.enabled);
if (typeof values.events === "string") await ctx.kv.set("settings:events", values.events);
if (typeof values.includeData === "boolean")
await ctx.kv.set("settings:includeData", values.includeData);
return {
...(await buildSettingsPage(ctx)),
toast: { message: "Settings saved", type: "success" },
};
} catch (error) {
ctx.log.error("Failed to save settings", error);
return {
blocks: [{ type: "banner", style: "error", text: "Failed to save settings" }],
toast: { message: "Failed to save settings", type: "error" },
};
}
}
async function testWebhook(ctx: PluginContext) {
const url = await ctx.kv.get<string>("settings:webhookUrl");
if (!url) {
return {
blocks: [{ type: "banner", style: "warning", text: "Enter a webhook URL first." }],
toast: { message: "No webhook URL configured", type: "error" },
};
}
const token = await ctx.kv.get<string>("settings:secretToken");
const testPayload: WebhookPayload = {
event: "content:create",
timestamp: new Date().toISOString(),
resourceId: "test-" + Date.now(),
resourceType: "content",
metadata: { test: true, message: "Webhook test from EmDash CMS" },
};
try {
const result = await sendWebhook(
getFetchFn(ctx),
ctx.log,
url,
testPayload,
token ?? undefined,
1,
);
if (result.success) {
return {
...(await buildSettingsPage(ctx)),
toast: { message: `Test sent -- HTTP ${result.status}`, type: "success" },
};
}
return {
...(await buildSettingsPage(ctx)),
toast: {
message: `Test failed: ${result.error ?? "Unknown error"}`,
type: "error",
},
};
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return {
...(await buildSettingsPage(ctx)),
toast: { message: `Test failed: ${msg}`, type: "error" },
};
}
}
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}