// 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./h/ into encrypted `webhooks.` 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.` 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 ", ), ); 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 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.)"; 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")); }