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

Merged opened this

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

Merged

mason/forge:mason/6307-the-webhooks-settings-page-and-its-forms → main 2 commits fork

  • Merged. mason/6307-the-webhooks-settings-page-and-its-forms was merged into main as ba52ec1.

Review

Sign in to review this change.
3 files changed +193 −177
src/http/server.ts +1 −3
@@ -376,7 +376,6 @@ import {
376376 crewRoleUpdate,
377377 crewSettingsPage,
378378 dangerPage,
379 hooksPage,
380379 promptSourceAdd,
381380 promptSourceDelete,
382381 promptSourceRun,
@@ -388,9 +387,8 @@ import {
388387 archiveSubmit,
389388 provenanceSubmit,
390389 mirrorStop,
391 webhookCreate,
392 webhookDelete,
393390 } from "../web/pages/settings.ts";
391 import { hooksPage, webhookCreate, webhookDelete } from "../web/pages/hooks.ts";
394392 import {
395393 labelCreate,
396394 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 @@
11 /**
22 * 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`.
55 *
66 * Four pages rather than one long scroll, because a settings page you have to
77 * 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";
5050 import { getAgentByName, isCrewRole, setCrewRole } from "../../core/agents.ts";
5151 import { isAutonomy, isDecisionsBy } from "../../core/repos.ts";
5252 import {
53 createWebhook,
5453 deleteRepo,
55 deleteWebhook,
5654 updateRepoSettings,
5755 } from "../../actions/repos.ts";
5856 import { appsRuntime } from "../../apps/runtime.ts";
@@ -73,7 +71,6 @@ import {
7371 import { canAdmin, type Repo } from "../../core/repos.ts";
7472 import { stopMirroring } from "../../actions/mirror_target.ts";
7573 import { getMirrorTarget, mirrorLine } from "../../core/migrate_mirror.ts";
76 import { listWebhooks, recentDeliveries } from "../../core/webhooks.ts";
7774 import { listRefs } from "../../git/read.ts";
7875 import { repoDir } from "../../git/repos.ts";
7976 import type { Ctx } from "../../http/context.ts";
@@ -102,8 +99,6 @@ import {
10299 btn,
103100 card,
104101 confirmAction,
105 dot,
106 empty,
107102 field as formField,
108103 row,
109104 selectField,
@@ -1733,173 +1728,6 @@ export async function crewRemove(ctx: Ctx): Promise<Response> {
17331728 );
17341729 }
17351730
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
19031731 // ---- danger zone ----
19041732
19051733 export function dangerPage(ctx: Ctx): Response {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs