Skip to content

Commit a0215b7

Browse files
committed
[release] Add webhook CRUD + signature rotation MCP tools
Wraps node-appwrite's Webhooks service. Adds list_webhooks / get_webhook / create_webhook / update_webhook / delete_webhook / rotate_webhook_signature to the projects tool group. Works on both Appwrite Cloud and self-hosted. Security: httpPass is always redacted; signatureKey is redacted except on create_webhook and rotate_webhook_signature responses, where the freshly-minted key is surfaced once so the caller can copy it for downstream verification. updateWebhook reads the current webhook to backfill name/url/events when the patch omits any of them — the SDK requires all three on PATCH, so this preserves true partial-update semantics from the caller's view. Includes WebhookManager helper (mirrors ProjectsManager / FunctionManager pattern: p-limit concurrency split, tryAwaitWithRetry on every SDK call) and unit tests for both the redaction chokepoint and the manager's partial-update baseline-fetch logic.
1 parent 30e38fa commit a0215b7

5 files changed

Lines changed: 923 additions & 8 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from "./projectsManager.js";
2+
export * from "./webhookManager.js";
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { Client, Webhooks, ID, type Models } from "node-appwrite";
2+
import pLimit from "p-limit";
3+
import { tryAwaitWithRetry } from "../utils/helperFunctions.js";
4+
5+
// Mirror ProjectsManager / FunctionManager concurrency split. Webhook writes
6+
// are infrequent and rate-limited server-side; reads happen during MCP audits
7+
// where a tighter parallelism budget is acceptable.
8+
const webhookWriteLimit = pLimit(5);
9+
const webhookReadLimit = pLimit(25);
10+
11+
export interface CreateWebhookOptions {
12+
/** Custom webhook ID. Auto-generated via `ID.unique()` when omitted. */
13+
webhookId?: string;
14+
/** Webhook enabled flag. Default: true (Appwrite default). */
15+
enabled?: boolean;
16+
/** SSL/TLS certificate verification for the webhook URL. Default: true. */
17+
security?: boolean;
18+
/** Optional HTTP basic-auth username. Max 256 chars. */
19+
httpUser?: string;
20+
/** Optional HTTP basic-auth password. Max 256 chars. */
21+
httpPass?: string;
22+
}
23+
24+
export interface UpdateWebhookPatch {
25+
/** New webhook name. Max 128 chars. */
26+
name?: string;
27+
/** New webhook URL. */
28+
url?: string;
29+
/** New events list. Max 100. */
30+
events?: string[];
31+
/** Enabled flag. */
32+
enabled?: boolean;
33+
/** SSL/TLS certificate verification. */
34+
security?: boolean;
35+
/** HTTP basic-auth username. */
36+
httpUser?: string;
37+
/** HTTP basic-auth password. */
38+
httpPass?: string;
39+
}
40+
41+
/**
42+
* Manager wrapping the node-appwrite `Webhooks` service for project-level
43+
* webhook configuration. Webhooks deliver Appwrite events (database row
44+
* mutations, function invocations, auth events, etc.) to an external URL with
45+
* an HMAC-signed payload.
46+
*
47+
* Available on both Appwrite Cloud and self-hosted — webhooks are a core
48+
* project feature, not a cloud-only add-on like Backups.
49+
*
50+
* Concurrency: 25 for reads, 5 for writes. All SDK calls are wrapped in
51+
* `tryAwaitWithRetry` for transient-error resilience.
52+
*
53+
* The `Webhooks` SDK service operates on the project bound to the client
54+
* (set via `client.setProject(projectId)` upstream). The `projectId` arg on
55+
* each method here is a documentation/sanity-check device, mirroring the
56+
* `ProjectsManager` pattern.
57+
*/
58+
export class WebhookManager {
59+
private client: Client;
60+
private webhooks: Webhooks;
61+
62+
constructor(client: Client) {
63+
this.client = client;
64+
this.webhooks = new Webhooks(client);
65+
}
66+
67+
/**
68+
* List webhooks configured for the bound project. Supports Appwrite Query
69+
* filters on: name, url, httpUser, security, events, enabled, logs, attempts.
70+
*/
71+
public async listWebhooks(
72+
projectId: string,
73+
queries?: string[]
74+
): Promise<Models.WebhookList> {
75+
void projectId;
76+
return await webhookReadLimit(() =>
77+
tryAwaitWithRetry(async () => {
78+
const params: { queries?: string[] } = {};
79+
if (queries && queries.length > 0) params.queries = queries;
80+
return await this.webhooks.list(params);
81+
})
82+
);
83+
}
84+
85+
/**
86+
* Get a single webhook by ID.
87+
*/
88+
public async getWebhook(
89+
projectId: string,
90+
webhookId: string
91+
): Promise<Models.Webhook> {
92+
void projectId;
93+
return await webhookReadLimit(() =>
94+
tryAwaitWithRetry(async () =>
95+
await this.webhooks.get({ webhookId })
96+
)
97+
);
98+
}
99+
100+
/**
101+
* Create a new webhook. If `options.webhookId` is omitted, a unique ID is
102+
* generated via `ID.unique()`. Response includes the freshly-minted
103+
* `signatureKey` — copy it now if you need to verify signatures, the
104+
* unredacted key only flows back here and on `rotateSignature`.
105+
*/
106+
public async createWebhook(
107+
projectId: string,
108+
name: string,
109+
url: string,
110+
events: string[],
111+
options: CreateWebhookOptions = {}
112+
): Promise<Models.Webhook> {
113+
void projectId;
114+
const webhookId = options.webhookId ?? ID.unique();
115+
116+
return await webhookWriteLimit(() =>
117+
tryAwaitWithRetry(async () => {
118+
const params: {
119+
webhookId: string;
120+
url: string;
121+
name: string;
122+
events: string[];
123+
enabled?: boolean;
124+
security?: boolean;
125+
httpUser?: string;
126+
httpPass?: string;
127+
} = { webhookId, url, name, events };
128+
if (options.enabled !== undefined) params.enabled = options.enabled;
129+
if (options.security !== undefined) params.security = options.security;
130+
if (options.httpUser !== undefined) params.httpUser = options.httpUser;
131+
if (options.httpPass !== undefined) params.httpPass = options.httpPass;
132+
return await this.webhooks.create(params);
133+
})
134+
);
135+
}
136+
137+
/**
138+
* Update an existing webhook. The SDK requires `name`, `url`, and `events`
139+
* on every update — when a patch omits any of these, the current value is
140+
* read first and re-sent so the call remains a true partial update from
141+
* the caller's perspective. This mirrors how the official Appwrite console
142+
* sends webhook PATCHes.
143+
*/
144+
public async updateWebhook(
145+
projectId: string,
146+
webhookId: string,
147+
patch: UpdateWebhookPatch
148+
): Promise<Models.Webhook> {
149+
void projectId;
150+
return await webhookWriteLimit(() =>
151+
tryAwaitWithRetry(async () => {
152+
// Fetch the current shape only when the patch is incomplete — saves a
153+
// round-trip for full-shape updates.
154+
const needsBaseline =
155+
patch.name === undefined ||
156+
patch.url === undefined ||
157+
patch.events === undefined;
158+
const baseline = needsBaseline
159+
? await this.webhooks.get({ webhookId })
160+
: null;
161+
162+
const params: {
163+
webhookId: string;
164+
name: string;
165+
url: string;
166+
events: string[];
167+
enabled?: boolean;
168+
security?: boolean;
169+
httpUser?: string;
170+
httpPass?: string;
171+
} = {
172+
webhookId,
173+
name: patch.name ?? baseline!.name,
174+
url: patch.url ?? baseline!.url,
175+
events: patch.events ?? baseline!.events,
176+
};
177+
if (patch.enabled !== undefined) params.enabled = patch.enabled;
178+
if (patch.security !== undefined) params.security = patch.security;
179+
if (patch.httpUser !== undefined) params.httpUser = patch.httpUser;
180+
if (patch.httpPass !== undefined) params.httpPass = patch.httpPass;
181+
return await this.webhooks.update(params);
182+
})
183+
);
184+
}
185+
186+
/**
187+
* Delete a webhook by ID. The Appwrite project stops emitting events to
188+
* the URL immediately.
189+
*/
190+
public async deleteWebhook(
191+
projectId: string,
192+
webhookId: string
193+
): Promise<void> {
194+
void projectId;
195+
await webhookWriteLimit(() =>
196+
tryAwaitWithRetry(async () => {
197+
await this.webhooks.delete({ webhookId });
198+
})
199+
);
200+
}
201+
202+
/**
203+
* Rotate the webhook signature key. The new key is returned in the
204+
* response (`signatureKey` field) — copy it immediately and update any
205+
* downstream signature verification, the unredacted key only flows back
206+
* here and on the initial `createWebhook` call.
207+
*/
208+
public async rotateSignature(
209+
projectId: string,
210+
webhookId: string
211+
): Promise<Models.Webhook> {
212+
void projectId;
213+
return await webhookWriteLimit(() =>
214+
tryAwaitWithRetry(async () =>
215+
await this.webhooks.updateSignature({ webhookId })
216+
)
217+
);
218+
}
219+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { Client, type Models } from "node-appwrite";
3+
4+
import { WebhookManager } from "../src/projects/webhookManager.js";
5+
6+
/**
7+
* Build a WebhookManager backed by a fake `Webhooks` SDK stub. We construct
8+
* the real manager (so concurrency limiters + retry wrapping are exercised)
9+
* then replace its private `.webhooks` slot with a stub that records calls.
10+
*/
11+
function buildManagerWithStub() {
12+
const client = new Client();
13+
const manager = new WebhookManager(client);
14+
15+
const calls: Array<{ method: string; args: unknown }> = [];
16+
let nextGetResult: Partial<Models.Webhook> | null = null;
17+
18+
const stub = {
19+
async list(params: { queries?: string[] }): Promise<Models.WebhookList> {
20+
calls.push({ method: "list", args: params });
21+
return { total: 0, webhooks: [] };
22+
},
23+
async get(params: { webhookId: string }): Promise<Models.Webhook> {
24+
calls.push({ method: "get", args: params });
25+
return {
26+
$id: params.webhookId,
27+
$createdAt: "now",
28+
$updatedAt: "now",
29+
name: nextGetResult?.name ?? "Baseline name",
30+
url: nextGetResult?.url ?? "https://baseline.test/hook",
31+
events: nextGetResult?.events ?? ["baseline.event"],
32+
security: nextGetResult?.security ?? true,
33+
httpUser: nextGetResult?.httpUser ?? "",
34+
httpPass: nextGetResult?.httpPass ?? "",
35+
signatureKey: nextGetResult?.signatureKey ?? "sk-baseline",
36+
enabled: nextGetResult?.enabled ?? true,
37+
logs: "",
38+
attempts: 0,
39+
} as Models.Webhook;
40+
},
41+
async create(params: unknown): Promise<Models.Webhook> {
42+
calls.push({ method: "create", args: params });
43+
return { $id: "wh-new" } as Models.Webhook;
44+
},
45+
async update(params: unknown): Promise<Models.Webhook> {
46+
calls.push({ method: "update", args: params });
47+
return { $id: "wh-updated" } as Models.Webhook;
48+
},
49+
async delete(params: unknown): Promise<Record<string, never>> {
50+
calls.push({ method: "delete", args: params });
51+
return {};
52+
},
53+
async updateSignature(params: unknown): Promise<Models.Webhook> {
54+
calls.push({ method: "updateSignature", args: params });
55+
return { $id: "wh-rotated", signatureKey: "sk-rotated" } as Models.Webhook;
56+
},
57+
};
58+
59+
(manager as unknown as { webhooks: typeof stub }).webhooks = stub;
60+
61+
return {
62+
manager,
63+
calls,
64+
setNextGetResult(w: Partial<Models.Webhook> | null) {
65+
nextGetResult = w;
66+
},
67+
};
68+
}
69+
70+
describe("WebhookManager", () => {
71+
test("listWebhooks omits empty queries from the SDK params", async () => {
72+
const { manager, calls } = buildManagerWithStub();
73+
await manager.listWebhooks("proj-1");
74+
expect(calls[0]?.args).toEqual({});
75+
});
76+
77+
test("listWebhooks forwards non-empty queries", async () => {
78+
const { manager, calls } = buildManagerWithStub();
79+
await manager.listWebhooks("proj-1", ['equal("enabled",true)']);
80+
expect(calls[0]?.args).toEqual({ queries: ['equal("enabled",true)'] });
81+
});
82+
83+
test("createWebhook generates a unique webhookId when not provided", async () => {
84+
const { manager, calls } = buildManagerWithStub();
85+
await manager.createWebhook("proj-1", "n", "https://a.test/h", ["x.y"]);
86+
const args = calls[0]?.args as { webhookId?: string };
87+
expect(typeof args.webhookId).toBe("string");
88+
expect(args.webhookId!.length).toBeGreaterThan(0);
89+
});
90+
91+
test("createWebhook honors an explicit webhookId", async () => {
92+
const { manager, calls } = buildManagerWithStub();
93+
await manager.createWebhook("proj-1", "n", "https://a.test/h", ["x.y"], {
94+
webhookId: "my-id",
95+
});
96+
expect((calls[0]?.args as { webhookId: string }).webhookId).toBe("my-id");
97+
});
98+
99+
test("createWebhook only forwards options that were actually set", async () => {
100+
const { manager, calls } = buildManagerWithStub();
101+
await manager.createWebhook("proj-1", "n", "https://a.test/h", ["x.y"], {
102+
enabled: true,
103+
});
104+
const args = calls[0]?.args as Record<string, unknown>;
105+
expect(args.enabled).toBe(true);
106+
expect("security" in args).toBe(false);
107+
expect("httpUser" in args).toBe(false);
108+
expect("httpPass" in args).toBe(false);
109+
});
110+
111+
test("updateWebhook with a partial patch first reads baseline then sends a full PUT", async () => {
112+
// The SDK requires name/url/events on every PATCH; the manager must
113+
// backfill from a fresh get() when the caller omits any of them.
114+
const { manager, calls, setNextGetResult } = buildManagerWithStub();
115+
setNextGetResult({
116+
name: "Old name",
117+
url: "https://old.test/hook",
118+
events: ["a.b", "c.d"],
119+
});
120+
await manager.updateWebhook("proj-1", "wh-1", { enabled: false });
121+
122+
expect(calls[0]?.method).toBe("get");
123+
expect(calls[1]?.method).toBe("update");
124+
const upd = calls[1]?.args as {
125+
name: string;
126+
url: string;
127+
events: string[];
128+
enabled: boolean;
129+
};
130+
expect(upd.name).toBe("Old name");
131+
expect(upd.url).toBe("https://old.test/hook");
132+
expect(upd.events).toEqual(["a.b", "c.d"]);
133+
expect(upd.enabled).toBe(false);
134+
});
135+
136+
test("updateWebhook with a full patch skips the baseline GET", async () => {
137+
const { manager, calls } = buildManagerWithStub();
138+
await manager.updateWebhook("proj-1", "wh-1", {
139+
name: "New",
140+
url: "https://new.test/hook",
141+
events: ["fresh.event"],
142+
enabled: true,
143+
});
144+
expect(calls.map((c) => c.method)).toEqual(["update"]);
145+
});
146+
147+
test("rotateSignature returns the new signatureKey for the caller to copy", async () => {
148+
const { manager } = buildManagerWithStub();
149+
const res = await manager.rotateSignature("proj-1", "wh-1");
150+
expect(res.signatureKey).toBe("sk-rotated");
151+
});
152+
153+
test("deleteWebhook returns void after a successful call", async () => {
154+
const { manager, calls } = buildManagerWithStub();
155+
await manager.deleteWebhook("proj-1", "wh-1");
156+
expect(calls[0]?.method).toBe("delete");
157+
expect((calls[0]?.args as { webhookId: string }).webhookId).toBe("wh-1");
158+
});
159+
});

0 commit comments

Comments
 (0)