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

GET /api/v1/repos/{owner}/{name}/hooks/{id}/deliveries answers a webhook's last deliveries, as the hooks page shows them #7877

Merged opened this

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

Merged

torque/forge:torque/7870-get-api-v1-repos-owner-name-hooks-id-del → main 7 commits fork

  • Merged. torque/7870-get-api-v1-repos-owner-name-hooks-id-del was merged into main as a3c9717.

Review

Sign in to review this change.
7 files changed +144 −4
README.md +1 −1

Diff not shown — a line 2,387 characters wide (limit 2,000). +1 −1

View file
src/contract/openapi/routes/getApiV1ReposOwnerNameHooksIdDeliveries.ts added +9
@@ -0,0 +1,9 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/hooks/{id}/deliveries`,
6 summary:
7 "A webhook's last 20 deliveries, newest first (admin scope); answers { items: [{ id, state, attempts, lastStatus, lastError, kind }] }, never the secret, a payload or a signature",
8 auth: true,
9 };
src/contract/openapi/routes/index.ts +1
@@ -94,6 +94,7 @@ export { route as getApiV1ReposOwnerNameFollowingProposals } from "./getApiV1Rep
9494 export { route as getApiV1ReposOwnerNameForks } from "./getApiV1ReposOwnerNameForks.ts";
9595 export { route as getApiV1ReposOwnerNameGatesIdLog } from "./getApiV1ReposOwnerNameGatesIdLog.ts";
9696 export { route as getApiV1ReposOwnerNameHooks } from "./getApiV1ReposOwnerNameHooks.ts";
97 export { route as getApiV1ReposOwnerNameHooksIdDeliveries } from "./getApiV1ReposOwnerNameHooksIdDeliveries.ts";
9798 export { route as getApiV1ReposOwnerNameIssues } from "./getApiV1ReposOwnerNameIssues.ts";
9899 export { route as getApiV1ReposOwnerNameIssuesNumber } from "./getApiV1ReposOwnerNameIssuesNumber.ts";
99100 export { route as getApiV1ReposOwnerNameIssuesNumberComments } from "./getApiV1ReposOwnerNameIssuesNumberComments.ts";
src/http/api/hooks.ts +14 −3
@@ -1,14 +1,15 @@
11 /**
22 * A repository's webhooks over the API: `GET` and `POST`
3 * `/api/v1/repos/:owner/:name/hooks`, and `DELETE` `…/hooks/:id`. Moved out
4 * of `api.ts` (#6333); the rules live in `actions/repos.ts`. Admin-scoped,
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,
56 * since a webhook exfiltrates repo events.
67 */
78
89 import { createWebhook, deleteWebhook } from "../../actions/repos.ts";
910 import { canAdmin } from "../../core/repos.ts";
1011 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
11 import { listWebhooks } from "../../core/webhooks.ts";
12 import { getWebhook, listWebhooks, recentDeliveries } from "../../core/webhooks.ts";
1213 import { loadRepo, pageOf, pageParams } from "../api.ts";
1314 import type { Handler } from "../context.ts";
1415 import { body, json, notFound, requireScope, respondAccess, str } from "../respond.ts";
@@ -27,6 +28,16 @@ export function hookRoutes(router: Router<Handler>): void {
2728 return timePhase(ctx.request, "render", () => pageOf(rows, page));
2829 });
2930
31 router.add("GET", "/api/v1/repos/:owner/:name/hooks/:id/deliveries", (ctx) => {
32 const denied = requireScope(ctx, "admin");
33 // A token short of admin learns nothing: the same 404 as a missing hook.
34 if (denied) return denied.status === 403 ? notFound() : denied;
35 const repo = loadRepo(ctx);
36 if (!repo || !canAdmin(ctx.app.db, repo, ctx.auth.user)) return notFound();
37 if (!getWebhook(ctx.app.db, repo.id, ctx.params.id!)) return notFound();
38 return json({ items: recentDeliveries(ctx.app.db, ctx.params.id!, 20) });
39 });
40
3041 router.add("POST", "/api/v1/repos/:owner/:name/hooks", async (ctx) => {
3142 const denied = requireScope(ctx, "admin");
3243 if (denied) return denied;
test/api-hook-deliveries.test.ts added +104
@@ -0,0 +1,104 @@
1 /** GET /api/v1/repos/:owner/:name/hooks/:id/deliveries (#7870). */
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 } from "../src/core/users.ts";
10 import { createWebhook } 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 adminTok = "";
20 let readTok = "";
21 let otherTok = "";
22 let hookA = "";
23 let hookB = "";
24 let secretA = "";
25 const cells = process.env["FORGE_CELLS"];
26
27 beforeAll(async () => {
28 process.env["FORGE_CELLS"] = "off";
29 dataDir = await mkdtemp(join(tmpdir(), "forge-api-hook-deliveries-"));
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",
35 trusted: true,
36 username,
37 email: `${username}@forge.local`,
38 password: "a-good-passphrase",
39 adminIfFirst: first,
40 });
41 if (!made.ok) throw new Error(String(made.error));
42 return made.value;
43 };
44 const alice = await mk("alice", true);
45 const bob = await mk("bob", false);
46 const ra = await createRepo(app, alice, { name: "a", visibility: "public" });
47 const rb = await createRepo(app, alice, { name: "b", visibility: "public" });
48 if (!ra.ok || !rb.ok) throw new Error("repo");
49 const a = createWebhook(app.db, { repoId: ra.value.id, url: "https://a.example/h" });
50 const b = createWebhook(app.db, { repoId: rb.value.id, url: "https://b.example/h" });
51 if (!a.ok || !b.ok) throw new Error("hook");
52 hookA = a.value.webhook.id;
53 hookB = b.value.webhook.id;
54 secretA = a.value.secret;
55 app.bus.append({ kind: "push", repoId: ra.value.id });
56 app.bus.append({ kind: "push", repoId: ra.value.id });
57 const rows = app.db
58 .query<{ id: string }, [string]>("SELECT id FROM webhook_delivery WHERE webhook_id = ?1 ORDER BY id")
59 .all(hookA);
60 app.db.query("UPDATE webhook_delivery SET state='ok', attempts=1, last_status=200 WHERE id=?1").run(rows[0]!.id);
61 app.db
62 .query("UPDATE webhook_delivery SET state='failed', attempts=3, last_status=NULL, last_error='refused: private address' WHERE id=?1")
63 .run(rows[1]!.id);
64 adminTok = createToken(app.db, alice.id, "t", "admin").token;
65 readTok = createToken(app.db, alice.id, "r", "ro").token;
66 otherTok = createToken(app.db, bob.id, "o", "admin").token;
67 });
68 afterAll(async () => {
69 await servers.stop();
70 app.close();
71 if (cells === undefined) delete process.env["FORGE_CELLS"];
72 else process.env["FORGE_CELLS"] = cells;
73 await rm(dataDir, { recursive: true, force: true });
74 });
75
76 const get = (path: string, tok: string) =>
77 fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${tok}` } });
78
79 test("an admin lists both deliveries, newest first, with status and reason", async () => {
80 const res = await get(`/api/v1/repos/alice/a/hooks/${hookA}/deliveries`, adminTok);
81 expect(res.status).toBe(200);
82 const text = await res.text();
83 const items = JSON.parse(text).items;
84 expect(items).toHaveLength(2);
85 expect(items[0]).toMatchObject({ state: "failed", attempts: 3, lastStatus: null, lastError: "refused: private address", kind: "push" });
86 expect(items[1]).toMatchObject({ state: "ok", attempts: 1, lastStatus: 200, lastError: null });
87 expect(Object.keys(items[0]).sort()).toEqual(["attempts", "id", "kind", "lastError", "lastStatus", "state"]);
88 expect(secretA.length).toBeGreaterThan(0);
89 expect(text).not.toContain(secretA);
90 });
91
92 test("a non-admin, a read-only token and another repository's hook are 404", async () => {
93 expect((await get(`/api/v1/repos/alice/a/hooks/${hookA}/deliveries`, otherTok)).status).toBe(404);
94 expect((await get(`/api/v1/repos/alice/a/hooks/${hookA}/deliveries`, readTok)).status).toBe(404);
95 const cross = await get(`/api/v1/repos/alice/a/hooks/${hookB}/deliveries`, adminTok);
96 const missing = await get(`/api/v1/repos/alice/a/hooks/nope/deliveries`, adminTok);
97 expect(cross.status).toBe(404);
98 expect(await cross.text()).toBe(await missing.text());
99 });
100
101 test("no credentials is 401", async () => {
102 const res = await fetch(`${servers.url}/api/v1/repos/alice/a/hooks/${hookA}/deliveries`);
103 expect(res.status).toBe(401);
104 });
test/mcp-hooks.test.ts +13
@@ -73,6 +73,19 @@ describe("MCP hooks", () => {
7373 expect((await tool(aliceAdmin, { repo: "alice/game", action: "remove", id: added.value.id })).error).toContain("not_found");
7474 });
7575
76 test("deliveries answers the hook's last deliveries as the route does, and refuses as it does", async () => {
77 const added = await tool(aliceAdmin, { repo: "alice/game", action: "add", url: "https://example.com/d", secret: "0123456789abcdef" });
78 const repoId = app.db.query<{ id: string }, []>("SELECT id FROM repo WHERE name='game'").get()!.id;
79 app.bus.append({ kind: "push", repoId });
80 const got = await tool(aliceAdmin, { repo: "alice/game", action: "deliveries", id: added.value.id });
81 expect(got.value.items.length).toBeGreaterThan(0);
82 expect(got.value.items[0].kind).toBe("push");
83 expect(JSON.stringify(got)).not.toContain("0123456789abcdef");
84 expect((await tool(aliceAdmin, { repo: "alice/game", action: "deliveries", id: "nope" })).error).toContain("not_found");
85 expect((await tool(bobAdmin, { repo: "alice/game", action: "deliveries", id: added.value.id })).error).toContain("not_found");
86 await tool(aliceAdmin, { repo: "alice/game", action: "remove", id: added.value.id });
87 });
88
7689 test("list pages by limit and cursor, and refuses what the route refuses", async () => {
7790 const ids: string[] = [];
7891 for (let i = 0; i < 3; i++) {
test/security-floor-routes.test.ts +2
@@ -42,6 +42,7 @@ const SECRET = "needs a secret scope an operator has set";
4242 const GO = "needs a short code minted by a share; the seed shares nothing";
4343 const ARCH = "needs an archived-home operation id; the seed archives nothing";
4444 const GATE = "needs a kept gate log, written only when a full gate runs on land";
45 const HOOK = "needs a webhook, which the seed does not register";
4546
4647 /** GET patterns the seed cannot fill → why. Requested with a placeholder all the same. */
4748 const UNSEEDED: Record<string, string> = {
@@ -58,6 +59,7 @@ const UNSEEDED: Record<string, string> = {
5859 "/api/v1/orgs/:org/teams": ORG,
5960 "/api/v1/orgs/:org/teams/:team/members": ORG,
6061 "/api/v1/repos/:owner/:name/gates/:id/log": GATE,
62 "/api/v1/repos/:owner/:name/hooks/:id/deliveries": HOOK,
6163 "/api/v1/repos/:owner/:name/waves/:version": WAVE,
6264 "/api/v1/secrets/:scope": SECRET,
6365 "/api/v1/user/origins/:id/browse/:owner/:name": ORIGIN,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs