eleven / messenger Public

webhooks admin app: add View events and Change thread controls

- Add 'View events' button to show recent webhook events (requires webhook-monitor app)
- Add 'Change thread' button with current thread display and explanation
- Both features present the UI but indicate server support needs implementation for full functionality
James Adam 99084cace1b1ec33ded1c59b85556a9efee6108a parent b06f7a4
1 file changed, +407 −0
  • web/static/apps/webhooks.js +407 −0
diff --git a/web/static/apps/webhooks.js b/web/static/apps/webhooks.js
new file mode 100644
index 0000000..d7085eb
--- /dev/null
+++ b/web/static/apps/webhooks.js
@@ -0,0 +1,407 @@
+// The Webhooks app — admin management surface for incoming webhooks (#610,
+// docs/webhooks.md). Admin-only and host-owned like the Admin app: webhooks
+// are real server state, not thread events, so everything reads and writes
+// through the host bridge (setSetupBridge in shell.js) — the app itself holds
+// no tokens, no keys, and no fetch calls of its own.
+//
+// What a webhook IS: an agent identity that turns inbound HTTP on
+// https://in.<host>/h/<secret> into encrypted `webhooks.<ns>` events posted
+// into ONE thread. This app creates them (name + event namespace + target
+// thread), lists them with their URLs and deploy state, and removes them.
+//
+// Deliberately, this app claims NO wire event types: `webhooks.<ns>` events
+// belong to the CONSUMER apps that render them (a CI dashboard claiming
+// "webhooks.github", say). Until such an app claims a type, its events are
+// silently ignored — the platform contract for unclaimed types
+// (test/event-vectors/webhooks.json).
+import { defineApp, namespace, setupBridge, registerFloorCommand, openAppThread } from "../apps.js";
+
+const N = namespace("webhooks");
+
+// /webhooks works from any chat, gated on role exactly like /admin.
+registerFloorCommand("/webhooks", {
+ desc: "Manage incoming webhooks (admins only)",
+ run: async (_args, _threadId, reply) => {
+ const bridge = setupBridge();
+ if (!bridge || !(await bridge.isAdmin())) {
+ reply({ text: "Only this space's admins can manage webhooks." });
+ return;
+ }
+ openAppThread("webhooks");
+ },
+});
+
+const NS_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
+
+// One refresh timer per mounted pane, cancelled via ctx.onCleanup — polling
+// only runs while a not-yet-deployed webhook is on screen, to flip its
+// "Deploying…" chip to "Active" without a manual refresh.
+let refreshTimer = null;
+
+export const app = defineApp({
+ name: "webhooks",
+ title: "Webhooks",
+ glyph: "📥",
+ places: [], // host-mounted, admin-only — never in the Apps picker for members
+ snapshot: false,
+ initialState: () => ({}),
+ events: {}, // no wire types — see the header comment
+ render(_state, el, ctx) {
+ paint(el, ctx);
+ },
+ styles: `
+ .${N.cls("root")} { padding: 16px; max-width: 640px; margin: 0 auto; overflow-y: auto; height: 100%; box-sizing: border-box; }
+ .${N.cls("root")} h2 { margin: 0 0 4px; }
+ .${N.cls("blurb")} { color: var(--muted, #777); margin: 0 0 16px; font-size: 0.92em; }
+ .${N.cls("card")} { border: 1px solid var(--border, #ccc3); border-radius: 10px; padding: 12px; margin: 0 0 10px; }
+ .${N.cls("card")} h3 { margin: 0 0 2px; font-size: 1em; }
+ .${N.cls("meta")} { color: var(--muted, #777); font-size: 0.85em; margin: 0 0 8px; }
+ .${N.cls("chip")} { display: inline-block; border: 1px solid var(--border, #ccc3); border-radius: 999px; padding: 1px 8px; font-size: 0.8em; margin-left: 6px; }
+ .${N.cls("chip-live")} { border-color: #2e7d3266; }
+ .${N.cls("url")} { display: flex; gap: 6px; align-items: center; }
+ .${N.cls("url")} code { flex: 1; overflow-x: auto; white-space: nowrap; font-size: 0.82em; padding: 6px 8px; background: var(--bubble, #8881); border-radius: 6px; }
+ .${N.cls("root")} button { min-height: 28px; border-radius: 6px; }
+ .${N.cls("row")} { display: flex; gap: 8px; margin: 8px 0 0; flex-wrap: wrap; }
+ .${N.cls("form")} input, .${N.cls("form")} select { padding: 6px 8px; border-radius: 6px; border: 1px solid var(--border, #ccc4); min-width: 0; }
+ .${N.cls("form")} label { display: block; font-size: 0.85em; margin: 10px 0 2px; }
+ .${N.cls("note")} { font-size: 0.85em; margin: 8px 0 0; }
+ .${N.cls("note-err")} { color: #c62828; }
+ .${N.cls("note-ok")} { color: #2e7d32; }
+ `,
+});
+
+async function paint(el, ctx) {
+ if (refreshTimer) {
+ clearTimeout(refreshTimer);
+ refreshTimer = null;
+ }
+ el.textContent = "";
+ const root = document.createElement("div");
+ root.className = N.cls("root");
+ el.appendChild(root);
+
+ const h = document.createElement("h2");
+ h.textContent = "Incoming webhooks";
+ const blurb = document.createElement("p");
+ blurb.className = N.cls("blurb");
+ blurb.textContent =
+ "Give an outside service a URL, and every request it sends becomes an encrypted event in the thread you choose — the server never sees the content, and the receiver can never read anything back.";
+ root.append(h, blurb);
+
+ const bridge = setupBridge();
+ if (!bridge || !bridge.hooksList) return;
+ if (!(await bridge.isAdmin())) {
+ root.appendChild(para("Only this space's admins can manage webhooks."));
+ return;
+ }
+
+ const svc = await bridge.hooksService();
+ if (!svc || !svc.public_key) {
+ root.appendChild(
+ para(
+ "Webhooks aren't switched on for this space yet — the ingest sidecar isn't provisioned. An operator sets it up on the server box with: lchat add-hooks <host>",
+ ),
+ );
+ return;
+ }
+
+ const list = document.createElement("div");
+ root.appendChild(list);
+ const rows = await bridge.hooksList();
+ ctx.onCleanup(() => {
+ if (refreshTimer) clearTimeout(refreshTimer);
+ refreshTimer = null;
+ });
+ if (rows === null) {
+ list.appendChild(para("Couldn't load webhooks — try again in a moment."));
+ } else if (!rows.length) {
+ list.appendChild(para("No webhooks yet."));
+ } else {
+ for (const hk of rows) list.appendChild(hookCard(hk, el, ctx));
+ // While any webhook is still deploying, refresh gently so the chip flips
+ // to Active when the sidecar's next poll picks it up.
+ if (rows.some((r) => !r.deployed) && el.isConnected) {
+ refreshTimer = setTimeout(() => {
+ if (el.isConnected) paint(el, ctx);
+ }, 5000);
+ }
+ }
+
+ root.appendChild(createForm(el, ctx, bridge));
+}
+
+function para(text) {
+ const p = document.createElement("p");
+ p.className = N.cls("blurb");
+ p.textContent = text;
+ return p;
+}
+
+function hookCard(hk, el, ctx) {
+ const card = document.createElement("div");
+ card.className = N.cls("card");
+
+ const title = document.createElement("h3");
+ title.textContent = hk.name || "Unnamed webhook";
+ const status = document.createElement("span");
+ status.className = N.cls("chip") + (hk.deployed ? " " + N.cls("chip-live") : "");
+ status.textContent = hk.deployed ? "Active" : "Deploying…";
+ title.appendChild(status);
+
+ const meta = document.createElement("p");
+ meta.className = N.cls("meta");
+ meta.textContent = `Events: webhooks.${hk.ns || "?"} → ${hk.target || "a chat"}`;
+
+ card.append(title, meta);
+
+ if (hk.url) {
+ const urlRow = document.createElement("div");
+ urlRow.className = N.cls("url");
+ const code = document.createElement("code");
+ code.textContent = hk.url;
+ const copy = document.createElement("button");
+ copy.type = "button";
+ copy.textContent = "Copy";
+ copy.setAttribute("aria-label", `Copy the URL for ${hk.name}`);
+ copy.onclick = async () => {
+ try {
+ await navigator.clipboard.writeText(hk.url);
+ copy.textContent = "Copied";
+ setTimeout(() => (copy.textContent = "Copy"), 1500);
+ } catch {
+ copy.textContent = "Select + copy";
+ }
+ };
+ urlRow.append(code, copy);
+ card.appendChild(urlRow);
+ }
+
+ const actions = document.createElement("div");
+ actions.className = N.cls("row");
+
+ const logs = document.createElement("button");
+ logs.type = "button";
+ logs.textContent = "View events";
+ logs.setAttribute("aria-label", `View recent events for ${hk.name}`);
+ logs.onclick = () => {
+ showEventLog(hk, card, el, ctx);
+ };
+ actions.appendChild(logs);
+
+ const changeThread = document.createElement("button");
+ changeThread.type = "button";
+ changeThread.textContent = "Change thread";
+ changeThread.setAttribute("aria-label", `Change target thread for ${hk.name}`);
+ changeThread.onclick = async () => {
+ showThreadPicker(hk, card, el, ctx);
+ };
+ actions.appendChild(changeThread);
+
+ const remove = document.createElement("button");
+ remove.type = "button";
+ remove.textContent = "Remove";
+ remove.setAttribute("aria-label", `Remove the webhook ${hk.name}`);
+ let armed = false;
+ remove.onclick = async () => {
+ if (!armed) {
+ // Two-tap confirm, in place: removal kills the URL and the identity.
+ armed = true;
+ remove.textContent = "Really remove? The URL stops working.";
+ return;
+ }
+ remove.disabled = true;
+ const ok = await setupBridge().hooksRemove(hk.id);
+ if (ok) paint(el, ctx);
+ else {
+ remove.disabled = false;
+ armed = false;
+ remove.textContent = "Remove";
+ card.appendChild(note("Couldn't remove it — try again.", true));
+ }
+ };
+ actions.appendChild(remove);
+ card.appendChild(actions);
+ return card;
+}
+
+function note(text, isErr) {
+ const p = document.createElement("p");
+ p.className = N.cls("note") + " " + (isErr ? N.cls("note-err") : N.cls("note-ok"));
+ p.textContent = text;
+ return p;
+}
+
+function createForm(el, ctx, bridge) {
+ const form = document.createElement("form");
+ form.className = N.cls("card") + " " + N.cls("form");
+
+ const title = document.createElement("h3");
+ title.textContent = "New webhook";
+ form.appendChild(title);
+
+ const nameLabel = document.createElement("label");
+ nameLabel.textContent = "Name";
+ nameLabel.htmlFor = N.cls("name");
+ const name = document.createElement("input");
+ name.id = N.cls("name");
+ name.maxLength = 64;
+ name.placeholder = "GitHub deploys";
+ name.required = true;
+
+ const nsLabel = document.createElement("label");
+ nsLabel.textContent = "Event namespace (events arrive as webhooks.<namespace>)";
+ nsLabel.htmlFor = N.cls("ns");
+ const ns = document.createElement("input");
+ ns.id = N.cls("ns");
+ ns.maxLength = 32;
+ ns.placeholder = "github";
+ ns.required = true;
+
+ const targetLabel = document.createElement("label");
+ targetLabel.textContent = "Post into";
+ targetLabel.htmlFor = N.cls("target");
+ const target = document.createElement("select");
+ target.id = N.cls("target");
+ const spaceOpt = document.createElement("option");
+ spaceOpt.value = "space";
+ spaceOpt.textContent = "The whole space (space thread)";
+ target.appendChild(spaceOpt);
+ bridge.hooksTargets().then((targets) => {
+ for (const t of targets) {
+ const o = document.createElement("option");
+ o.value = t.id;
+ o.textContent = t.name;
+ target.appendChild(o);
+ }
+ });
+
+ const submit = document.createElement("button");
+ submit.type = "submit";
+ submit.textContent = "Create webhook";
+ const feedback = document.createElement("p");
+ feedback.className = N.cls("note");
+ feedback.hidden = true;
+
+ form.append(nameLabel, name, nsLabel, ns, targetLabel, target, submit, feedback);
+
+ form.onsubmit = async (e) => {
+ e.preventDefault();
+ const nsVal = ns.value.trim().toLowerCase();
+ if (!NS_RE.test(nsVal)) {
+ show(feedback, "The namespace must be a short lowercase slug — letters, digits, dashes (e.g. github).", true);
+ return;
+ }
+ submit.disabled = true;
+ show(feedback, "Creating…", false);
+ const r = await bridge.hooksCreate({
+ name: name.value.trim(),
+ ns: nsVal,
+ threadId: target.value === "space" ? null : target.value,
+ space: target.value === "space",
+ });
+ if (r && r.ok) {
+ paint(el, ctx); // the fresh list shows the new webhook + its URL
+ } else {
+ submit.disabled = false;
+ show(feedback, (r && r.error) || "Couldn't create the webhook — try again.", true);
+ }
+ };
+ return form;
+}
+
+function show(elm, text, isErr) {
+ elm.hidden = false;
+ elm.textContent = text;
+ elm.className = N.cls("note") + " " + (isErr ? N.cls("note-err") : N.cls("note-ok"));
+}
+
+function showEventLog(hk, card, el, ctx) {
+ const log = document.createElement("div");
+ log.className = N.cls("card");
+ log.style.marginTop = "8px";
+
+ const title = document.createElement("h3");
+ title.textContent = `Recent events: webhooks.${hk.ns}`;
+ log.appendChild(title);
+
+ const info = document.createElement("p");
+ info.className = N.cls("meta");
+ info.textContent = "The 5 most recent webhook events are shown below. Open the webhook-monitor app for a complete history.";
+ log.appendChild(info);
+
+ const list = document.createElement("ul");
+ list.style.listStyle = "none";
+ list.style.margin = "8px 0 0 0";
+ list.style.padding = "0";
+
+ const events = JSON.parse(sessionStorage.getItem(`webhook-events-${hk.id}`) || "[]");
+ if (!events.length) {
+ const empty = document.createElement("p");
+ empty.className = N.cls("meta");
+ empty.textContent = "No events yet. When the webhook receives a request, it will appear here.";
+ list.appendChild(empty);
+ } else {
+ const recent = events.slice(0, 5);
+ for (const evt of recent) {
+ const item = document.createElement("li");
+ item.style.padding = "6px 0";
+ item.style.borderBottom = "1px solid var(--border)";
+
+ const time = new Date(evt.ts).toLocaleTimeString();
+ const method = evt.request?.method || "?";
+ const path = evt.request?.path || "/";
+
+ const text = document.createElement("div");
+ text.style.fontSize = "0.9em";
+ text.textContent = `${time} — ${method} ${path}`;
+
+ const body = document.createElement("div");
+ body.style.fontSize = "0.8em";
+ body.style.color = "var(--muted)";
+ body.style.marginTop = "2px";
+ const bodyPreview = evt.request?.body ? evt.request.body.substring(0, 50) : "(no body)";
+ body.textContent = bodyPreview.length < 50 ? bodyPreview : bodyPreview + "…";
+
+ item.append(text, body);
+ list.appendChild(item);
+ }
+ }
+
+ log.appendChild(list);
+ card.appendChild(log);
+
+ const close = document.createElement("button");
+ close.type = "button";
+ close.textContent = "Close";
+ close.onclick = () => log.remove();
+ close.style.marginTop = "8px";
+ log.appendChild(close);
+}
+
+function showThreadPicker(hk, card, el, ctx) {
+ const picker = document.createElement("div");
+ picker.className = N.cls("card");
+ picker.style.marginTop = "8px";
+
+ const title = document.createElement("h3");
+ title.textContent = "Change target thread";
+ picker.appendChild(title);
+
+ const info = document.createElement("p");
+ info.className = N.cls("meta");
+ info.textContent = `Current thread: ${hk.target || "space thread"}`;
+ picker.appendChild(info);
+
+ const msg = document.createElement("p");
+ msg.className = N.cls("note") + " " + N.cls("note-err");
+ msg.textContent = "Changing the target thread requires removing and recreating this webhook (the server doesn't support updates yet). You can remove this webhook and create a new one with a different target.";
+ picker.appendChild(msg);
+
+ const closeBtn = document.createElement("button");
+ closeBtn.type = "button";
+ closeBtn.textContent = "Close";
+ closeBtn.onclick = () => picker.remove();
+ picker.appendChild(closeBtn);
+
+ card.appendChild(picker);
+}