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

An admin sends a webhook a test `ping` delivery through the API and MCP, and sees it among the deliveries #8006

Merged opened this

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

Merged

mason/forge:mason/7872-an-admin-sends-a-webhook-a-test-ping-del → main 7 commits fork

  • Merged. mason/7872-an-admin-sends-a-webhook-a-test-ping-del was merged into main as 083513e.

Review

Sign in to review this change.
14 files changed +445 −33
docs/MCP.md +1 −1
@@ -97,7 +97,7 @@ for a tunnel and a forge that sleeps.
9797 | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | |
9898 | `here` | `{ url }`: on a forge page, what you may do there as who you are, each action with its route and tool; on the link of one of this forge's app hosts, what its `/__forge/` door shows: `{ app, repo, environment, pull: { number, title } or null, working: [names], links: { home, repo, report } }`; a foreign host, or a private app you may not enter, is refused like a missing page | |
9999 | `hold` | `{ app, pull | path, why?, until?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; or with `path` (no `pull`) hold new work on a file so the picker starts no build whose issue names it, or list the holds and who set each; an admin of the app; plans unless `dryRun: false` | yes |
100 | `hooks` | (admin scope) `{ 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 }] }`); an admin-scope token on a repository you administer, and a secret is never shown | yes |
100 | `hooks` | (admin scope) `{ 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; an admin-scope token on a repository you administer, and a secret is never shown | yes |
101101 | `invites` | `{ action, id? }`: `list` the outstanding invites (state and expiry, never a link) or `revoke` one by `id`; a person admin with an admin token, stricter than the API's list, which an `ro` token may read | yes |
102102 | `issue` | `{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, the newest 50 comments (author, authorIsAgent, body, at), `commentsTotal` and `commentsNext` (older ones: `GET …/comments?cursor=`), and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found; draws the `ui://forge/issue` card: title, state, assignee, labels and what it waits for, never the body or comments | |
103103 | `issue_teams` | `{ repo, number, teams? }`: the crew teams an issue names; with `teams` (at most two slugs, `[]` clears) names them, and each reviews the closing pull | yes |
docs/features/webhook-ping.md added +11
@@ -0,0 +1,11 @@
1 # Send a webhook a test ping
2
3 A repository admin can check that a webhook's receiver answers without waiting for a real event: a `ping` is one signed delivery to that hook only, whatever kinds it follows.
4
5 - **Page.** Settings › Webhooks has a **Send ping** button on each hook, a same-origin POST to `/:owner/:name/settings/hooks/:id/ping`. The ping appears at once among that hook's deliveries; a refused one says how long to wait.
6 - **API.** `POST /api/v1/repos/:owner/:name/hooks/:id/ping` with an admin-scope token answers 202 `{ id }`. Below admin, or for a hook not in the repository, it is 404.
7 - **MCP.** `hooks` with `action: ping` and `id`, admin scope; it answers `{ id }`.
8 - **Terminal.** Open a hook's Deliveries screen and press `p`. The screen and the key are a repository admin's; the new row shows at the top as `ping · pending`.
9 - **Body.** `{ kind: "ping", repo, hook, at }`, signed with the hook's secret like any delivery. The worker sends it on its next pass, with the same egress refusal and retries; a paused hook holds it pending until it is resumed.
10 - **Rate limit.** At most 3 a minute per hook. Past that the API answers 429 with `retry-after`, MCP says the same, and the page and the terminal say how many seconds to wait.
11 - **Not an event.** The ping lives on its own delivery row (`webhook_ping`), never in the event log, so no feed, stream or other hook hears of it. A failed ping is not redelivered with `r`: send another.
src/contract/openapi/routes/index.ts +1
@@ -242,6 +242,7 @@ export { route as postApiV1ReposOwnerNameFollowingSever } from "./postApiV1Repos
242242 export { route as postApiV1ReposOwnerNameFollowingTake } from "./postApiV1ReposOwnerNameFollowingTake.ts";
243243 export { route as postApiV1ReposOwnerNameForks } from "./postApiV1ReposOwnerNameForks.ts";
244244 export { route as postApiV1ReposOwnerNameHooks } from "./postApiV1ReposOwnerNameHooks.ts";
245 export { route as postApiV1ReposOwnerNameHooksIdPing } from "./postApiV1ReposOwnerNameHooksIdPing.ts";
245246 export { route as postApiV1ReposOwnerNameIssues } from "./postApiV1ReposOwnerNameIssues.ts";
246247 export { route as postApiV1ReposOwnerNameIssuesNumberComments } from "./postApiV1ReposOwnerNameIssuesNumberComments.ts";
247248 export { route as postApiV1ReposOwnerNameIssuesNumberDependencies } from "./postApiV1ReposOwnerNameIssuesNumberDependencies.ts";
src/contract/openapi/routes/postApiV1ReposOwnerNameHooksIdPing.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}/ping`,
6 summary:
7 "Queue one signed `ping` delivery { kind, repo, hook, at } to this webhook only (admin scope); answers 202 { id } of the delivery, 429 past 3 a minute per hook",
8 auth: true,
9 };
src/core/migration-pins/20261102160000-webhook-ping-own-row.pin added +1
@@ -0,0 +1,1 @@
1 9ec6439b3eb7d11764ea98a509a4978937e9c1f11ee06a53c0d943ec3dde3ca0
src/core/migrations/20261102160000-webhook-ping-own-row.ts added +19
@@ -0,0 +1,19 @@
1 // A test `ping` belongs to its own row, not to the repository's event log
2 // (#8006). A delivery must name an event, and a roll keeps the old release
3 // reading `webhook_delivery`, so the ping gets a table of its own beside it
4 // rather than a rebuilt delivery table: nothing the old release reads changes.
5 export default `
6 CREATE TABLE IF NOT EXISTS webhook_ping (
7 id TEXT PRIMARY KEY,
8 webhook_id TEXT NOT NULL REFERENCES webhook(id) ON DELETE CASCADE,
9 state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','ok','failed')),
10 attempts INTEGER NOT NULL DEFAULT 0,
11 next_try_at TEXT,
12 last_status INTEGER,
13 last_error TEXT,
14 payload TEXT NOT NULL,
15 created_at TEXT NOT NULL
16 );
17 CREATE INDEX IF NOT EXISTS ping_due ON webhook_ping(state, next_try_at);
18 CREATE INDEX IF NOT EXISTS ping_hook ON webhook_ping(webhook_id, created_at);
19 `;
src/core/webhooks.ts +92 −19
@@ -187,6 +187,49 @@ export function enqueueForEvent(db: Database, event: ForgeEvent): number {
187187 return queued;
188188 }
189189
190 /** Pings one hook may be sent in a minute. */
191 export const PING_LIMIT = 3;
192 const PING_WINDOW_MS = 60_000;
193
194 /**
195 * Queue one `ping` delivery for one hook, whatever kinds it subscribes to.
196 * The payload lives on the delivery row, never in the event log, so no feed,
197 * stream or other hook hears of it; the request is made later by `deliverDue`, which keeps the
198 * egress refusal. `retryAfter` is in seconds when the hook was pinged too
199 * often.
200 */
201 export function pingWebhook(
202 db: Database,
203 repoSlug: string,
204 hook: Webhook,
205 now = new Date(),
206 ): Result<{ id: string }, { retryAfter: number }> {
207 const since = new Date(now.getTime() - PING_WINDOW_MS).toISOString();
208 const recent = db
209 .query<{ n: number; first: string | null }, [string, string]>(
210 `SELECT COUNT(*) AS n, MIN(created_at) AS first
211 FROM webhook_ping
212 WHERE webhook_id = ?1 AND created_at > ?2`,
213 )
214 .get(hook.id, since)!;
215 if (recent.n >= PING_LIMIT) {
216 const first = recent.first ? Date.parse(recent.first) : now.getTime();
217 const wait = Math.ceil((first + PING_WINDOW_MS - now.getTime()) / 1000);
218 return err({ retryAfter: Math.max(1, wait) });
219 }
220 const at = now.toISOString();
221 const deliveryId = id();
222 db.query(
223 "INSERT INTO webhook_ping (id, webhook_id, payload, created_at) VALUES (?1, ?2, ?3, ?4)",
224 ).run(
225 deliveryId,
226 hook.id,
227 JSON.stringify({ kind: "ping", repo: repoSlug, hook: hook.id, at }),
228 at,
229 );
230 return ok({ id: deliveryId });
231 }
232
190233 export type RedeliverError = "not_found" | "not_failed" | "sealed";
191234
192235 /**
@@ -244,7 +287,9 @@ function redeliverIn(db: Database, deliveryId: string, webhookId: string): Resul
244287 interface DueRow {
245288 id: string;
246289 webhook_id: string;
247 event_id: string;
290 event_id: string | null;
291 /** True when the row is in `webhook_ping`, not `webhook_delivery`. */
292 ping: boolean;
248293 attempts: number;
249294 url: string;
250295 secret: string;
@@ -271,9 +316,9 @@ export async function deliverDue(
271316 outbound: OutboundOptions = {},
272317 ): Promise<number> {
273318 const nowIso = now.toISOString();
274 const due = db
319 const events = db
275320 .query<DueRow, [string]>(
276 `SELECT d.id, d.webhook_id, d.event_id, d.attempts,
321 `SELECT d.id, d.webhook_id, d.event_id, 0 AS ping, d.attempts,
277322 w.url, w.secret,
278323 e.kind, e.repo_id, e.actor_id, e.payload, e.created_at
279324 FROM webhook_delivery d
@@ -284,6 +329,20 @@ export async function deliverDue(
284329 ORDER BY d.id LIMIT 50`,
285330 )
286331 .all(nowIso);
332 const pings = db
333 .query<DueRow, [string]>(
334 `SELECT d.id, d.webhook_id, NULL AS event_id, 1 AS ping, d.attempts,
335 w.url, w.secret,
336 'ping' AS kind, NULL AS repo_id, NULL AS actor_id,
337 d.payload, d.created_at
338 FROM webhook_ping d
339 JOIN webhook w ON w.id = d.webhook_id
340 WHERE d.state = 'pending' AND w.active = 1
341 AND (d.next_try_at IS NULL OR d.next_try_at <= ?1)
342 ORDER BY d.id LIMIT 50`,
343 )
344 .all(nowIso);
345 const due = [...events, ...pings].map((r) => ({ ...r, ping: !!r.ping }));
287346
288347 for (const row of due) {
289348 // Claimed before it is sent: two forge processes over one database (a
@@ -295,7 +354,7 @@ export async function deliverDue(
295354 const claimAt = Date.now();
296355 const claimed = db
297356 .query(
298 `UPDATE webhook_delivery SET next_try_at = ?2
357 `UPDATE ${tableOf(row)} SET next_try_at = ?2
299358 WHERE id = ?1 AND state = 'pending'
300359 AND (next_try_at IS NULL OR next_try_at <= ?3)`,
301360 )
@@ -324,6 +383,11 @@ export async function deliverDue(
324383 return due.length;
325384 }
326385
386 /** Where a due row lives: a ping has its own table, never the event log's. */
387 function tableOf(row: DueRow): "webhook_ping" | "webhook_delivery" {
388 return row.ping ? "webhook_ping" : "webhook_delivery";
389 }
390
327391 async function deliverOne(
328392 db: Database,
329393 log: Log,
@@ -331,14 +395,18 @@ async function deliverOne(
331395 now: Date,
332396 outbound: OutboundOptions,
333397 ): Promise<void> {
334 const body = JSON.stringify({
335 id: row.event_id,
336 kind: row.kind,
337 repo_id: row.repo_id,
338 actor_id: row.actor_id,
339 payload: JSON.parse(row.payload),
340 created_at: row.created_at,
341 });
398 // A ping's body is its payload as it stands: { kind, repo, hook, at }.
399 const body =
400 row.ping
401 ? row.payload
402 : JSON.stringify({
403 id: row.event_id,
404 kind: row.kind,
405 repo_id: row.repo_id,
406 actor_id: row.actor_id,
407 payload: JSON.parse(row.payload),
408 created_at: row.created_at,
409 });
342410 const signature = await sign(row.secret, body);
343411 const attempt = row.attempts + 1;
344412
@@ -370,7 +438,7 @@ async function deliverOne(
370438 // seconds, so there is nothing to retry: fail it now, with the reason.
371439 log.warn("webhook delivery refused", { delivery: row.id });
372440 db.query(
373 "UPDATE webhook_delivery SET state = 'failed', attempts = ?1, last_status = NULL, last_error = ?2 WHERE id = ?3",
441 `UPDATE ${tableOf(row)} SET state = 'failed', attempts = ?1, last_status = NULL, last_error = ?2 WHERE id = ?3`,
374442 ).run(attempt, error.message, row.id);
375443 return;
376444 }
@@ -383,7 +451,7 @@ async function deliverOne(
383451 const ok2xx = status >= 200 && status < 300;
384452 if (ok2xx) {
385453 db.query(
386 "UPDATE webhook_delivery SET state = 'ok', attempts = ?1, last_status = ?2, last_error = NULL WHERE id = ?3",
454 `UPDATE ${tableOf(row)} SET state = 'ok', attempts = ?1, last_status = ?2, last_error = NULL WHERE id = ?3`,
387455 ).run(attempt, status, row.id);
388456 return;
389457 }
@@ -401,7 +469,7 @@ function backOff(
401469 const attempt = row.attempts + 1;
402470 if (attempt >= MAX_ATTEMPTS) {
403471 db.query(
404 "UPDATE webhook_delivery SET state = 'failed', attempts = ?1, last_status = ?2 WHERE id = ?3",
472 `UPDATE ${tableOf(row)} SET state = 'failed', attempts = ?1, last_status = ?2 WHERE id = ?3`,
405473 ).run(attempt, status, row.id);
406474 log.warn("webhook delivery gave up", {
407475 delivery: row.id,
@@ -414,7 +482,7 @@ function backOff(
414482 const delayMs = attempt * attempt * 10_000;
415483 const nextTry = new Date(now.getTime() + delayMs).toISOString();
416484 db.query(
417 "UPDATE webhook_delivery SET attempts = ?1, last_status = ?2, next_try_at = ?3 WHERE id = ?4",
485 `UPDATE ${tableOf(row)} SET attempts = ?1, last_status = ?2, next_try_at = ?3 WHERE id = ?4`,
418486 ).run(attempt, status, nextTry, row.id);
419487 }
420488
@@ -484,9 +552,14 @@ export function recentDeliveries(
484552 },
485553 [string, number]
486554 >(
487 `SELECT d.id, d.state, d.attempts, d.last_status, d.last_error, e.kind
488 FROM webhook_delivery d JOIN event e ON e.id = d.event_id
489 WHERE d.webhook_id = ?1 ORDER BY d.id DESC LIMIT ?2`,
555 `SELECT id, state, attempts, last_status, last_error, kind FROM (
556 SELECT d.id, d.state, d.attempts, d.last_status, d.last_error, e.kind
557 FROM webhook_delivery d JOIN event e ON e.id = d.event_id
558 WHERE d.webhook_id = ?1
559 UNION ALL
560 SELECT id, state, attempts, last_status, last_error, 'ping' AS kind
561 FROM webhook_ping WHERE webhook_id = ?1
562 ) ORDER BY id DESC LIMIT ?2`,
490563 )
491564 .all(webhookId, limit);
492565 return rows.map((r) => ({
src/http/api/hooks.ts +18 −1
@@ -9,7 +9,7 @@
99 import { createWebhook, deleteWebhook } from "../../actions/repos.ts";
1010 import { canAdmin } from "../../core/repos.ts";
1111 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
12 import { getWebhook, listWebhooks, recentDeliveries } from "../../core/webhooks.ts";
12 import { getWebhook, listWebhooks, pingWebhook, recentDeliveries } from "../../core/webhooks.ts";
1313 import { loadRepo, pageOf, pageParams } from "../api.ts";
1414 import type { Handler } from "../context.ts";
1515 import { body, json, notFound, requireScope, respondAccess, str } from "../respond.ts";
@@ -38,6 +38,23 @@ export function hookRoutes(router: Router<Handler>): void {
3838 return json({ items: recentDeliveries(ctx.app.db, ctx.params.id!, 20) });
3939 });
4040
41 router.add("POST", "/api/v1/repos/:owner/:name/hooks/:id/ping", (ctx) => {
42 const denied = requireScope(ctx, "admin");
43 if (denied) return denied.status === 403 ? notFound() : denied;
44 const repo = loadRepo(ctx);
45 if (!repo || !canAdmin(ctx.app.db, repo, ctx.auth.user)) return notFound();
46 const hook = getWebhook(ctx.app.db, repo.id, ctx.params.id!);
47 if (!hook) return notFound();
48 const pinged = pingWebhook(ctx.app.db, `${ctx.params.owner}/${ctx.params.name}`, hook);
49 if (!pinged.ok)
50 return json(
51 { error: "rate_limited", message: `too many pings: try again in ${pinged.error.retryAfter} s` },
52 429,
53 { "retry-after": String(pinged.error.retryAfter) },
54 );
55 return json({ id: pinged.value.id }, 202);
56 });
57
4158 router.add("POST", "/api/v1/repos/:owner/:name/hooks", async (ctx) => {
4259 const denied = requireScope(ctx, "admin");
4360 if (denied) return denied;
src/http/mcp/tools/hooks.ts +13 −4
@@ -1,5 +1,5 @@
11 import { canAdmin } from "../../../core/repos.ts";
2 import { getWebhook, listWebhooks, recentDeliveries } from "../../../core/webhooks.ts";
2 import { getWebhook, listWebhooks, pingWebhook, recentDeliveries } from "../../../core/webhooks.ts";
33 import { createWebhook, deleteWebhook } 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";
@@ -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 }] }`); 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; 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"] },
29 action: { type: "string", enum: ["list", "add", "remove", "deliveries", "ping"] },
3030 url: { type: "string", maxLength: 2048 },
3131 kinds: { type: "string", maxLength: 200 },
3232 secret: { type: "string", maxLength: 200 },
@@ -54,6 +54,15 @@ export const tool: Tool = {
5454 if (hook === undefined) return bad("not_found", "no such webhook here");
5555 return good({ items: recentDeliveries(ctx.app.db, hook.id, 20) });
5656 }
57 if (action === "ping") {
58 const long = hookTooLong(args);
59 if (long !== undefined) return long;
60 if (!canAdmin(ctx.app.db, repo, user)) return notAdmin();
61 const hook = getWebhook(ctx.app.db, repo.id, str(args["id"]));
62 if (hook === undefined) return bad("not_found", "no such webhook here");
63 const pinged = pingWebhook(ctx.app.db, `${repo.ownerName}/${repo.name}`, hook);
64 return pinged.ok ? good({ id: pinged.value.id }) : bad("rate_limited", `too many pings: try again in ${pinged.error.retryAfter} s`);
65 }
5766 if (action === "add") {
5867 const long = hookTooLong(args);
5968 if (long !== undefined) return long;
@@ -78,6 +87,6 @@ export const tool: Tool = {
7887 const removed = deleteWebhook(ctx.app, user, repo, str(args["id"]));
7988 return removed.ok ? good({}) : bad("not_found", "no such webhook here");
8089 }
81 return bad("invalid", "action is list, add, remove or deliveries");
90 return bad("invalid", "action is list, add, remove, deliveries or ping");
8291 },
8392 };
src/http/server.ts +2 −1
@@ -411,7 +411,7 @@ import {
411411 } from "../web/pages/settings.ts";
412412 import { dangerPage, repoDelete } from "../web/pages/danger.ts";
413413 import { detachSubmit } from "../web/pages/detach.ts";
414 import { hooksPage, webhookCreate, webhookDelete } from "../web/pages/hooks.ts";
414 import { hooksPage, webhookCreate, webhookDelete, webhookPing } from "../web/pages/hooks.ts";
415415 import {
416416 crewAdd,
417417 crewRemove,
@@ -1545,6 +1545,7 @@ export function buildRouter(): Router<Handler> {
15451545 router.add("GET", "/:owner/:name/settings/hooks", hooksPage);
15461546 router.add("POST", "/:owner/:name/settings/hooks", webhookCreate);
15471547 router.add("POST", "/:owner/:name/settings/hooks/:id/delete", webhookDelete);
1548 router.add("POST", "/:owner/:name/settings/hooks/:id/ping", webhookPing);
15481549 router.add("GET", "/:owner/:name/settings/danger", dangerPage);
15491550 router.add("POST", "/:owner/:name/settings/delete", repoDelete);
15501551 router.add("POST", "/:owner/:name/settings/detach", detachSubmit);
src/tui/screens/hooks.ts +22 −4
@@ -1,7 +1,9 @@
11 /**
22 * A repository's webhooks in the terminal, an admin's: each hook's url,
33 * kinds and last delivery, never its secret. `enter` opens its last 20
4 * deliveries; `r` there sends a failed one again through `redeliver`.
4 * deliveries; `r` there sends a failed one again through `redeliver`, and
5 * `p` sends the hook a `ping` through `pingWebhook` (a refusal past the rate
6 * limit says how long to wait).
57 * `a` asks for url, kinds and a secret (typed masked, 16–200 characters)
68 * and adds it through `createWebhook`; `p` pauses or resumes the selected
79 * one through `pauseWebhook` (a paused row reads `paused` and gets no
@@ -15,7 +17,14 @@ import {
1517 pauseWebhook,
1618 } from "../../actions/repos.ts";
1719 import { canAdmin, type Repo } from "../../core/repos.ts";
18 import { listWebhooks, recentDeliveries, redeliver, type DeliveryInfo, type Webhook } from "../../core/webhooks.ts";
20 import {
21 listWebhooks,
22 pingWebhook,
23 recentDeliveries,
24 redeliver,
25 type DeliveryInfo,
26 type Webhook,
27 } from "../../core/webhooks.ts";
1928 import type { Ctx, Screen } from "../app/app.ts";
2029 import type { Command } from "../app/palette.ts";
2130 import { binding } from "../app/keys.ts";
@@ -53,7 +62,7 @@ function lastDelivery(f: Session, hook: Webhook): string {
5362 }
5463
5564 /** One hook's last 20 deliveries, newest first: kind · state · status or reason · attempts. */
56 function deliveriesScreen(f: Session, ctx: Ctx, hook: Webhook, allowed: () => boolean): Screen {
65 function deliveriesScreen(f: Session, ctx: Ctx, repo: Repo, hook: Webhook, allowed: () => boolean): Screen {
5766 const state = new ListState();
5867 let all: DeliveryInfo[] = [];
5968 return {
@@ -63,6 +72,7 @@ function deliveriesScreen(f: Session, ctx: Ctx, hook: Webhook, allowed: () => bo
6372 return [
6473 binding(["j", "k"], "move"),
6574 binding(["r"], "redeliver", { disabled: !failed }),
75 binding(["p"], "send a ping"),
6676 binding(["esc", "q"], "back"),
6777 ];
6878 },
@@ -100,6 +110,14 @@ function deliveriesScreen(f: Session, ctx: Ctx, hook: Webhook, allowed: () => bo
100110 ctx.invalidate();
101111 return true;
102112 }
113 if (e.key === "p") {
114 const sent = pingWebhook(f.db, `${repo.ownerName}/${repo.name}`, hook);
115 if (sent.ok) ctx.toast("ping queued", "success");
116 else ctx.toast(`too many pings: try again in ${sent.error.retryAfter} s`, "danger");
117 state.index = 0;
118 ctx.invalidate();
119 return true;
120 }
103121 return state.key(e, all.length);
104122 },
105123 };
@@ -203,7 +221,7 @@ export function hooksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
203221 else if (e.key === "enter") {
204222 const hook = hooks()[state.index];
205223 if (hook === undefined) return false;
206 ctx.push(deliveriesScreen(f, ctx, hook, allowed));
224 ctx.push(deliveriesScreen(f, ctx, repo, hook, allowed));
207225 } else if (e.key === "p") {
208226 const hook = hooks()[state.index];
209227 if (hook === undefined) return false;
src/web/pages/hooks.ts +21 −1
@@ -7,7 +7,7 @@
77
88 import { createWebhook, deleteWebhook } from "../../actions/repos.ts";
99 import { notePhase, timePhase } from "../../core/route_timing.ts";
10 import { listWebhooks, recentDeliveries } from "../../core/webhooks.ts";
10 import { getWebhook, listWebhooks, pingWebhook, recentDeliveries } from "../../core/webhooks.ts";
1111 import type { Ctx } from "../../http/context.ts";
1212 import { repoBase, repoNotFound } from "../chrome.ts";
1313 import { field } from "../form.ts";
@@ -85,6 +85,9 @@ export function hooksPage(ctx: Ctx): Response {
8585 head: html`<span class="grow truncate mono" title="${hook.url}"
8686 >${hook.url}</span
8787 >
88 <form method="post" action="${base}/settings/hooks/${hook.id}/ping">
89 ${btn("Send ping", { size: "sm" })}
90 </form>
8891 <span class="badge" data-variant="secondary"
8992 >${hook.kinds}</span
9093 >
@@ -188,3 +191,20 @@ export async function webhookDelete(ctx: Ctx): Promise<Response> {
188191 : { tone: "danger", message: "That webhook no longer exists." },
189192 );
190193 }
194
195 export async function webhookPing(ctx: Ctx): Promise<Response> {
196 const gate = await adminForm(ctx);
197 if (gate instanceof Response) return gate;
198 const { repo } = gate;
199 const to = `${repoBase(repo)}/settings/hooks`;
200 const hook = getWebhook(ctx.app.db, repo.id, ctx.params.id ?? "");
201 if (!hook) return flashTo(ctx, to, { tone: "danger", message: "That webhook no longer exists." });
202 const pinged = pingWebhook(ctx.app.db, `${repo.ownerName}/${repo.name}`, hook);
203 return flashTo(
204 ctx,
205 to,
206 pinged.ok
207 ? { message: "Ping queued: it shows among this webhook's deliveries." }
208 : { tone: "danger", message: `Too many pings: try again in ${pinged.error.retryAfter} s.` },
209 );
210 }
test/hook-ping.test.ts added +203
@@ -0,0 +1,203 @@
1 /** POST …/hooks/:id/ping, MCP `hooks` action `ping` and the settings page's Send ping (#8006). */
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 { createToken } from "../src/core/tokens.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { createWebhook, deliverDue, recentDeliveries, verifySignature } from "../src/core/webhooks.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { LOOPBACK } from "./helpers/loopback-hook.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 let dataDir: string;
19 let app: App;
20 let servers: Servers;
21 let adminTok = "";
22 let otherTok = "";
23 let cookie = "";
24 let hookA = "";
25 let hookB = "";
26 let secretA = "";
27 let received: { body: string; sig: string; kind: string }[] = [];
28 let receiver: ReturnType<typeof Bun.serve>;
29 const cells = process.env["FORGE_CELLS"];
30
31 beforeAll(async () => {
32 process.env["FORGE_CELLS"] = "off";
33 receiver = Bun.serve({
34 port: 0,
35 hostname: "127.0.0.1",
36 async fetch(req) {
37 received.push({
38 body: await req.text(),
39 sig: req.headers.get("x-forge-signature-256") ?? "",
40 kind: req.headers.get("x-forge-event") ?? "",
41 });
42 return new Response("ok");
43 },
44 });
45 dataDir = await mkdtemp(join(tmpdir(), "forge-hook-ping-"));
46 app = createApp({ dataDir, port: 0 });
47 servers = await serve(app);
48 const mk = async (username: string, first: boolean) => {
49 const made = await createUser(app.db, {
50 origin: "seed",
51 trusted: true,
52 username,
53 email: `${username}@forge.local`,
54 password: "a-good-passphrase",
55 adminIfFirst: first,
56 });
57 if (!made.ok) throw new Error(String(made.error));
58 return made.value;
59 };
60 const alice = await mk("alice", true);
61 const bob = await mk("bob", false);
62 const ra = await createRepo(app, alice, { name: "a", visibility: "public" });
63 if (!ra.ok) throw new Error("repo");
64 // Subscribes to issues only: a ping must reach it anyway. The other hook hears nothing.
65 const a = createWebhook(app.db, { repoId: ra.value.id, url: "https://a.example/h", kinds: "issue.opened" });
66 const b = createWebhook(app.db, { repoId: ra.value.id, url: "https://b.example/h" });
67 if (!a.ok || !b.ok) throw new Error("hook");
68 hookA = a.value.webhook.id;
69 hookB = b.value.webhook.id;
70 secretA = a.value.secret;
71 adminTok = createToken(app.db, alice.id, "t", "admin").token;
72 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
73 otherTok = createToken(app.db, bob.id, "o", "admin").token;
74 });
75 afterAll(async () => {
76 receiver.stop(true);
77 await servers.stop();
78 app.close();
79 if (cells === undefined) delete process.env["FORGE_CELLS"];
80 else process.env["FORGE_CELLS"] = cells;
81 await rm(dataDir, { recursive: true, force: true });
82 });
83
84 const ping = (hook: string, tok: string) =>
85 fetch(`${servers.url}/api/v1/repos/alice/a/hooks/${hook}/ping`, { method: "POST", headers: { authorization: `Bearer ${tok}` } });
86 const mcp = async (args: unknown) => {
87 const res = await fetch(`${servers.url}/mcp`, {
88 method: "POST",
89 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${adminTok}` },
90 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "hooks", arguments: args } }),
91 });
92 const body = (await res.json()) as any;
93 const text: string = body.result.content[0].text;
94 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
95 };
96 const resetWindow = () => app.db.run("UPDATE webhook_ping SET created_at = '2020-01-01T00:00:00.000Z'");
97
98 test("a ping answers 202, shows among the deliveries and reaches only that hook", async () => {
99 const res = await ping(hookA, adminTok);
100 expect(res.status).toBe(202);
101 const { id } = (await res.json()) as { id: string };
102 const api = await fetch(`${servers.url}/api/v1/repos/alice/a/hooks/${hookA}/deliveries`, { headers: { authorization: `Bearer ${adminTok}` } });
103 const items = ((await api.json()) as any).items;
104 expect(items[0]).toMatchObject({ id, kind: "ping", state: "pending" });
105 const viaMcp = await mcp({ repo: "alice/a", action: "deliveries", id: hookA });
106 expect(viaMcp.value.items[0]).toMatchObject({ id, kind: "ping" });
107 expect(recentDeliveries(app.db, hookB)).toHaveLength(0);
108 });
109
110 test("the ping is signed and goes through deliverDue, body { kind, repo, hook, at }", async () => {
111 received = [];
112 app.db.run("UPDATE webhook SET url = ?2 WHERE id = ?1", [hookA, `http://127.0.0.1:${receiver.port}/h`]);
113 await deliverDue(app.db, app.log, new Date(), LOOPBACK);
114 expect(received).toHaveLength(1);
115 const hit = received[0]!;
116 expect(hit.kind).toBe("ping");
117 const body = JSON.parse(hit.body);
118 expect(Object.keys(body).sort()).toEqual(["at", "hook", "kind", "repo"]);
119 expect(body).toMatchObject({ kind: "ping", repo: "alice/a", hook: hookA });
120 expect(await verifySignature(secretA, hit.body, hit.sig)).toBe(true);
121 });
122
123 test("a private address is refused as a failed delivery with its reason, never fetched inline", async () => {
124 resetWindow();
125 const res = await ping(hookB, adminTok);
126 expect(res.status).toBe(202);
127 expect(recentDeliveries(app.db, hookB)[0]).toMatchObject({ kind: "ping", state: "pending", attempts: 0 });
128 app.db.run("UPDATE webhook SET url = 'https://127.0.0.1/h' WHERE id = ?1", [hookB]);
129 await deliverDue(app.db, app.log);
130 expect(recentDeliveries(app.db, hookB)[0]).toMatchObject({ kind: "ping", state: "failed" });
131 expect(recentDeliveries(app.db, hookB)[0]!.lastError).toBeTruthy();
132 });
133
134 test("a non-admin, a missing hook and no credentials are refused", async () => {
135 expect((await ping(hookA, otherTok)).status).toBe(404);
136 expect((await ping("nope", adminTok)).status).toBe(404);
137 const anon = await fetch(`${servers.url}/api/v1/repos/alice/a/hooks/${hookA}/ping`, { method: "POST" });
138 expect(anon.status).toBe(401);
139 });
140
141 test("past three a minute it is 429 with retry-after, over the API and MCP", async () => {
142 resetWindow();
143 for (let i = 0; i < 3; i++) expect((await ping(hookA, adminTok)).status).toBe(202);
144 const over = await ping(hookA, adminTok);
145 expect(over.status).toBe(429);
146 expect(Number(over.headers.get("retry-after"))).toBeGreaterThan(0);
147 const m = await mcp({ repo: "alice/a", action: "ping", id: hookA });
148 expect(m.error).toContain("too many pings");
149 resetWindow();
150 const ok = await mcp({ repo: "alice/a", action: "ping", id: hookA });
151 expect(typeof ok.value.id).toBe("string");
152 });
153
154 test("a ping is not an event: the repository's event log, feed and stream never hear of it", async () => {
155 resetWindow();
156 const before = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM event").get()!.n;
157 expect((await ping(hookA, adminTok)).status).toBe(202);
158 expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM event").get()!.n).toBe(before);
159 expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM event WHERE kind = 'ping'").get()!.n).toBe(0);
160 const events = await fetch(`${servers.url}/api/v1/repos/alice/a/events?kind=ping`);
161 expect(((await events.json()) as any).items).toHaveLength(0);
162 const all = await fetch(`${servers.url}/api/v1/repos/alice/a/events`);
163 expect(JSON.stringify(await all.json())).not.toContain("ping");
164 });
165
166 test("the limit counts deliveries, so it holds with nothing in the event log", async () => {
167 resetWindow();
168 for (let i = 0; i < 3; i++) expect((await ping(hookB, adminTok)).status).toBe(202);
169 expect((await ping(hookB, adminTok)).status).toBe(429);
170 expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM event WHERE kind = 'ping'").get()!.n).toBe(0);
171 });
172
173 const send = (hook: string, headers: Record<string, string>) =>
174 fetch(`${servers.url}/alice/a/settings/hooks/${hook}/ping`, { method: "POST", redirect: "manual", headers: { cookie, ...headers } });
175
176 test("the hooks page has a Send ping form; posting it queues a delivery, a cross-site post does not", async () => {
177 resetWindow();
178 const page = await (await fetch(`${servers.url}/alice/a/settings/hooks`, { headers: { cookie } })).text();
179 expect(page).toContain(`action="/alice/a/settings/hooks/${hookA}/ping"`);
180 expect(page).toContain("Send ping");
181 const n = () => recentDeliveries(app.db, hookA, 50).length;
182 const before = n();
183 expect((await send(hookA, { origin: "https://evil.example" })).status).toBe(403);
184 expect(n()).toBe(before);
185 const ok = await send(hookA, { origin: servers.url });
186 expect(ok.status).toBe(303);
187 expect(n()).toBe(before + 1);
188 expect(recentDeliveries(app.db, hookA, 1)[0]).toMatchObject({ kind: "ping", state: "pending" });
189 });
190
191 test("the page post past the limit is a message, not a delivery; a stranger gets nothing", async () => {
192 resetWindow();
193 for (let i = 0; i < 3; i++) await send(hookA, { origin: servers.url });
194 const before = recentDeliveries(app.db, hookA, 50).length;
195 const over = await send(hookA, { origin: servers.url });
196 expect(over.status).toBe(303);
197 expect(recentDeliveries(app.db, hookA, 50)).toHaveLength(before);
198 const shown = await (await fetch(`${servers.url}/alice/a/settings/hooks`, { headers: { cookie: over.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ") + "; " + cookie } })).text();
199 expect(shown).toContain("Too many pings");
200 const anon = await fetch(`${servers.url}/alice/a/settings/hooks/${hookA}/ping`, { method: "POST", redirect: "manual", headers: { origin: servers.url } });
201 expect(anon.status).toBe(404);
202 expect(recentDeliveries(app.db, hookA, 50)).toHaveLength(before);
203 });
test/tui-hooks-deliveries.test.ts +32 −2
@@ -1,7 +1,8 @@
11 /**
22 * Enter on a webhook in the terminal's Hooks screen opens its last 20
33 * deliveries (#7873): state, status and why it failed, never a secret;
4 * esc or q returns to the same row; losing admin closes it.
4 * esc or q returns to the same row; losing admin closes it. `r` redelivers
5 * a failed row (#8075); `p` sends the hook a ping (#7872).
56 */
67 import { afterAll, beforeAll, expect, test } from "bun:test";
78 import { mkdtemp, rm } from "node:fs/promises";
@@ -11,7 +12,7 @@ import { createRepo } from "../src/actions/repos.ts";
1112 import { createApp, type App } from "../src/app.ts";
1213 import type { Repo } from "../src/core/repos.ts";
1314 import { createUser, type User } from "../src/core/users.ts";
14 import { createWebhook, enqueueForEvent, listWebhooks } from "../src/core/webhooks.ts";
15 import { createWebhook, enqueueForEvent, listWebhooks, PING_LIMIT } from "../src/core/webhooks.ts";
1516 import { createForgeApp } from "../src/tui/forge.ts";
1617 import { hooksScreen } from "../src/tui/screens/hooks.ts";
1718 import { headlessTerminal } from "../src/tui/term/terminal.ts";
@@ -174,3 +175,32 @@ test("a person who lost admin cannot redeliver with r: the row stays failed", as
174175 expect(app.db.query("SELECT 1 FROM webhook_delivery WHERE state = 'pending'").get()).toBeNull();
175176 h.stop();
176177 });
178
179 test("p sends the hook a ping that shows at once among its deliveries; past the limit it says how long to wait", async () => {
180 const h = open();
181 await h.feed("j", "j", "enter");
182 expect(h.text()).toContain("No deliveries yet.");
183 for (let i = 0; i < PING_LIMIT; i++) {
184 await h.feed("p");
185 expect(h.text()).toContain("ping queued");
186 }
187 expect(h.text()).toMatch(/ping · pending · — · 0/);
188 await h.feed("p");
189 expect(h.text()).toMatch(/too many pings: try again in \d+ s/);
190 const hook = listWebhooks(app.db, repo.id)[2]!;
191 expect(app.db.query("SELECT COUNT(*) AS n FROM webhook_ping WHERE webhook_id = ?1").get(hook.id)).toEqual({
192 n: PING_LIMIT,
193 });
194 h.stop();
195 });
196
197 test("a person who lost admin cannot ping with p", async () => {
198 const before = (app.db.query("SELECT COUNT(*) AS n FROM webhook_ping").get() as { n: number }).n;
199 const h = open();
200 await h.feed("enter");
201 (h.session as { user: User }).user = bob;
202 await h.feed("p");
203 expect(h.text()).not.toContain("ping queued");
204 expect((app.db.query("SELECT COUNT(*) AS n FROM webhook_ping").get() as { n: number }).n).toBe(before);
205 h.stop();
206 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs