| 1 | // The Webhooks app — admin management surface for incoming webhooks (#610, |
| 2 | // docs/webhooks.md). Admin-only and host-owned like the Admin app: webhooks |
| 3 | // are real server state, not thread events, so everything reads and writes |
| 4 | // through the host bridge (setSetupBridge in shell.js) — the app itself holds |
| 5 | // no tokens, no keys, and no fetch calls of its own. |
| 6 | // |
| 7 | // What a webhook IS: an agent identity that turns inbound HTTP on |
| 8 | // https://in.<host>/h/<secret> into encrypted `webhooks.<ns>` events posted |
| 9 | // into ONE thread. This app creates them (name + event namespace + target |
| 10 | // thread), lists them with their URLs and deploy state, and removes them. |
| 11 | // |
| 12 | // Deliberately, this app claims NO wire event types: `webhooks.<ns>` events |
| 13 | // belong to the CONSUMER apps that render them (a CI dashboard claiming |
| 14 | // "webhooks.github", say). Until such an app claims a type, its events are |
| 15 | // silently ignored — the platform contract for unclaimed types |
| 16 | // (test/event-vectors/webhooks.json). |
| 17 | import { defineApp, namespace, setupBridge, registerFloorCommand, openAppThread } from "../apps.js"; |
| 18 | |
| 19 | const N = namespace("webhooks"); |
| 20 | |
| 21 | // /webhooks works from any chat, gated on role exactly like /admin. |
| 22 | registerFloorCommand("/webhooks", { |
| 23 | desc: "Manage incoming webhooks (admins only)", |
| 24 | run: async (_args, _threadId, reply) => { |
| 25 | const bridge = setupBridge(); |
| 26 | if (!bridge || !(await bridge.isAdmin())) { |
| 27 | reply({ text: "Only this space's admins can manage webhooks." }); |
| 28 | return; |
| 29 | } |
| 30 | openAppThread("webhooks"); |
| 31 | }, |
| 32 | }); |
| 33 | |
| 34 | const NS_RE = /^[a-z0-9][a-z0-9-]{0,31}$/; |
| 35 | |
| 36 | // One refresh timer per mounted pane, cancelled via ctx.onCleanup — polling |
| 37 | // only runs while a not-yet-deployed webhook is on screen, to flip its |
| 38 | // "Deploying…" chip to "Active" without a manual refresh. |
| 39 | let refreshTimer = null; |
| 40 | |
| 41 | export const app = defineApp({ |
| 42 | name: "webhooks", |
| 43 | title: "Webhooks", |
| 44 | glyph: "📥", |
| 45 | places: [], // host-mounted, admin-only — never in the Apps picker for members |
| 46 | snapshot: false, |
| 47 | initialState: () => ({}), |
| 48 | events: {}, // no wire types — see the header comment |
| 49 | render(_state, el, ctx) { |
| 50 | paint(el, ctx); |
| 51 | }, |
| 52 | styles: ` |
| 53 | .${N.cls("root")} { padding: 16px; max-width: 640px; margin: 0 auto; overflow-y: auto; height: 100%; box-sizing: border-box; } |
| 54 | .${N.cls("root")} h2 { margin: 0 0 4px; } |
| 55 | .${N.cls("blurb")} { color: var(--muted, #777); margin: 0 0 16px; font-size: 0.92em; } |
| 56 | .${N.cls("card")} { border: 1px solid var(--border, #ccc3); border-radius: 10px; padding: 12px; margin: 0 0 10px; } |
| 57 | .${N.cls("card")} h3 { margin: 0 0 2px; font-size: 1em; } |
| 58 | .${N.cls("meta")} { color: var(--muted, #777); font-size: 0.85em; margin: 0 0 8px; } |
| 59 | .${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; } |
| 60 | .${N.cls("chip-live")} { border-color: #2e7d3266; } |
| 61 | .${N.cls("url")} { display: flex; gap: 6px; align-items: center; } |
| 62 | .${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; } |
| 63 | .${N.cls("root")} button { min-height: 28px; border-radius: 6px; } |
| 64 | .${N.cls("row")} { display: flex; gap: 8px; margin: 8px 0 0; flex-wrap: wrap; } |
| 65 | .${N.cls("form")} input, .${N.cls("form")} select { padding: 6px 8px; border-radius: 6px; border: 1px solid var(--border, #ccc4); min-width: 0; } |
| 66 | .${N.cls("form")} label { display: block; font-size: 0.85em; margin: 10px 0 2px; } |
| 67 | .${N.cls("note")} { font-size: 0.85em; margin: 8px 0 0; } |
| 68 | .${N.cls("note-err")} { color: #c62828; } |
| 69 | .${N.cls("note-ok")} { color: #2e7d32; } |
| 70 | `, |
| 71 | }); |
| 72 | |
| 73 | async function paint(el, ctx) { |
| 74 | if (refreshTimer) { |
| 75 | clearTimeout(refreshTimer); |
| 76 | refreshTimer = null; |
| 77 | } |
| 78 | el.textContent = ""; |
| 79 | const root = document.createElement("div"); |
| 80 | root.className = N.cls("root"); |
| 81 | el.appendChild(root); |
| 82 | |
| 83 | const h = document.createElement("h2"); |
| 84 | h.textContent = "Incoming webhooks"; |
| 85 | const blurb = document.createElement("p"); |
| 86 | blurb.className = N.cls("blurb"); |
| 87 | blurb.textContent = |
| 88 | "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."; |
| 89 | root.append(h, blurb); |
| 90 | |
| 91 | const bridge = setupBridge(); |
| 92 | if (!bridge || !bridge.hooksList) return; |
| 93 | if (!(await bridge.isAdmin())) { |
| 94 | root.appendChild(para("Only this space's admins can manage webhooks.")); |
| 95 | return; |
| 96 | } |
| 97 | |
| 98 | const svc = await bridge.hooksService(); |
| 99 | if (!svc || !svc.public_key) { |
| 100 | root.appendChild( |
| 101 | para( |
| 102 | "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>", |
| 103 | ), |
| 104 | ); |
| 105 | return; |
| 106 | } |
| 107 | |
| 108 | const list = document.createElement("div"); |
| 109 | root.appendChild(list); |
| 110 | const rows = await bridge.hooksList(); |
| 111 | ctx.onCleanup(() => { |
| 112 | if (refreshTimer) clearTimeout(refreshTimer); |
| 113 | refreshTimer = null; |
| 114 | }); |
| 115 | if (rows === null) { |
| 116 | list.appendChild(para("Couldn't load webhooks — try again in a moment.")); |
| 117 | } else if (!rows.length) { |
| 118 | list.appendChild(para("No webhooks yet.")); |
| 119 | } else { |
| 120 | for (const hk of rows) list.appendChild(hookCard(hk, el, ctx)); |
| 121 | // While any webhook is still deploying, refresh gently so the chip flips |
| 122 | // to Active when the sidecar's next poll picks it up. |
| 123 | if (rows.some((r) => !r.deployed) && el.isConnected) { |
| 124 | refreshTimer = setTimeout(() => { |
| 125 | if (el.isConnected) paint(el, ctx); |
| 126 | }, 5000); |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | root.appendChild(createForm(el, ctx, bridge)); |
| 131 | } |
| 132 | |
| 133 | function para(text) { |
| 134 | const p = document.createElement("p"); |
| 135 | p.className = N.cls("blurb"); |
| 136 | p.textContent = text; |
| 137 | return p; |
| 138 | } |
| 139 | |
| 140 | function hookCard(hk, el, ctx) { |
| 141 | const card = document.createElement("div"); |
| 142 | card.className = N.cls("card"); |
| 143 | |
| 144 | const title = document.createElement("h3"); |
| 145 | title.textContent = hk.name || "Unnamed webhook"; |
| 146 | const status = document.createElement("span"); |
| 147 | status.className = N.cls("chip") + (hk.deployed ? " " + N.cls("chip-live") : ""); |
| 148 | status.textContent = hk.deployed ? "Active" : "Deploying…"; |
| 149 | title.appendChild(status); |
| 150 | |
| 151 | const meta = document.createElement("p"); |
| 152 | meta.className = N.cls("meta"); |
| 153 | meta.textContent = `Events: webhooks.${hk.ns || "?"} → ${hk.target || "a chat"}`; |
| 154 | |
| 155 | card.append(title, meta); |
| 156 | |
| 157 | if (hk.url) { |
| 158 | const urlRow = document.createElement("div"); |
| 159 | urlRow.className = N.cls("url"); |
| 160 | const code = document.createElement("code"); |
| 161 | code.textContent = hk.url; |
| 162 | const copy = document.createElement("button"); |
| 163 | copy.type = "button"; |
| 164 | copy.textContent = "Copy"; |
| 165 | copy.setAttribute("aria-label", `Copy the URL for ${hk.name}`); |
| 166 | copy.onclick = async () => { |
| 167 | try { |
| 168 | await navigator.clipboard.writeText(hk.url); |
| 169 | copy.textContent = "Copied"; |
| 170 | setTimeout(() => (copy.textContent = "Copy"), 1500); |
| 171 | } catch { |
| 172 | copy.textContent = "Select + copy"; |
| 173 | } |
| 174 | }; |
| 175 | urlRow.append(code, copy); |
| 176 | card.appendChild(urlRow); |
| 177 | } |
| 178 | |
| 179 | const actions = document.createElement("div"); |
| 180 | actions.className = N.cls("row"); |
| 181 | const remove = document.createElement("button"); |
| 182 | remove.type = "button"; |
| 183 | remove.textContent = "Remove"; |
| 184 | remove.setAttribute("aria-label", `Remove the webhook ${hk.name}`); |
| 185 | let armed = false; |
| 186 | remove.onclick = async () => { |
| 187 | if (!armed) { |
| 188 | // Two-tap confirm, in place: removal kills the URL and the identity. |
| 189 | armed = true; |
| 190 | remove.textContent = "Really remove? The URL stops working."; |
| 191 | return; |
| 192 | } |
| 193 | remove.disabled = true; |
| 194 | const ok = await setupBridge().hooksRemove(hk.id); |
| 195 | if (ok) paint(el, ctx); |
| 196 | else { |
| 197 | remove.disabled = false; |
| 198 | armed = false; |
| 199 | remove.textContent = "Remove"; |
| 200 | card.appendChild(note("Couldn't remove it — try again.", true)); |
| 201 | } |
| 202 | }; |
| 203 | actions.appendChild(remove); |
| 204 | card.appendChild(actions); |
| 205 | return card; |
| 206 | } |
| 207 | |
| 208 | function note(text, isErr) { |
| 209 | const p = document.createElement("p"); |
| 210 | p.className = N.cls("note") + " " + (isErr ? N.cls("note-err") : N.cls("note-ok")); |
| 211 | p.textContent = text; |
| 212 | return p; |
| 213 | } |
| 214 | |
| 215 | function createForm(el, ctx, bridge) { |
| 216 | const form = document.createElement("form"); |
| 217 | form.className = N.cls("card") + " " + N.cls("form"); |
| 218 | |
| 219 | const title = document.createElement("h3"); |
| 220 | title.textContent = "New webhook"; |
| 221 | form.appendChild(title); |
| 222 | |
| 223 | const nameLabel = document.createElement("label"); |
| 224 | nameLabel.textContent = "Name"; |
| 225 | nameLabel.htmlFor = N.cls("name"); |
| 226 | const name = document.createElement("input"); |
| 227 | name.id = N.cls("name"); |
| 228 | name.maxLength = 64; |
| 229 | name.placeholder = "GitHub deploys"; |
| 230 | name.required = true; |
| 231 | |
| 232 | const nsLabel = document.createElement("label"); |
| 233 | nsLabel.textContent = "Event namespace (events arrive as webhooks.<namespace>)"; |
| 234 | nsLabel.htmlFor = N.cls("ns"); |
| 235 | const ns = document.createElement("input"); |
| 236 | ns.id = N.cls("ns"); |
| 237 | ns.maxLength = 32; |
| 238 | ns.placeholder = "github"; |
| 239 | ns.required = true; |
| 240 | |
| 241 | const targetLabel = document.createElement("label"); |
| 242 | targetLabel.textContent = "Post into"; |
| 243 | targetLabel.htmlFor = N.cls("target"); |
| 244 | const target = document.createElement("select"); |
| 245 | target.id = N.cls("target"); |
| 246 | const spaceOpt = document.createElement("option"); |
| 247 | spaceOpt.value = "space"; |
| 248 | spaceOpt.textContent = "The whole space (space thread)"; |
| 249 | target.appendChild(spaceOpt); |
| 250 | bridge.hooksTargets().then((targets) => { |
| 251 | for (const t of targets) { |
| 252 | const o = document.createElement("option"); |
| 253 | o.value = t.id; |
| 254 | o.textContent = t.name; |
| 255 | target.appendChild(o); |
| 256 | } |
| 257 | }); |
| 258 | |
| 259 | const submit = document.createElement("button"); |
| 260 | submit.type = "submit"; |
| 261 | submit.textContent = "Create webhook"; |
| 262 | const feedback = document.createElement("p"); |
| 263 | feedback.className = N.cls("note"); |
| 264 | feedback.hidden = true; |
| 265 | |
| 266 | form.append(nameLabel, name, nsLabel, ns, targetLabel, target, submit, feedback); |
| 267 | |
| 268 | form.onsubmit = async (e) => { |
| 269 | e.preventDefault(); |
| 270 | const nsVal = ns.value.trim().toLowerCase(); |
| 271 | if (!NS_RE.test(nsVal)) { |
| 272 | show(feedback, "The namespace must be a short lowercase slug — letters, digits, dashes (e.g. github).", true); |
| 273 | return; |
| 274 | } |
| 275 | submit.disabled = true; |
| 276 | show(feedback, "Creating…", false); |
| 277 | const r = await bridge.hooksCreate({ |
| 278 | name: name.value.trim(), |
| 279 | ns: nsVal, |
| 280 | threadId: target.value === "space" ? null : target.value, |
| 281 | space: target.value === "space", |
| 282 | }); |
| 283 | if (r && r.ok) { |
| 284 | paint(el, ctx); // the fresh list shows the new webhook + its URL |
| 285 | } else { |
| 286 | submit.disabled = false; |
| 287 | show(feedback, (r && r.error) || "Couldn't create the webhook — try again.", true); |
| 288 | } |
| 289 | }; |
| 290 | return form; |
| 291 | } |
| 292 | |
| 293 | function show(elm, text, isErr) { |
| 294 | elm.hidden = false; |
| 295 | elm.textContent = text; |
| 296 | elm.className = N.cls("note") + " " + (isErr ? N.cls("note-err") : N.cls("note-ok")); |
| 297 | } |
| 298 | |