Public: anyone can read this; the crew can change it.
Settings › Webhooks pauses and resumes a hook with one button, as the terminal's `p` does #10115
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10103-settings-webhooks-pauses-and-resumes-a-h → main 2 commits fork
4 files
changed +171
−9
docs/features/webhook-pause.md +9 −4
| @@ -1,17 +1,22 @@ | ||
| 1 | 1 | # Pausing a webhook |
| 2 | 2 | |
| 3 | A repository admin pauses and resumes a webhook from the terminal (#8082). | |
| 3 | A repository admin pauses and resumes a webhook from the terminal (#8082) | |
| 4 | or from Settings › Webhooks (#10103). | |
| 4 | 5 | |
| 5 | 6 | - **Where.** The palette's **Webhooks** (`:` → `webhooks`) lists the |
| 6 | 7 | repository's hooks. `p` pauses the selected hook, or resumes it when it is |
| 7 | 8 | paused. Anyone who does not administer the repository is not offered the |
| 8 | screen. | |
| 9 | screen. On the web, each row of `/:owner/:name/settings/hooks` has | |
| 10 | **Pause**, or **Resume** when paused: a same-origin POST to | |
| 11 | `…/settings/hooks/:id/pause` or `…/resume`. Anyone who does not | |
| 12 | administer the repository gets 404, from the page and from the post. | |
| 9 | 13 | - **What paused means.** The row reads `paused` in place of its last delivery. |
| 10 | 14 | A paused hook gets no deliveries: new events are not queued for it, and |
| 11 | 15 | deliveries already queued (including ones waiting to retry) stay `pending` |
| 12 | 16 | until it is resumed, then go out. Events that came while it was paused are |
| 13 | 17 | not backfilled. |
| 14 | 18 | - **Trail.** Each change is a `webhook.paused` / `webhook.resumed` event. |
| 15 | - **Code.** `pauseWebhook` in `src/actions/repos.ts`; `setWebhookActive` and | |
| 19 | - **Code.** `pauseWebhook` in `src/actions/repos.ts`, called by the | |
| 20 | terminal and by `webhookPause` / `webhookResume` in `src/web/pages/hooks.ts`; `setWebhookActive` and | |
| 16 | 21 | the `w.active = 1` filters in `src/core/webhooks.ts`. |
| 17 | - **Not yet.** No button on the web settings page and no MCP tool. | |
| 22 | - **Not yet.** No MCP tool. |
src/http/server.ts +11 −1
| @@ -384,7 +384,15 @@ import { | ||
| 384 | 384 | } from "../web/pages/settings.ts"; |
| 385 | 385 | import { dangerPage, repoDelete } from "../web/pages/danger.ts"; |
| 386 | 386 | import { detachSubmit } from "../web/pages/detach.ts"; |
| 387 | import { hooksPage, webhookCreate, webhookDelete, webhookPing, webhookRedeliver } from "../web/pages/hooks.ts"; | |
| 387 | import { | |
| 388 | hooksPage, | |
| 389 | webhookCreate, | |
| 390 | webhookDelete, | |
| 391 | webhookPause, | |
| 392 | webhookPing, | |
| 393 | webhookRedeliver, | |
| 394 | webhookResume, | |
| 395 | } from "../web/pages/hooks.ts"; | |
| 388 | 396 | import { |
| 389 | 397 | crewAdd, |
| 390 | 398 | crewRemove, |
| @@ -1502,6 +1510,8 @@ export function buildRouter(): Router<Handler> { | ||
| 1502 | 1510 | router.add("POST", "/:owner/:name/settings/hooks/:id/delete", webhookDelete); |
| 1503 | 1511 | router.add("POST", "/:owner/:name/settings/hooks/:id/deliveries/:delivery/redeliver", webhookRedeliver); |
| 1504 | 1512 | router.add("POST", "/:owner/:name/settings/hooks/:id/ping", webhookPing); |
| 1513 | router.add("POST", "/:owner/:name/settings/hooks/:id/pause", webhookPause); | |
| 1514 | router.add("POST", "/:owner/:name/settings/hooks/:id/resume", webhookResume); | |
| 1505 | 1515 | router.add("GET", "/:owner/:name/settings/danger", dangerPage); |
| 1506 | 1516 | router.add("POST", "/:owner/:name/settings/delete", repoDelete); |
| 1507 | 1517 | router.add("POST", "/:owner/:name/settings/detach", detachSubmit); |
src/web/pages/hooks.ts +32 −4
| @@ -2,10 +2,12 @@ | ||
| 2 | 2 | * Settings › Webhooks, admin-only: the hooks that carry this repository's |
| 3 | 3 | * events out, the last deliveries of each, and the form that adds one. A |
| 4 | 4 | * hook's signing secret is shown once, in the flash after it is created, |
| 5 | * and never again. | |
| 5 | * and never again. Pause quiets a failing hook where it is seen, as the | |
| 6 | * terminal's `p` does (`pauseWebhook`); a paused row reads `paused` in | |
| 7 | * place of its last delivery. | |
| 6 | 8 | */ |
| 7 | 9 | |
| 8 | import { createWebhook, deleteWebhook } from "../../actions/repos.ts"; | |
| 10 | import { createWebhook, deleteWebhook, pauseWebhook } from "../../actions/repos.ts"; | |
| 9 | 11 | import { notePhase, timePhase } from "../../core/route_timing.ts"; |
| 10 | 12 | import { getWebhook, listWebhooks, pingWebhook, recentDeliveries, redeliver } from "../../core/webhooks.ts"; |
| 11 | 13 | import type { Ctx } from "../../http/context.ts"; |
| @@ -62,10 +64,13 @@ export function hooksPage(ctx: Ctx): Response { | ||
| 62 | 64 | meta: html`<span class="mono">${hook.kinds}</span> · ${dot(hook.active ? "success" : "attn")}${hook.active |
| 63 | 65 | ? "active" |
| 64 | 66 | : "paused"} · added ${time(hook.createdAt)}${when(!hook.hasSecret, () => html` · unsigned`)}${when( |
| 65 | deliveries.length === 0, | |
| 67 | hook.active && deliveries.length === 0, | |
| 66 | 68 | () => html` · No deliveries yet.`, |
| 67 | 69 | )}`, |
| 68 | trail: html`<form method="post" action="${base}/settings/hooks/${hook.id}/ping"> | |
| 70 | trail: html`<form method="post" action="${base}/settings/hooks/${hook.id}/${hook.active ? "pause" : "resume"}"> | |
| 71 | ${btn(hook.active ? "Pause" : "Resume", { size: "sm" })} | |
| 72 | </form> | |
| 73 | <form method="post" action="${base}/settings/hooks/${hook.id}/ping"> | |
| 69 | 74 | ${btn("Send ping", { size: "sm" })} |
| 70 | 75 | </form> |
| 71 | 76 | ${confirmAction({ |
| @@ -192,3 +197,26 @@ export async function webhookPing(ctx: Ctx): Promise<Response> { | ||
| 192 | 197 | : { tone: "danger", message: `Too many pings: try again in ${pinged.error.retryAfter} s.` }, |
| 193 | 198 | ); |
| 194 | 199 | } |
| 200 | ||
| 201 | /** | |
| 202 | * POST …/settings/hooks/:id/pause and …/resume: one route each, so a | |
| 203 | * repeated post leaves the hook as it asked, never toggled back. | |
| 204 | */ | |
| 205 | function webhookSetPaused(paused: boolean) { | |
| 206 | return async (ctx: Ctx): Promise<Response> => { | |
| 207 | const gate = await adminForm(ctx); | |
| 208 | if (gate instanceof Response) return gate; | |
| 209 | const { repo } = gate; | |
| 210 | const done = pauseWebhook(ctx.app, ctx.auth.user, repo, ctx.params.id ?? "", paused); | |
| 211 | return flashTo( | |
| 212 | ctx, | |
| 213 | `${repoBase(repo)}/settings/hooks`, | |
| 214 | done.ok | |
| 215 | ? { message: paused ? "Webhook paused: it gets no deliveries until resumed." : "Webhook resumed." } | |
| 216 | : { tone: "danger", message: "That webhook no longer exists." }, | |
| 217 | ); | |
| 218 | }; | |
| 219 | } | |
| 220 | ||
| 221 | export const webhookPause = webhookSetPaused(true); | |
| 222 | export const webhookResume = webhookSetPaused(false); |
test/web-hook-pause.test.ts added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | /** Settings › Webhooks pauses and resumes a hook with one button (#10103). */ | |
| 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 { createSession } from "../src/core/sessions.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { createWebhook, getWebhook } 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 admin = ""; | |
| 22 | let reader = ""; | |
| 23 | const cells = process.env["FORGE_CELLS"]; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | process.env["FORGE_CELLS"] = "off"; | |
| 27 | dataDir = await mkdtemp(join(tmpdir(), "forge-web-hook-pause-")); | |
| 28 | app = createApp({ dataDir, port: 0 }); | |
| 29 | servers = await serve(app); | |
| 30 | const mk = async (username: string, first: boolean) => { | |
| 31 | const made = await createUser(app.db, { | |
| 32 | origin: "seed", | |
| 33 | trusted: true, | |
| 34 | username, | |
| 35 | email: `${username}@forge.local`, | |
| 36 | password: "a-good-passphrase", | |
| 37 | adminIfFirst: first, | |
| 38 | }); | |
| 39 | if (!made.ok) throw new Error(String(made.error)); | |
| 40 | return made.value; | |
| 41 | }; | |
| 42 | const alice = await mk("alice", true); | |
| 43 | const bob = await mk("bob", false); | |
| 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 | admin = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 51 | reader = `forge_session=${createSession(app.db, bob.id).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 page = (cookie: string) => fetch(`${servers.url}/alice/a/settings/hooks`, { headers: { cookie } }); | |
| 62 | const post = (verb: "pause" | "resume", cookie: string, origin = servers.url) => | |
| 63 | fetch(`${servers.url}/alice/a/settings/hooks/${hook}/${verb}`, { | |
| 64 | method: "POST", | |
| 65 | redirect: "manual", | |
| 66 | headers: { cookie, origin }, | |
| 67 | }); | |
| 68 | const active = () => getWebhook(app.db, repoId, hook)!.active; | |
| 69 | const events = (kind: string) => | |
| 70 | app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM event WHERE kind = ?1").get(kind)!.n; | |
| 71 | ||
| 72 | test("an active hook's row offers Pause; posting it pauses the hook and the row reads paused with Resume", async () => { | |
| 73 | const before = await (await page(admin)).text(); | |
| 74 | expect(before).toContain(`action="/alice/a/settings/hooks/${hook}/pause"`); | |
| 75 | expect(before).toMatch(/<button[^>]*>\s*Pause\s*<\/button>/); | |
| 76 | expect(before).toContain("No deliveries yet."); | |
| 77 | expect(before).not.toContain(`/settings/hooks/${hook}/resume"`); | |
| 78 | ||
| 79 | const res = await post("pause", admin); | |
| 80 | expect(res.status).toBe(303); | |
| 81 | expect(res.headers.get("location")).toBe("/alice/a/settings/hooks"); | |
| 82 | expect(active()).toBeFalsy(); | |
| 83 | expect(events("webhook.paused")).toBe(1); | |
| 84 | ||
| 85 | const after = await (await page(admin)).text(); | |
| 86 | expect(after).toContain(`action="/alice/a/settings/hooks/${hook}/resume"`); | |
| 87 | expect(after).toMatch(/<button[^>]*>\s*Resume\s*<\/button>/); | |
| 88 | expect(after).toContain("paused"); | |
| 89 | expect(after).not.toContain("No deliveries yet."); | |
| 90 | expect(after).not.toContain(`/settings/hooks/${hook}/pause"`); | |
| 91 | }); | |
| 92 | ||
| 93 | test("Resume makes it active again and the row offers Pause once more", async () => { | |
| 94 | const res = await post("resume", admin); | |
| 95 | expect(res.status).toBe(303); | |
| 96 | expect(active()).toBeTruthy(); | |
| 97 | expect(events("webhook.resumed")).toBe(1); | |
| 98 | const html = await (await page(admin)).text(); | |
| 99 | expect(html).toContain(`action="/alice/a/settings/hooks/${hook}/pause"`); | |
| 100 | }); | |
| 101 | ||
| 102 | test("a reader neither sees the page nor can post it: 404, and the hook is untouched", async () => { | |
| 103 | expect((await page(reader)).status).toBe(404); | |
| 104 | expect((await post("pause", reader)).status).toBe(404); | |
| 105 | expect((await post("pause", "")).status).toBe(404); | |
| 106 | expect(active()).toBeTruthy(); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a cross-site post is refused and a missing hook is a message", async () => { | |
| 110 | expect((await post("pause", admin, "https://evil.example")).status).toBe(403); | |
| 111 | expect(active()).toBeTruthy(); | |
| 112 | const missing = await fetch(`${servers.url}/alice/a/settings/hooks/nope/pause`, { | |
| 113 | method: "POST", | |
| 114 | redirect: "manual", | |
| 115 | headers: { cookie: admin, origin: servers.url }, | |
| 116 | }); | |
| 117 | expect(missing.status).toBe(303); | |
| 118 | expect(events("webhook.paused")).toBe(1); | |
| 119 | }); |