Public: anyone can read this; the crew can change it.

The API and MCP `hooks` pause and resume a webhook #10122

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/10104-the-api-and-mcp-hooks-pause-and-resume-a → main 4 commits fork 1 thread · 1 unresolved

  • Merged. mason/10104-the-api-and-mcp-hooks-pause-and-resume-a was merged into main as 83a6a10.

Review

Sign in to review this change.
7 files changed +192 −11
docs/features/webhook-pause.md +3 −3
@@ -1,7 +1,7 @@
11 # Pausing a webhook
22
3 A repository admin pauses and resumes a webhook from the terminal (#8082)
4 or from Settings › Webhooks (#10103).
3 A repository admin pauses and resumes a webhook from the terminal (#8082),
4 from Settings › Webhooks (#10103), the API and MCP (#10104).
55
66 - **Where.** The palette's **Webhooks** (`:` → `webhooks`) lists the
77 repository's hooks. `p` pauses the selected hook, or resumes it when it is
@@ -19,4 +19,4 @@ or from Settings › Webhooks (#10103).
1919 - **Code.** `pauseWebhook` in `src/actions/repos.ts`, called by the
2020 terminal and by `webhookPause` / `webhookResume` in `src/web/pages/hooks.ts`; `setWebhookActive` and
2121 the `w.active = 1` filters in `src/core/webhooks.ts`.
22 - **Not yet.** No MCP tool.
22 - **API and MCP.** `POST /api/v1/repos/:owner/:name/hooks/:id/pause` and `…/resume` (admin scope; 404 for anyone who does not administer the repository or a missing hook) answer `{ id, active }`; MCP `hooks` takes `action: "pause"` or `"resume"` with `id`, the same checks. Resuming queues one delivery of its own: the `webhook.resumed` event is recorded once the hook is active again, so a hook watching `*` receives it; a pause is not delivered, the hook being already inactive. (#10104)
src/contract/openapi/routes/index.ts +2
@@ -290,7 +290,9 @@ export { route as postApiV1ReposOwnerNameFollowingTake } from "./postApiV1ReposO
290290 export { route as postApiV1ReposOwnerNameForks } from "./postApiV1ReposOwnerNameForks.ts";
291291 export { route as postApiV1ReposOwnerNameHooks } from "./postApiV1ReposOwnerNameHooks.ts";
292292 export { route as postApiV1ReposOwnerNameHooksIdDeliveriesDeliveryRedeliver } from "./postApiV1ReposOwnerNameHooksIdDeliveriesDeliveryRedeliver.ts";
293 export { route as postApiV1ReposOwnerNameHooksIdPause } from "./postApiV1ReposOwnerNameHooksIdPause.ts";
293294 export { route as postApiV1ReposOwnerNameHooksIdPing } from "./postApiV1ReposOwnerNameHooksIdPing.ts";
295 export { route as postApiV1ReposOwnerNameHooksIdResume } from "./postApiV1ReposOwnerNameHooksIdResume.ts";
294296 export { route as postApiV1ReposOwnerNameIssues } from "./postApiV1ReposOwnerNameIssues.ts";
295297 export { route as postApiV1ReposOwnerNameIssuesNumberComments } from "./postApiV1ReposOwnerNameIssuesNumberComments.ts";
296298 export { route as postApiV1ReposOwnerNameIssuesNumberDependencies } from "./postApiV1ReposOwnerNameIssuesNumberDependencies.ts";
src/contract/openapi/routes/postApiV1ReposOwnerNameHooksIdPause.ts added +9
@@ -0,0 +1,9 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: `${REPO}/hooks/{id}/pause`,
6 summary:
7 "Pause this webhook: no new event is queued for it, and what is queued waits (admin scope); answers { id, active: false }, 404 unless you administer the repository and the hook is its own",
8 auth: true,
9 };
src/contract/openapi/routes/postApiV1ReposOwnerNameHooksIdResume.ts added +9
@@ -0,0 +1,9 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: `${REPO}/hooks/{id}/resume`,
6 summary:
7 "Resume a paused webhook: new events are queued for it again and what waited goes out (admin scope); answers { id, active: true }, 404 unless you administer the repository and the hook is its own",
8 auth: true,
9 };
src/http/api/hooks.ts +17 −4
@@ -1,12 +1,13 @@
11 /**
22 * A repository's webhooks over the API: `GET` and `POST`
33 * `/api/v1/repos/:owner/:name/hooks`, and `DELETE` `…/hooks/:id`;
4 * `GET …/hooks/:id/deliveries` answers the last twenty. Moved out of
5 * `api.ts` (#6333); the rules live in `actions/repos.ts`. Admin-scoped,
6 * since a webhook exfiltrates repo events.
4 * `GET …/hooks/:id/deliveries` answers the last twenty; `POST
5 * …/hooks/:id/pause` and `…/resume` stop and restart deliveries (#10104).
6 * Moved out of `api.ts` (#6333); the rules live in `actions/repos.ts`.
7 * Admin-scoped, since a webhook exfiltrates repo events.
78 */
89
9 import { createWebhook, deleteWebhook, redeliverWebhook } from "../../actions/repos.ts";
10 import { createWebhook, deleteWebhook, pauseWebhook, redeliverWebhook } from "../../actions/repos.ts";
1011 import { canAdmin } from "../../core/repos.ts";
1112 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1213 import { getWebhook, listWebhooks, pingWebhook, recentDeliveries } from "../../core/webhooks.ts";
@@ -71,6 +72,18 @@ export function hookRoutes(router: Router<Handler>): void {
7172 return json({ id: pinged.value.id }, 202);
7273 });
7374
75 const setActive = (pause: boolean): Handler => (ctx) => {
76 const denied = requireScope(ctx, "admin");
outdated

This turns a wrong-scope 403 into 404, which is a new behaviour with no test. Add one with a non-admin-scope token. Also wrap the long header comment line above.

77 // As for ping: a token short of admin learns nothing, the same 404.
78 if (denied) return denied.status === 403 ? notFound() : denied;
79 const repo = loadRepo(ctx);
80 if (!repo || !canAdmin(ctx.app.db, repo, ctx.auth.user)) return notFound();
81 const done = pauseWebhook(ctx.app, ctx.auth.user, repo, ctx.params.id!, pause);
82 if (!done.ok) return notFound();
83 return json({ id: ctx.params.id!, active: !pause });
84 };
85 router.add("POST", "/api/v1/repos/:owner/:name/hooks/:id/pause", setActive(true));
86 router.add("POST", "/api/v1/repos/:owner/:name/hooks/:id/resume", setActive(false));
7487 router.add("POST", "/api/v1/repos/:owner/:name/hooks/:id/deliveries/:delivery/redeliver", (ctx) => {
7588 const denied = requireScope(ctx, "admin");
7689 if (denied) return denied.status === 403 ? notFound() : denied;
src/http/mcp/tools/hooks.ts +12 −4
@@ -1,6 +1,6 @@
11 import { canAdmin } from "../../../core/repos.ts";
22 import { getWebhook, listWebhooks, pingWebhook, recentDeliveries } from "../../../core/webhooks.ts";
3 import { createWebhook, deleteWebhook, redeliverWebhook } from "../../../actions/repos.ts";
3 import { createWebhook, deleteWebhook, pauseWebhook, redeliverWebhook } from "../../../actions/repos.ts";
44 import { pageRows } from "../page.ts";
55 import { good, bad, type Answer, type Args, type Tool, REPO_ARG, repoArg, isAnswer, str } from "../shared.ts";
66
@@ -20,13 +20,13 @@ const notAdmin = (): Answer => bad("not_found", "no such app or repository, or y
2020
2121 export const tool: Tool = {
2222 name: "hooks",
23 doc: "`{ repo, action, url, secret, kinds, id }`: `list` (pages: `limit` 1–200, `cursor`; `{ items, next_cursor }`), `add` (`secret`, 16–200 characters, is required) or `remove` the repository's webhooks, as `…/hooks`; `deliveries` with `id` answers that hook's last 20, newest first (`{ items: [{ id, state, attempts, lastStatus, lastError, kind }] }`); `ping` with `id` queues one `ping` delivery (`{ id }`), at most 3 a minute per hook; `redeliver` with `id` and `delivery` queues a failed delivery again (`{ id, state }`; 409-like `conflict` if not failed or sealed); an admin-scope token on a repository you administer, and a secret is never shown",
23 doc: "`{ repo, action, url, secret, kinds, id }`: `list` (pages: `limit` 1–200, `cursor`; `{ items, next_cursor }`), `add` (`secret`, 16–200 characters, is required) or `remove` the repository's webhooks, as `…/hooks`; `deliveries` with `id` answers that hook's last 20, newest first (`{ items: [{ id, state, attempts, lastStatus, lastError, kind }] }`); `ping` with `id` queues one `ping` delivery (`{ id }`), at most 3 a minute per hook; `redeliver` with `id` and `delivery` queues a failed delivery again (`{ id, state }`; 409-like `conflict` if not failed or sealed); `pause` or `resume` with `id` stops or restarts a hook's deliveries (`{ id, active }`); an admin-scope token on a repository you administer, and a secret is never shown",
2424 description: "List, add (`url`, `secret`, `kinds`) or remove (`id`) webhooks; admins.",
2525 inputSchema: {
2626 type: "object",
2727 properties: {
2828 repo: REPO_ARG,
29 action: { type: "string", enum: ["list", "add", "remove", "deliveries", "ping", "redeliver"] },
29 action: { type: "string", enum: ["list", "add", "remove", "deliveries", "ping", "pause", "resume", "redeliver"] },
3030 url: { type: "string", maxLength: 2048 },
3131 kinds: { type: "string", maxLength: 200 },
3232 secret: { type: "string", maxLength: 200 },
@@ -64,6 +64,14 @@ export const tool: Tool = {
6464 const pinged = pingWebhook(ctx.app.db, `${repo.ownerName}/${repo.name}`, hook);
6565 return pinged.ok ? good({ id: pinged.value.id }) : bad("rate_limited", `too many pings: try again in ${pinged.error.retryAfter} s`);
6666 }
67 if (action === "pause" || action === "resume") {
68 const long = hookTooLong(args);
69 if (long !== undefined) return long;
70 if (!canAdmin(ctx.app.db, repo, user)) return notAdmin();
71 const id = str(args["id"]);
72 const done = pauseWebhook(ctx.app, user, repo, id, action === "pause");
73 return done.ok ? good({ id, active: action === "resume" }) : bad("not_found", "no such webhook here");
74 }
6775 if (action === "redeliver") {
6876 const long = hookTooLong(args);
6977 if (long !== undefined) return long;
@@ -97,6 +105,6 @@ export const tool: Tool = {
97105 const removed = deleteWebhook(ctx.app, user, repo, str(args["id"]));
98106 return removed.ok ? good({}) : bad("not_found", "no such webhook here");
99107 }
100 return bad("invalid", "action is list, add, remove, deliveries, ping or redeliver");
108 return bad("invalid", "action is list, add, remove, deliveries, ping, pause, resume or redeliver");
101109 },
102110 };
test/hook-pause-api.test.ts added +140
@@ -0,0 +1,140 @@
1 /** POST …/hooks/:id/pause and /resume, and MCP `hooks` pause and resume (#10104). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser, type User } from "../src/core/users.ts";
10 import { createWebhook, getWebhook, recentDeliveries } from "../src/core/webhooks.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 let repoId = "";
20 let hook = "";
21 let adminTok = "";
22 let otherTok = "";
23 let aliceId = "";
24 let aliceUser: User;
25 const cells = process.env["FORGE_CELLS"];
26
27 beforeAll(async () => {
28 process.env["FORGE_CELLS"] = "off";
29 dataDir = await mkdtemp(join(tmpdir(), "forge-hook-pause-api-"));
30 app = createApp({ dataDir, port: 0 });
31 servers = await serve(app);
32 const mk = async (username: string, first: boolean) => {
33 const made = await createUser(app.db, {
34 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
35 password: "a-good-passphrase", adminIfFirst: first,
36 });
37 if (!made.ok) throw new Error(String(made.error));
38 return made.value;
39 };
40 const alice = await mk("alice", true);
41 const bob = await mk("bob", false);
42 aliceId = alice.id;
43 aliceUser = alice;
44 const repo = await createRepo(app, alice, { name: "a", visibility: "public" });
45 if (!repo.ok) throw new Error("repo");
46 repoId = repo.value.id;
47 const made = createWebhook(app.db, { repoId, url: "https://a.example/h" });
48 if (!made.ok) throw new Error("hook");
49 hook = made.value.webhook.id;
50 adminTok = createToken(app.db, alice.id, "t", "admin").token;
51 otherTok = createToken(app.db, bob.id, "o", "admin").token;
52 });
53 afterAll(async () => {
54 await servers.stop();
55 app.close();
56 if (cells === undefined) delete process.env["FORGE_CELLS"];
57 else process.env["FORGE_CELLS"] = cells;
58 await rm(dataDir, { recursive: true, force: true });
59 });
60
61 const api = (verb: string, id: string, tok: string) =>
62 fetch(`${servers.url}/api/v1/repos/alice/a/hooks/${id}/${verb}`, { method: "POST", headers: { authorization: `Bearer ${tok}` } });
63 const mcp = async (args: unknown, tok = adminTok) => {
64 const res = await fetch(`${servers.url}/mcp`, {
65 method: "POST",
66 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${tok}` },
67 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "hooks", arguments: args } }),
68 });
69 const body = (await res.json()) as any;
70 if (body.error) return { error: body.error.message as string };
71 const text: string = body.result.content[0].text;
72 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
73 };
74 const emit = () => app.bus.append({ kind: "tag", repoId, actorId: null, payload: {} } as any);
75 const queued = () => recentDeliveries(app.db, hook).length;
76 const active = () => !!getWebhook(app.db, repoId, hook)!.active;
77
78 test("the API pauses: nothing new is queued; resume queues again", async () => {
79 emit();
80 expect(queued()).toBe(1);
81 const paused = await api("pause", hook, adminTok);
82 expect(paused.status).toBe(200);
83 expect(await paused.json()).toEqual({ id: hook, active: false });
84 expect(active()).toBe(false);
85 emit();
86 expect(queued()).toBe(1);
87 const resumed = await api("resume", hook, adminTok);
88 expect(await resumed.json()).toEqual({ id: hook, active: true });
89 // Resume queues one delivery on its own: the `webhook.resumed` event is
90 // appended after the hook is active again, so a hook watching `*` gets it.
91 expect(recentDeliveries(app.db, hook).map((d) => d.kind)).toContain("webhook.resumed");
92 const before = queued();
93 emit();
94 expect(queued()).toBe(before + 1);
95 });
96
97 test("MCP pauses and resumes with the same effect", async () => {
98 expect((await mcp({ repo: "alice/a", action: "pause", id: hook })).value).toEqual({ id: hook, active: false });
99 expect(active()).toBe(false);
100 const before = queued();
101 emit();
102 expect(queued()).toBe(before);
103 expect((await mcp({ repo: "alice/a", action: "resume", id: hook })).value).toEqual({ id: hook, active: true });
104 const resumedAt = queued();
105 emit();
106 expect(queued()).toBe(resumedAt + 1);
107 });
108
109 test("a token short of admin scope gets 404 and changes nothing, even the owner's", async () => {
110 const writeTok = createToken(app.db, aliceId, "w", "rw").token;
111 const readTok = createToken(app.db, aliceId, "r", "ro").token;
112 for (const tok of [writeTok, readTok]) expect((await api("pause", hook, tok)).status).toBe(404);
113 expect(active()).toBe(true);
114 expect((await mcp({ repo: "alice/a", action: "pause", id: hook }, writeTok)).error).toBeTruthy();
115 expect(active()).toBe(true);
116 await api("pause", hook, adminTok);
117 expect((await api("resume", hook, writeTok)).status).toBe(404);
118 expect(active()).toBe(false);
119 await api("resume", hook, adminTok);
120 });
121
122 test("another repository's hook id is 404 on the API and MCP and stays active", async () => {
123 const theirs = await createRepo(app, aliceUser, { name: "b", visibility: "public" });
124 if (!theirs.ok) throw new Error("repo b " + JSON.stringify(theirs.error));
125 const made = createWebhook(app.db, { repoId: theirs.value.id, url: "https://b.example/h" });
126 if (!made.ok) throw new Error("hook b");
127 const foreign = made.value.webhook.id;
128 expect((await api("pause", foreign, adminTok)).status).toBe(404);
129 expect((await mcp({ repo: "alice/a", action: "pause", id: foreign })).error).toContain("no such webhook");
130 expect(!!getWebhook(app.db, theirs.value.id, foreign)!.active).toBe(true);
131 });
132
133 test("a non-admin, a missing hook and no credentials get 404 / 401, and nothing changes", async () => {
134 expect((await api("pause", hook, otherTok)).status).toBe(404);
135 expect((await api("pause", "nope", adminTok)).status).toBe(404);
136 expect((await fetch(`${servers.url}/api/v1/repos/alice/a/hooks/${hook}/pause`, { method: "POST" })).status).toBe(401);
137 expect((await mcp({ repo: "alice/a", action: "pause", id: "nope" })).error).toContain("no such webhook");
138 expect((await mcp({ repo: "alice/a", action: "pause", id: hook }, otherTok)).error).toBeTruthy();
139 expect(active()).toBe(true);
140 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs