Public: anyone can read this; the crew can change it.
The webhooks settings page and its forms leave settings.ts for src/web/pages/hooks.ts #6314
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/6307-the-webhooks-settings-page-and-its-forms → main 2 commits fork
3 files
changed +193
−177
src/http/server.ts +1 −3
| @@ -376,7 +376,6 @@ import { | ||
| 376 | 376 | crewRoleUpdate, |
| 377 | 377 | crewSettingsPage, |
| 378 | 378 | dangerPage, |
| 379 | hooksPage, | |
| 380 | 379 | promptSourceAdd, |
| 381 | 380 | promptSourceDelete, |
| 382 | 381 | promptSourceRun, |
| @@ -388,9 +387,8 @@ import { | ||
| 388 | 387 | archiveSubmit, |
| 389 | 388 | provenanceSubmit, |
| 390 | 389 | mirrorStop, |
| 391 | webhookCreate, | |
| 392 | webhookDelete, | |
| 393 | 390 | } from "../web/pages/settings.ts"; |
| 391 | import { hooksPage, webhookCreate, webhookDelete } from "../web/pages/hooks.ts"; | |
| 394 | 392 | import { |
| 395 | 393 | labelCreate, |
| 396 | 394 | labelDelete, |
src/web/pages/hooks.ts added +190
| @@ -0,0 +1,190 @@ | ||
| 1 | /** | |
| 2 | * Settings › Webhooks, admin-only: the hooks that carry this repository's | |
| 3 | * events out, the last deliveries of each, and the form that adds one. A | |
| 4 | * hook's signing secret is shown once, in the flash after it is created, | |
| 5 | * and never again. | |
| 6 | */ | |
| 7 | ||
| 8 | import { createWebhook, deleteWebhook } from "../../actions/repos.ts"; | |
| 9 | import { notePhase, timePhase } from "../../core/route_timing.ts"; | |
| 10 | import { listWebhooks, recentDeliveries } from "../../core/webhooks.ts"; | |
| 11 | import type { Ctx } from "../../http/context.ts"; | |
| 12 | import { repoBase, repoNotFound } from "../chrome.ts"; | |
| 13 | import { field } from "../form.ts"; | |
| 14 | import { html, when } from "../html.ts"; | |
| 15 | import { flashTo } from "../layout.ts"; | |
| 16 | import { | |
| 17 | btn, | |
| 18 | card, | |
| 19 | confirmAction, | |
| 20 | dot, | |
| 21 | empty, | |
| 22 | field as formField, | |
| 23 | time, | |
| 24 | } from "../ui.ts"; | |
| 25 | import { adminForm, settingsPageResponse, timedAdminRepo } from "./settings.ts"; | |
| 26 | ||
| 27 | export function hooksPage(ctx: Ctx): Response { | |
| 28 | const repo = timedAdminRepo(ctx); | |
| 29 | if (!repo) return repoNotFound(ctx); | |
| 30 | const hooks = timePhase(ctx.request, "hooks", () => listWebhooks(ctx.app.db, repo.id)); | |
| 31 | const recent = timePhase( | |
| 32 | ctx.request, | |
| 33 | "deliveries", | |
| 34 | () => new Map(hooks.map((h) => [h.id, recentDeliveries(ctx.app.db, h.id, 10)] as const)), | |
| 35 | ); | |
| 36 | const rendering = performance.now(); | |
| 37 | const base = repoBase(repo); | |
| 38 | ||
| 39 | const list = | |
| 40 | hooks.length === 0 | |
| 41 | ? empty({ | |
| 42 | icon: "webhook", | |
| 43 | title: "No webhooks yet", | |
| 44 | note: "Every event this repository produces can be POSTed to a URL you control, signed and retried.", | |
| 45 | }) | |
| 46 | : html`${hooks.map((hook) => { | |
| 47 | const deliveries = recent.get(hook.id) ?? []; | |
| 48 | return card( | |
| 49 | deliveries.length === 0 | |
| 50 | ? html`<p class="muted small">No deliveries yet.</p>` | |
| 51 | : html`<div class="table-scroll"> | |
| 52 | <table class="table" data-dense> | |
| 53 | <thead> | |
| 54 | <tr> | |
| 55 | <th>Event</th> | |
| 56 | <th>State</th> | |
| 57 | <th>Attempts</th> | |
| 58 | <th>Last status</th> | |
| 59 | </tr> | |
| 60 | </thead> | |
| 61 | <tbody> | |
| 62 | ${deliveries.map( | |
| 63 | (d) => | |
| 64 | html`<tr> | |
| 65 | <td class="mono">${d.kind}</td> | |
| 66 | <td> | |
| 67 | ${dot( | |
| 68 | d.state === "ok" | |
| 69 | ? "success" | |
| 70 | : d.state === "failed" | |
| 71 | ? "danger" | |
| 72 | : "attn", | |
| 73 | )} | |
| 74 | ${d.state} | |
| 75 | </td> | |
| 76 | <td>${d.attempts}</td> | |
| 77 | <td>${d.lastError ?? d.lastStatus ?? "—"}</td> | |
| 78 | </tr>`, | |
| 79 | )} | |
| 80 | </tbody> | |
| 81 | </table> | |
| 82 | </div>`, | |
| 83 | { | |
| 84 | pad: deliveries.length === 0, | |
| 85 | head: html`<span class="grow truncate mono" title="${hook.url}" | |
| 86 | >${hook.url}</span | |
| 87 | > | |
| 88 | <span class="badge" data-variant="secondary" | |
| 89 | >${hook.kinds}</span | |
| 90 | > | |
| 91 | <span class="badge" | |
| 92 | >${dot(hook.active ? "success" : "attn")}${hook.active | |
| 93 | ? "active" | |
| 94 | : "paused"}</span | |
| 95 | > | |
| 96 | ${confirmAction({ | |
| 97 | action: `${base}/settings/hooks/${hook.id}/delete`, | |
| 98 | verb: "Delete webhook", | |
| 99 | size: "sm", | |
| 100 | })}`, | |
| 101 | foot: html`<span class="muted small" | |
| 102 | >Added | |
| 103 | ${time(hook.createdAt)}${when( | |
| 104 | !hook.hasSecret, | |
| 105 | () => html` · unsigned`, | |
| 106 | )}</span | |
| 107 | >`, | |
| 108 | }, | |
| 109 | ); | |
| 110 | })}`; | |
| 111 | ||
| 112 | const create = card( | |
| 113 | html`<form method="post" action="${base}/settings/hooks"> | |
| 114 | ${formField({ | |
| 115 | label: "Payload URL", | |
| 116 | name: "url", | |
| 117 | type: "url", | |
| 118 | required: true, | |
| 119 | placeholder: "https://example.com/hook", | |
| 120 | hint: "Every delivery is a POST with a JSON body.", | |
| 121 | })} | |
| 122 | ${formField({ | |
| 123 | label: "Events", | |
| 124 | name: "kinds", | |
| 125 | value: "*", | |
| 126 | mono: true, | |
| 127 | hint: "A space-separated list of kinds; * means everything. e.g. push issue.* pull.merged", | |
| 128 | })} | |
| 129 | ${formField({ | |
| 130 | label: "Signing secret", | |
| 131 | name: "secret", | |
| 132 | mono: true, | |
| 133 | hint: "Optional — one is generated for you. Deliveries carry HMAC-SHA256 in X-Forge-Signature-256.", | |
| 134 | })} | |
| 135 | <div class="form-actions"> | |
| 136 | ${btn("Add webhook", { variant: "primary" })} | |
| 137 | </div> | |
| 138 | </form>`, | |
| 139 | { head: html`<h2>New webhook</h2>` }, | |
| 140 | ); | |
| 141 | ||
| 142 | const body = html`${list} | |
| 143 | <div class="mt">${create}</div>`; | |
| 144 | const response = settingsPageResponse(ctx, repo, "hooks", "Webhooks", body); | |
| 145 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 146 | return response; | |
| 147 | } | |
| 148 | ||
| 149 | export async function webhookCreate(ctx: Ctx): Promise<Response> { | |
| 150 | const gate = await adminForm(ctx); | |
| 151 | if (gate instanceof Response) return gate; | |
| 152 | const { repo, form } = gate; | |
| 153 | const providedSecret = field(form, "secret"); | |
| 154 | const created = createWebhook(ctx.app, ctx.auth.user, repo, { | |
| 155 | url: field(form, "url"), | |
| 156 | kinds: field(form, "kinds") || "*", | |
| 157 | ...(providedSecret !== "" ? { secret: providedSecret } : {}), | |
| 158 | }); | |
| 159 | if (!created.ok) { | |
| 160 | return flashTo(ctx, `${repoBase(repo)}/settings/hooks`, { | |
| 161 | tone: "danger", | |
| 162 | message: "That URL is not one this forge will deliver to.", | |
| 163 | }); | |
| 164 | } | |
| 165 | // The only moment the secret exists outside the database: show it once. | |
| 166 | return flashTo(ctx, `${repoBase(repo)}/settings/hooks`, { | |
| 167 | message: | |
| 168 | "Webhook created. Copy its signing secret now — it is never shown again.", | |
| 169 | code: created.value.secret, | |
| 170 | }); | |
| 171 | } | |
| 172 | ||
| 173 | export async function webhookDelete(ctx: Ctx): Promise<Response> { | |
| 174 | const gate = await adminForm(ctx); | |
| 175 | if (gate instanceof Response) return gate; | |
| 176 | const { repo } = gate; | |
| 177 | const removed = deleteWebhook( | |
| 178 | ctx.app, | |
| 179 | ctx.auth.user, | |
| 180 | repo, | |
| 181 | ctx.params.id ?? "", | |
| 182 | ); | |
| 183 | return flashTo( | |
| 184 | ctx, | |
| 185 | `${repoBase(repo)}/settings/hooks`, | |
| 186 | removed.ok | |
| 187 | ? { message: "Webhook deleted." } | |
| 188 | : { tone: "danger", message: "That webhook no longer exists." }, | |
| 189 | ); | |
| 190 | } |
src/web/pages/settings.ts +2 −174
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Repository settings, admin-only: what the repo is (description, visibility, |
| 3 | * default branch, protection), the labels that organise its work, the | |
| 4 | * webhooks that carry its events out, and the one door marked danger. | |
| 3 | * default branch, protection) and the one door marked danger. The labels | |
| 4 | * pane lives in `labels.ts` and the webhooks pane in `hooks.ts`. | |
| 5 | 5 | * |
| 6 | 6 | * Four pages rather than one long scroll, because a settings page you have to |
| 7 | 7 | * hunt through is a settings page people get wrong. Every mutation is its own |
| @@ -50,9 +50,7 @@ import { addToCrew, listCrew, removeFromCrew } from "../../actions/crew.ts"; | ||
| 50 | 50 | import { getAgentByName, isCrewRole, setCrewRole } from "../../core/agents.ts"; |
| 51 | 51 | import { isAutonomy, isDecisionsBy } from "../../core/repos.ts"; |
| 52 | 52 | import { |
| 53 | createWebhook, | |
| 54 | 53 | deleteRepo, |
| 55 | deleteWebhook, | |
| 56 | 54 | updateRepoSettings, |
| 57 | 55 | } from "../../actions/repos.ts"; |
| 58 | 56 | import { appsRuntime } from "../../apps/runtime.ts"; |
| @@ -73,7 +71,6 @@ import { | ||
| 73 | 71 | import { canAdmin, type Repo } from "../../core/repos.ts"; |
| 74 | 72 | import { stopMirroring } from "../../actions/mirror_target.ts"; |
| 75 | 73 | import { getMirrorTarget, mirrorLine } from "../../core/migrate_mirror.ts"; |
| 76 | import { listWebhooks, recentDeliveries } from "../../core/webhooks.ts"; | |
| 77 | 74 | import { listRefs } from "../../git/read.ts"; |
| 78 | 75 | import { repoDir } from "../../git/repos.ts"; |
| 79 | 76 | import type { Ctx } from "../../http/context.ts"; |
| @@ -102,8 +99,6 @@ import { | ||
| 102 | 99 | btn, |
| 103 | 100 | card, |
| 104 | 101 | confirmAction, |
| 105 | dot, | |
| 106 | empty, | |
| 107 | 102 | field as formField, |
| 108 | 103 | row, |
| 109 | 104 | selectField, |
| @@ -1733,173 +1728,6 @@ export async function crewRemove(ctx: Ctx): Promise<Response> { | ||
| 1733 | 1728 | ); |
| 1734 | 1729 | } |
| 1735 | 1730 | |
| 1736 | // ---- webhooks ---- | |
| 1737 | ||
| 1738 | export function hooksPage(ctx: Ctx): Response { | |
| 1739 | const repo = timedAdminRepo(ctx); | |
| 1740 | if (!repo) return repoNotFound(ctx); | |
| 1741 | const hooks = timePhase(ctx.request, "hooks", () => listWebhooks(ctx.app.db, repo.id)); | |
| 1742 | const recent = timePhase( | |
| 1743 | ctx.request, | |
| 1744 | "deliveries", | |
| 1745 | () => new Map(hooks.map((h) => [h.id, recentDeliveries(ctx.app.db, h.id, 10)] as const)), | |
| 1746 | ); | |
| 1747 | const rendering = performance.now(); | |
| 1748 | const base = repoBase(repo); | |
| 1749 | ||
| 1750 | const list = | |
| 1751 | hooks.length === 0 | |
| 1752 | ? empty({ | |
| 1753 | icon: "webhook", | |
| 1754 | title: "No webhooks yet", | |
| 1755 | note: "Every event this repository produces can be POSTed to a URL you control, signed and retried.", | |
| 1756 | }) | |
| 1757 | : html`${hooks.map((hook) => { | |
| 1758 | const deliveries = recent.get(hook.id) ?? []; | |
| 1759 | return card( | |
| 1760 | deliveries.length === 0 | |
| 1761 | ? html`<p class="muted small">No deliveries yet.</p>` | |
| 1762 | : html`<div class="table-scroll"> | |
| 1763 | <table class="table" data-dense> | |
| 1764 | <thead> | |
| 1765 | <tr> | |
| 1766 | <th>Event</th> | |
| 1767 | <th>State</th> | |
| 1768 | <th>Attempts</th> | |
| 1769 | <th>Last status</th> | |
| 1770 | </tr> | |
| 1771 | </thead> | |
| 1772 | <tbody> | |
| 1773 | ${deliveries.map( | |
| 1774 | (d) => | |
| 1775 | html`<tr> | |
| 1776 | <td class="mono">${d.kind}</td> | |
| 1777 | <td> | |
| 1778 | ${dot( | |
| 1779 | d.state === "ok" | |
| 1780 | ? "success" | |
| 1781 | : d.state === "failed" | |
| 1782 | ? "danger" | |
| 1783 | : "attn", | |
| 1784 | )} | |
| 1785 | ${d.state} | |
| 1786 | </td> | |
| 1787 | <td>${d.attempts}</td> | |
| 1788 | <td>${d.lastError ?? d.lastStatus ?? "—"}</td> | |
| 1789 | </tr>`, | |
| 1790 | )} | |
| 1791 | </tbody> | |
| 1792 | </table> | |
| 1793 | </div>`, | |
| 1794 | { | |
| 1795 | pad: deliveries.length === 0, | |
| 1796 | head: html`<span class="grow truncate mono" title="${hook.url}" | |
| 1797 | >${hook.url}</span | |
| 1798 | > | |
| 1799 | <span class="badge" data-variant="secondary" | |
| 1800 | >${hook.kinds}</span | |
| 1801 | > | |
| 1802 | <span class="badge" | |
| 1803 | >${dot(hook.active ? "success" : "attn")}${hook.active | |
| 1804 | ? "active" | |
| 1805 | : "paused"}</span | |
| 1806 | > | |
| 1807 | ${confirmAction({ | |
| 1808 | action: `${base}/settings/hooks/${hook.id}/delete`, | |
| 1809 | verb: "Delete webhook", | |
| 1810 | size: "sm", | |
| 1811 | })}`, | |
| 1812 | foot: html`<span class="muted small" | |
| 1813 | >Added | |
| 1814 | ${time(hook.createdAt)}${when( | |
| 1815 | !hook.hasSecret, | |
| 1816 | () => html` · unsigned`, | |
| 1817 | )}</span | |
| 1818 | >`, | |
| 1819 | }, | |
| 1820 | ); | |
| 1821 | })}`; | |
| 1822 | ||
| 1823 | const create = card( | |
| 1824 | html`<form method="post" action="${base}/settings/hooks"> | |
| 1825 | ${formField({ | |
| 1826 | label: "Payload URL", | |
| 1827 | name: "url", | |
| 1828 | type: "url", | |
| 1829 | required: true, | |
| 1830 | placeholder: "https://example.com/hook", | |
| 1831 | hint: "Every delivery is a POST with a JSON body.", | |
| 1832 | })} | |
| 1833 | ${formField({ | |
| 1834 | label: "Events", | |
| 1835 | name: "kinds", | |
| 1836 | value: "*", | |
| 1837 | mono: true, | |
| 1838 | hint: "A space-separated list of kinds; * means everything. e.g. push issue.* pull.merged", | |
| 1839 | })} | |
| 1840 | ${formField({ | |
| 1841 | label: "Signing secret", | |
| 1842 | name: "secret", | |
| 1843 | mono: true, | |
| 1844 | hint: "Optional — one is generated for you. Deliveries carry HMAC-SHA256 in X-Forge-Signature-256.", | |
| 1845 | })} | |
| 1846 | <div class="form-actions"> | |
| 1847 | ${btn("Add webhook", { variant: "primary" })} | |
| 1848 | </div> | |
| 1849 | </form>`, | |
| 1850 | { head: html`<h2>New webhook</h2>` }, | |
| 1851 | ); | |
| 1852 | ||
| 1853 | const body = html`${list} | |
| 1854 | <div class="mt">${create}</div>`; | |
| 1855 | const response = settingsPageResponse(ctx, repo, "hooks", "Webhooks", body); | |
| 1856 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 1857 | return response; | |
| 1858 | } | |
| 1859 | ||
| 1860 | export async function webhookCreate(ctx: Ctx): Promise<Response> { | |
| 1861 | const gate = await adminForm(ctx); | |
| 1862 | if (gate instanceof Response) return gate; | |
| 1863 | const { repo, form } = gate; | |
| 1864 | const providedSecret = field(form, "secret"); | |
| 1865 | const created = createWebhook(ctx.app, ctx.auth.user, repo, { | |
| 1866 | url: field(form, "url"), | |
| 1867 | kinds: field(form, "kinds") || "*", | |
| 1868 | ...(providedSecret !== "" ? { secret: providedSecret } : {}), | |
| 1869 | }); | |
| 1870 | if (!created.ok) { | |
| 1871 | return flashTo(ctx, `${repoBase(repo)}/settings/hooks`, { | |
| 1872 | tone: "danger", | |
| 1873 | message: "That URL is not one this forge will deliver to.", | |
| 1874 | }); | |
| 1875 | } | |
| 1876 | // The only moment the secret exists outside the database: show it once. | |
| 1877 | return flashTo(ctx, `${repoBase(repo)}/settings/hooks`, { | |
| 1878 | message: | |
| 1879 | "Webhook created. Copy its signing secret now — it is never shown again.", | |
| 1880 | code: created.value.secret, | |
| 1881 | }); | |
| 1882 | } | |
| 1883 | ||
| 1884 | export async function webhookDelete(ctx: Ctx): Promise<Response> { | |
| 1885 | const gate = await adminForm(ctx); | |
| 1886 | if (gate instanceof Response) return gate; | |
| 1887 | const { repo } = gate; | |
| 1888 | const removed = deleteWebhook( | |
| 1889 | ctx.app, | |
| 1890 | ctx.auth.user, | |
| 1891 | repo, | |
| 1892 | ctx.params.id ?? "", | |
| 1893 | ); | |
| 1894 | return flashTo( | |
| 1895 | ctx, | |
| 1896 | `${repoBase(repo)}/settings/hooks`, | |
| 1897 | removed.ok | |
| 1898 | ? { message: "Webhook deleted." } | |
| 1899 | : { tone: "danger", message: "That webhook no longer exists." }, | |
| 1900 | ); | |
| 1901 | } | |
| 1902 | ||
| 1903 | 1731 | // ---- danger zone ---- |
| 1904 | 1732 | |
| 1905 | 1733 | export function dangerPage(ctx: Ctx): Response { |
new above
offline