Public: anyone can read this; the crew can change it.
The API and MCP `hooks` pause and resume a webhook #10122
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10104-the-api-and-mcp-hooks-pause-and-resume-a → main 4 commits fork 1 thread · 1 unresolved
7 files
changed +192
−11
docs/features/webhook-pause.md +3 −3
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | # Pausing a webhook |
| 2 | 2 | |
| 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). | |
| 5 | 5 | |
| 6 | 6 | - **Where.** The palette's **Webhooks** (`:` → `webhooks`) lists the |
| 7 | 7 | repository's hooks. `p` pauses the selected hook, or resumes it when it is |
| @@ -19,4 +19,4 @@ or from Settings › Webhooks (#10103). | ||
| 19 | 19 | - **Code.** `pauseWebhook` in `src/actions/repos.ts`, called by the |
| 20 | 20 | terminal and by `webhookPause` / `webhookResume` in `src/web/pages/hooks.ts`; `setWebhookActive` and |
| 21 | 21 | 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 | ||
| 290 | 290 | export { route as postApiV1ReposOwnerNameForks } from "./postApiV1ReposOwnerNameForks.ts"; |
| 291 | 291 | export { route as postApiV1ReposOwnerNameHooks } from "./postApiV1ReposOwnerNameHooks.ts"; |
| 292 | 292 | export { route as postApiV1ReposOwnerNameHooksIdDeliveriesDeliveryRedeliver } from "./postApiV1ReposOwnerNameHooksIdDeliveriesDeliveryRedeliver.ts"; |
| 293 | export { route as postApiV1ReposOwnerNameHooksIdPause } from "./postApiV1ReposOwnerNameHooksIdPause.ts"; | |
| 293 | 294 | export { route as postApiV1ReposOwnerNameHooksIdPing } from "./postApiV1ReposOwnerNameHooksIdPing.ts"; |
| 295 | export { route as postApiV1ReposOwnerNameHooksIdResume } from "./postApiV1ReposOwnerNameHooksIdResume.ts"; | |
| 294 | 296 | export { route as postApiV1ReposOwnerNameIssues } from "./postApiV1ReposOwnerNameIssues.ts"; |
| 295 | 297 | export { route as postApiV1ReposOwnerNameIssuesNumberComments } from "./postApiV1ReposOwnerNameIssuesNumberComments.ts"; |
| 296 | 298 | 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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * A repository's webhooks over the API: `GET` and `POST` |
| 3 | 3 | * `/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. | |
| 7 | 8 | */ |
| 8 | 9 | |
| 9 | import { createWebhook, deleteWebhook, redeliverWebhook } from "../../actions/repos.ts"; | |
| 10 | import { createWebhook, deleteWebhook, pauseWebhook, redeliverWebhook } from "../../actions/repos.ts"; | |
| 10 | 11 | import { canAdmin } from "../../core/repos.ts"; |
| 11 | 12 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 12 | 13 | import { getWebhook, listWebhooks, pingWebhook, recentDeliveries } from "../../core/webhooks.ts"; |
| @@ -71,6 +72,18 @@ export function hookRoutes(router: Router<Handler>): void { | ||
| 71 | 72 | return json({ id: pinged.value.id }, 202); |
| 72 | 73 | }); |
| 73 | 74 | |
| 75 | const setActive = (pause: boolean): Handler => (ctx) => { | |
| 76 | const denied = requireScope(ctx, "admin"); | |
| 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)); | |
| 74 | 87 | router.add("POST", "/api/v1/repos/:owner/:name/hooks/:id/deliveries/:delivery/redeliver", (ctx) => { |
| 75 | 88 | const denied = requireScope(ctx, "admin"); |
| 76 | 89 | if (denied) return denied.status === 403 ? notFound() : denied; |
src/http/mcp/tools/hooks.ts +12 −4
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { canAdmin } from "../../../core/repos.ts"; |
| 2 | 2 | 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"; | |
| 4 | 4 | import { pageRows } from "../page.ts"; |
| 5 | 5 | import { good, bad, type Answer, type Args, type Tool, REPO_ARG, repoArg, isAnswer, str } from "../shared.ts"; |
| 6 | 6 | |
| @@ -20,13 +20,13 @@ const notAdmin = (): Answer => bad("not_found", "no such app or repository, or y | ||
| 20 | 20 | |
| 21 | 21 | export const tool: Tool = { |
| 22 | 22 | 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", | |
| 24 | 24 | description: "List, add (`url`, `secret`, `kinds`) or remove (`id`) webhooks; admins.", |
| 25 | 25 | inputSchema: { |
| 26 | 26 | type: "object", |
| 27 | 27 | properties: { |
| 28 | 28 | 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"] }, | |
| 30 | 30 | url: { type: "string", maxLength: 2048 }, |
| 31 | 31 | kinds: { type: "string", maxLength: 200 }, |
| 32 | 32 | secret: { type: "string", maxLength: 200 }, |
| @@ -64,6 +64,14 @@ export const tool: Tool = { | ||
| 64 | 64 | const pinged = pingWebhook(ctx.app.db, `${repo.ownerName}/${repo.name}`, hook); |
| 65 | 65 | return pinged.ok ? good({ id: pinged.value.id }) : bad("rate_limited", `too many pings: try again in ${pinged.error.retryAfter} s`); |
| 66 | 66 | } |
| 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 | } | |
| 67 | 75 | if (action === "redeliver") { |
| 68 | 76 | const long = hookTooLong(args); |
| 69 | 77 | if (long !== undefined) return long; |
| @@ -97,6 +105,6 @@ export const tool: Tool = { | ||
| 97 | 105 | const removed = deleteWebhook(ctx.app, user, repo, str(args["id"])); |
| 98 | 106 | return removed.ok ? good({}) : bad("not_found", "no such webhook here"); |
| 99 | 107 | } |
| 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"); | |
| 101 | 109 | }, |
| 102 | 110 | }; |
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 | }); |
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.