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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7870-get-api-v1-repos-owner-name-hooks-id-del → main 7 commits fork
7 files
changed +144
−4
README.md +1 −1
Diff not shown — a line 2,387 characters wide (limit 2,000). +1 −1
View filesrc/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 | ||
| 94 | 94 | export { route as getApiV1ReposOwnerNameForks } from "./getApiV1ReposOwnerNameForks.ts"; |
| 95 | 95 | export { route as getApiV1ReposOwnerNameGatesIdLog } from "./getApiV1ReposOwnerNameGatesIdLog.ts"; |
| 96 | 96 | export { route as getApiV1ReposOwnerNameHooks } from "./getApiV1ReposOwnerNameHooks.ts"; |
| 97 | export { route as getApiV1ReposOwnerNameHooksIdDeliveries } from "./getApiV1ReposOwnerNameHooksIdDeliveries.ts"; | |
| 97 | 98 | export { route as getApiV1ReposOwnerNameIssues } from "./getApiV1ReposOwnerNameIssues.ts"; |
| 98 | 99 | export { route as getApiV1ReposOwnerNameIssuesNumber } from "./getApiV1ReposOwnerNameIssuesNumber.ts"; |
| 99 | 100 | export { route as getApiV1ReposOwnerNameIssuesNumberComments } from "./getApiV1ReposOwnerNameIssuesNumberComments.ts"; |
src/http/api/hooks.ts +14 −3
| @@ -1,14 +1,15 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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, | |
| 5 | 6 | * since a webhook exfiltrates repo events. |
| 6 | 7 | */ |
| 7 | 8 | |
| 8 | 9 | import { createWebhook, deleteWebhook } from "../../actions/repos.ts"; |
| 9 | 10 | import { canAdmin } from "../../core/repos.ts"; |
| 10 | 11 | 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"; | |
| 12 | 13 | import { loadRepo, pageOf, pageParams } from "../api.ts"; |
| 13 | 14 | import type { Handler } from "../context.ts"; |
| 14 | 15 | import { body, json, notFound, requireScope, respondAccess, str } from "../respond.ts"; |
| @@ -27,6 +28,16 @@ export function hookRoutes(router: Router<Handler>): void { | ||
| 27 | 28 | return timePhase(ctx.request, "render", () => pageOf(rows, page)); |
| 28 | 29 | }); |
| 29 | 30 | |
| 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 | ||
| 30 | 41 | router.add("POST", "/api/v1/repos/:owner/:name/hooks", async (ctx) => { |
| 31 | 42 | const denied = requireScope(ctx, "admin"); |
| 32 | 43 | 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", () => { | ||
| 73 | 73 | expect((await tool(aliceAdmin, { repo: "alice/game", action: "remove", id: added.value.id })).error).toContain("not_found"); |
| 74 | 74 | }); |
| 75 | 75 | |
| 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 | ||
| 76 | 89 | test("list pages by limit and cursor, and refuses what the route refuses", async () => { |
| 77 | 90 | const ids: string[] = []; |
| 78 | 91 | 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"; | ||
| 42 | 42 | const GO = "needs a short code minted by a share; the seed shares nothing"; |
| 43 | 43 | const ARCH = "needs an archived-home operation id; the seed archives nothing"; |
| 44 | 44 | 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"; | |
| 45 | 46 | |
| 46 | 47 | /** GET patterns the seed cannot fill → why. Requested with a placeholder all the same. */ |
| 47 | 48 | const UNSEEDED: Record<string, string> = { |
| @@ -58,6 +59,7 @@ const UNSEEDED: Record<string, string> = { | ||
| 58 | 59 | "/api/v1/orgs/:org/teams": ORG, |
| 59 | 60 | "/api/v1/orgs/:org/teams/:team/members": ORG, |
| 60 | 61 | "/api/v1/repos/:owner/:name/gates/:id/log": GATE, |
| 62 | "/api/v1/repos/:owner/:name/hooks/:id/deliveries": HOOK, | |
| 61 | 63 | "/api/v1/repos/:owner/:name/waves/:version": WAVE, |
| 62 | 64 | "/api/v1/secrets/:scope": SECRET, |
| 63 | 65 | "/api/v1/user/origins/:id/browse/:owner/:name": ORIGIN, |
new above
offline