Public: anyone can read this; the crew can change it.
A repository's webhook routes leave api.ts for src/http/api/hooks.ts #7044
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/6333-a-repository-s-webhook-routes-leave-api → main 1 commit fork
2 files
changed +59
−44
src/http/api.ts +2 −44
| @@ -148,9 +148,7 @@ import { runRepos } from "./run_gate.ts"; | ||
| 148 | 148 | import { |
| 149 | 149 | commitToBranch, |
| 150 | 150 | createRepo, |
| 151 | createWebhook, | |
| 152 | 151 | deleteRepo, |
| 153 | deleteWebhook, | |
| 154 | 152 | repositoryLimitMessage, |
| 155 | 153 | resolveOwner, |
| 156 | 154 | privateRepoForSealed, |
| @@ -241,7 +239,6 @@ import { | ||
| 241 | 239 | type User, |
| 242 | 240 | } from "../core/users.ts"; |
| 243 | 241 | import { isPinned } from "../core/pins.ts"; |
| 244 | import { listWebhooks } from "../core/webhooks.ts"; | |
| 245 | 242 | import { diffCommit } from "../git/diff.ts"; |
| 246 | 243 | import { runGit } from "../git/env.ts"; |
| 247 | 244 | import { appLimitWords, appsOwnedBy } from "../actions/app_limit.ts"; |
| @@ -415,6 +412,7 @@ import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts"; | ||
| 415 | 412 | import { passportRoutes } from "./passport_edge.ts"; |
| 416 | 413 | import { egressGrantRoutes } from "./api/egress_grants.ts"; |
| 417 | 414 | import { forgeRoutes } from "./api/forges.ts"; |
| 415 | import { hookRoutes } from "./api/hooks.ts"; | |
| 418 | 416 | import { agentRoutes } from "./api/agents.ts"; |
| 419 | 417 | import { boardRoutes } from "./api/boards.ts"; |
| 420 | 418 | import { labelsRoutes } from "./api/labels.ts"; |
| @@ -4309,47 +4307,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4309 | 4307 | return json(contributionJson(ctx, result.value), 201); |
| 4310 | 4308 | }); |
| 4311 | 4309 | |
| 4312 | // ── webhooks — admin-scoped, since a webhook exfiltrates repo events ── | |
| 4313 | ||
| 4314 | router.add("GET", "/api/v1/repos/:owner/:name/hooks", (ctx) => { | |
| 4315 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 4316 | const denied = requireScope(ctx, "admin"); | |
| 4317 | if (denied) return denied; | |
| 4318 | const repo = loadRepo(ctx); | |
| 4319 | if (!repo || !canAdmin(ctx.app.db, repo, ctx.auth.user)) return notFound(); | |
| 4320 | const page = pageParams(ctx); | |
| 4321 | if (page instanceof Response) return page; | |
| 4322 | const rows = timePhase(ctx.request, "hooks", () => listWebhooks(ctx.app.db, repo.id)); | |
| 4323 | return timePhase(ctx.request, "render", () => pageOf(rows, page)); | |
| 4324 | }); | |
| 4325 | ||
| 4326 | router.add("POST", "/api/v1/repos/:owner/:name/hooks", async (ctx) => { | |
| 4327 | const denied = requireScope(ctx, "admin"); | |
| 4328 | if (denied) return denied; | |
| 4329 | const repo = loadRepo(ctx); | |
| 4330 | if (!repo) return notFound(); | |
| 4331 | const input = await body(ctx.request); | |
| 4332 | const created = createWebhook(ctx.app, ctx.auth.user, repo, { | |
| 4333 | url: str(input.url), | |
| 4334 | kinds: str(input.kinds) || "*", | |
| 4335 | ...(typeof input.secret === "string" ? { secret: input.secret } : {}), | |
| 4336 | }); | |
| 4337 | if (!created.ok) return respondAccess(created.error, 422); | |
| 4338 | // The secret is returned exactly once, at creation. | |
| 4339 | return json( | |
| 4340 | { ...created.value.webhook, secret: created.value.secret }, | |
| 4341 | 201, | |
| 4342 | ); | |
| 4343 | }); | |
| 4344 | ||
| 4345 | router.add("DELETE", "/api/v1/repos/:owner/:name/hooks/:id", (ctx) => { | |
| 4346 | const denied = requireScope(ctx, "admin"); | |
| 4347 | if (denied) return denied; | |
| 4348 | const repo = loadRepo(ctx); | |
| 4349 | if (!repo) return notFound(); | |
| 4350 | const removed = deleteWebhook(ctx.app, ctx.auth.user, repo, ctx.params.id!); | |
| 4351 | return json({}, removed.ok ? 200 : 404); | |
| 4352 | }); | |
| 4310 | hookRoutes(router); | |
| 4353 | 4311 | |
| 4354 | 4312 | // ── origins ────────────────────────────────────────────────────────── |
| 4355 | 4313 | // |
src/http/api/hooks.ts added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | /** | |
| 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, | |
| 5 | * since a webhook exfiltrates repo events. | |
| 6 | */ | |
| 7 | ||
| 8 | import { createWebhook, deleteWebhook } from "../../actions/repos.ts"; | |
| 9 | import { canAdmin } from "../../core/repos.ts"; | |
| 10 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; | |
| 11 | import { listWebhooks } from "../../core/webhooks.ts"; | |
| 12 | import { loadRepo, pageOf, pageParams } from "../api.ts"; | |
| 13 | import type { Handler } from "../context.ts"; | |
| 14 | import { body, json, notFound, requireScope, respondAccess, str } from "../respond.ts"; | |
| 15 | import type { Router } from "../router.ts"; | |
| 16 | ||
| 17 | export function hookRoutes(router: Router<Handler>): void { | |
| 18 | router.add("GET", "/api/v1/repos/:owner/:name/hooks", (ctx) => { | |
| 19 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 20 | const denied = requireScope(ctx, "admin"); | |
| 21 | if (denied) return denied; | |
| 22 | const repo = loadRepo(ctx); | |
| 23 | if (!repo || !canAdmin(ctx.app.db, repo, ctx.auth.user)) return notFound(); | |
| 24 | const page = pageParams(ctx); | |
| 25 | if (page instanceof Response) return page; | |
| 26 | const rows = timePhase(ctx.request, "hooks", () => listWebhooks(ctx.app.db, repo.id)); | |
| 27 | return timePhase(ctx.request, "render", () => pageOf(rows, page)); | |
| 28 | }); | |
| 29 | ||
| 30 | router.add("POST", "/api/v1/repos/:owner/:name/hooks", async (ctx) => { | |
| 31 | const denied = requireScope(ctx, "admin"); | |
| 32 | if (denied) return denied; | |
| 33 | const repo = loadRepo(ctx); | |
| 34 | if (!repo) return notFound(); | |
| 35 | const input = await body(ctx.request); | |
| 36 | const created = createWebhook(ctx.app, ctx.auth.user, repo, { | |
| 37 | url: str(input.url), | |
| 38 | kinds: str(input.kinds) || "*", | |
| 39 | ...(typeof input.secret === "string" ? { secret: input.secret } : {}), | |
| 40 | }); | |
| 41 | if (!created.ok) return respondAccess(created.error, 422); | |
| 42 | // The secret is returned exactly once, at creation. | |
| 43 | return json( | |
| 44 | { ...created.value.webhook, secret: created.value.secret }, | |
| 45 | 201, | |
| 46 | ); | |
| 47 | }); | |
| 48 | ||
| 49 | router.add("DELETE", "/api/v1/repos/:owner/:name/hooks/:id", (ctx) => { | |
| 50 | const denied = requireScope(ctx, "admin"); | |
| 51 | if (denied) return denied; | |
| 52 | const repo = loadRepo(ctx); | |
| 53 | if (!repo) return notFound(); | |
| 54 | const removed = deleteWebhook(ctx.app, ctx.auth.user, repo, ctx.params.id!); | |
| 55 | return json({}, removed.ok ? 200 : 404); | |
| 56 | }); | |
| 57 | } |
new above
offline