| 1 | package main |
| 2 | |
| 3 | // Incoming webhooks (issue #610, docs/webhooks.md): the server half of the |
| 4 | // hooks sidecar (`lchat hooks run`) — the INVERSE of the bot sidecar. Where a |
| 5 | // bot is a pure-outbound member that must never listen, a webhook is a |
| 6 | // pure-INBOUND relay: external services POST plaintext to `in.<host>`, the |
| 7 | // sidecar encrypts each request into a typed event (`webhooks.<ns>`) and posts |
| 8 | // it into the one thread that webhook belongs to. The sidecar can never read |
| 9 | // anything back — it never opens a websocket, never fetches messages, and |
| 10 | // never holds the instance key. |
| 11 | // |
| 12 | // The server's role is the member-bots one: store OPAQUE blobs and mint the |
| 13 | // identities' plumbing. |
| 14 | // |
| 15 | // - Each webhook is an ordinary agent member (roster-visible, labelled with |
| 16 | // the webhook's name) whose keypair is minted by the CREATING ADMIN'S |
| 17 | // BROWSER. Its private key + device token + URL secret travel inside |
| 18 | // def_blob, sealed (ECIES) to the hooks sidecar's service identity — the |
| 19 | // server stores ciphertext it cannot open and never sees a usable key. |
| 20 | // - meta_blob (name, namespace, thread, secret → the displayed URL) is |
| 21 | // instance-key-encrypted for the admin app's list view. |
| 22 | // - Thread membership is the routing: the creating admin wraps the thread |
| 23 | // key to the webhook's pubkey, and the webhook may post only there. The |
| 24 | // space thread works via an EXPLICIT membership row — the deliberate |
| 25 | // agent opt-in threadMember() already anticipates. |
| 26 | // |
| 27 | // Management is admin-only (requireAdmin: the break-glass admin token or an |
| 28 | // admin member's own device token). The sidecar authenticates with its agent |
| 29 | // device token; the defs it polls are protected by crypto, not by the gate — |
| 30 | // any member could fetch the ciphertext, only the service identity can open it. |
| 31 | |
| 32 | import ( |
| 33 | "encoding/json" |
| 34 | "net/http" |
| 35 | "strings" |
| 36 | ) |
| 37 | |
| 38 | const ( |
| 39 | // maxWebhookBlobBytes caps each encrypted blob: a definition or meta blob |
| 40 | // is well under 2 KB of JSON; 16 K is lavish headroom. |
| 41 | maxWebhookBlobBytes = 16 * 1024 |
| 42 | // maxWebhooks bounds the registry — every webhook is a roster identity and |
| 43 | // a standing inbound surface, so the cap is an abuse dial like member bots'. |
| 44 | maxWebhooks = 50 |
| 45 | // hooksServiceUserKey is the config row naming the sidecar's service |
| 46 | // identity (set when its agent invite is minted with hooks_service, see |
| 47 | // createKeyInvite). Its pubkey is what creation seals definitions to. |
| 48 | hooksServiceUserKey = "hooks_service_user" |
| 49 | ) |
| 50 | |
| 51 | // getHooksService tells a member whether this space has a hooks sidecar and, if |
| 52 | // so, which roster identity it is — the seal target for definitions. {} when |
| 53 | // none is provisioned (the webhooks app shows its not-set-up empty state). |
| 54 | // Member-token-gated like the roster: the pubkey is roster-public anyway. |
| 55 | func (s *Server) getHooksService(w http.ResponseWriter, r *http.Request) { |
| 56 | if !s.tokenOK(r.URL.Query().Get("token")) { |
| 57 | httpError(w, http.StatusForbidden, "invalid token") |
| 58 | return |
| 59 | } |
| 60 | uid, ok, err := s.db.getConfig(hooksServiceUserKey) |
| 61 | if err != nil { |
| 62 | httpError(w, http.StatusInternalServerError, "db error") |
| 63 | return |
| 64 | } |
| 65 | if !ok || uid == "" || !s.db.userExists(uid) { |
| 66 | writeJSON(w, http.StatusOK, map[string]any{}) |
| 67 | return |
| 68 | } |
| 69 | resp := map[string]any{"user_id": uid, "public_key": s.db.userPublicKey(uid)} |
| 70 | // ingest_base overrides the client's https://in.<host> derivation — set |
| 71 | // only where that derivation is wrong (LCHAT_HOOKS_BASE; local dev runs |
| 72 | // the sidecar on a bare port with no router in front). |
| 73 | if s.hooksBase != "" { |
| 74 | resp["ingest_base"] = s.hooksBase |
| 75 | } |
| 76 | writeJSON(w, http.StatusOK, resp) |
| 77 | } |
| 78 | |
| 79 | // createWebhookHandler mints a webhook: the agent user row (plaintext label, |
| 80 | // like bot labels — the roster discloses the webhook by name), its device |
| 81 | // token (returned ONCE, for the creator to seal into the definition), and its |
| 82 | // thread membership carrying the wrapped thread key. The blobs land in a |
| 83 | // second step (putWebhookBlobsHandler) because the definition can't be sealed |
| 84 | // until the token exists. |
| 85 | func (s *Server) createWebhookHandler(w http.ResponseWriter, r *http.Request) { |
| 86 | if !s.requireAdmin(w, r) { |
| 87 | return |
| 88 | } |
| 89 | var body struct { |
| 90 | Name string `json:"name"` |
| 91 | PublicKey string `json:"public_key"` |
| 92 | ThreadID string `json:"thread_id"` |
| 93 | Wrapped string `json:"wrapped"` |
| 94 | } |
| 95 | if json.NewDecoder(http.MaxBytesReader(w, r.Body, 16*1024)).Decode(&body) != nil { |
| 96 | httpError(w, http.StatusBadRequest, "bad request") |
| 97 | return |
| 98 | } |
| 99 | name := strings.TrimSpace(body.Name) |
| 100 | if name == "" || len(name) > 64 { |
| 101 | httpError(w, http.StatusBadRequest, "name must be 1-64 characters") |
| 102 | return |
| 103 | } |
| 104 | if body.PublicKey == "" || len(body.PublicKey) > 200 || body.Wrapped == "" || len(body.Wrapped) > 4096 { |
| 105 | httpError(w, http.StatusBadRequest, "bad key material") |
| 106 | return |
| 107 | } |
| 108 | switch s.db.threadKind(body.ThreadID) { |
| 109 | case "group", "space": |
| 110 | default: |
| 111 | // DM/self threads keep their one-thread-per-pair invariants; a webhook |
| 112 | // feeds a group or the space thread. |
| 113 | httpError(w, http.StatusBadRequest, "webhooks post into a group or the space thread") |
| 114 | return |
| 115 | } |
| 116 | // When the caller is an admin MEMBER (not the break-glass console token), |
| 117 | // they must be in the target thread — that's whose browser wrapped the |
| 118 | // thread key, so it's also the only honest case. |
| 119 | if uid, ok := s.db.deviceTokenUser(strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))); ok { |
| 120 | if !s.db.threadMember(body.ThreadID, uid) { |
| 121 | httpError(w, http.StatusForbidden, "not a member of that thread") |
| 122 | return |
| 123 | } |
| 124 | } |
| 125 | if n, err := s.db.countWebhooks(); err != nil { |
| 126 | httpError(w, http.StatusInternalServerError, "db error") |
| 127 | return |
| 128 | } else if n >= maxWebhooks { |
| 129 | httpError(w, http.StatusForbidden, "webhook limit reached") |
| 130 | return |
| 131 | } |
| 132 | // The webhook's identity: an agent user labelled with the webhook's name. |
| 133 | // Deliberately NOT gated on botsEnabled — webhooks are integrations with |
| 134 | // their own admin-only gate, not AI bots. |
| 135 | userID, err := s.db.createUser(name) |
| 136 | if err != nil { |
| 137 | httpError(w, http.StatusInternalServerError, "db error") |
| 138 | return |
| 139 | } |
| 140 | // Failures past this point best-effort sweep the half-made identity so a |
| 141 | // retry doesn't strand roster rows (removeUser clears tokens + memberships). |
| 142 | fail := func() { |
| 143 | _ = s.db.removeUser(userID) |
| 144 | httpError(w, http.StatusInternalServerError, "db error") |
| 145 | } |
| 146 | if err := s.db.setUserAgent(userID); err != nil { |
| 147 | fail() |
| 148 | return |
| 149 | } |
| 150 | // The roster badge: a distinct kind so clients can render webhooks as what |
| 151 | // they are (declareBotKind stays bots-only; this is server-assigned). |
| 152 | if err := s.db.setBotKind(userID, "webhook"); err != nil { |
| 153 | fail() |
| 154 | return |
| 155 | } |
| 156 | // The creator's browser minted the keypair, so the pubkey registers here |
| 157 | // (not via POST /api/pubkey — the webhook has no session of its own yet). |
| 158 | // No bind-MAC: webhook identities aren't invite-bootstrapped, so there is |
| 159 | // no invite secret to bind key transparency to; the KT join event still |
| 160 | // journals the key. |
| 161 | if err := s.db.setUserPublicKey(userID, body.PublicKey, ""); err != nil { |
| 162 | fail() |
| 163 | return |
| 164 | } |
| 165 | tok, err := s.db.createDeviceToken(userID, "") |
| 166 | if err != nil { |
| 167 | fail() |
| 168 | return |
| 169 | } |
| 170 | // Membership = routing. For the space thread this is the explicit |
| 171 | // membership row that opts an agent in (threadMember checks rows first); |
| 172 | // for groups it's the ordinary add-member write. |
| 173 | if err := s.db.addThreadMember(body.ThreadID, userID, body.Wrapped); err != nil { |
| 174 | fail() |
| 175 | return |
| 176 | } |
| 177 | id := "wh-" + userID[:16] |
| 178 | if err := s.db.createWebhook(id, userID); err != nil { |
| 179 | fail() |
| 180 | return |
| 181 | } |
| 182 | writeJSON(w, http.StatusOK, map[string]any{"id": id, "user_id": userID, "token": tok}) |
| 183 | } |
| 184 | |
| 185 | // putWebhookBlobsHandler stores the two encrypted blobs — the create flow's |
| 186 | // second step, once the creator has sealed the definition around the token. |
| 187 | func (s *Server) putWebhookBlobsHandler(w http.ResponseWriter, r *http.Request) { |
| 188 | if !s.requireAdmin(w, r) { |
| 189 | return |
| 190 | } |
| 191 | var body struct { |
| 192 | DefBlob string `json:"def_blob"` |
| 193 | MetaBlob string `json:"meta_blob"` |
| 194 | } |
| 195 | if json.NewDecoder(http.MaxBytesReader(w, r.Body, 2*maxWebhookBlobBytes+4096)).Decode(&body) != nil { |
| 196 | httpError(w, http.StatusBadRequest, "bad request") |
| 197 | return |
| 198 | } |
| 199 | if body.DefBlob == "" || len(body.DefBlob) > maxWebhookBlobBytes || |
| 200 | body.MetaBlob == "" || len(body.MetaBlob) > maxWebhookBlobBytes { |
| 201 | httpError(w, http.StatusBadRequest, "blob missing or too large") |
| 202 | return |
| 203 | } |
| 204 | ok, err := s.db.setWebhookBlobs(r.PathValue("id"), body.DefBlob, body.MetaBlob) |
| 205 | if err != nil { |
| 206 | httpError(w, http.StatusInternalServerError, "db error") |
| 207 | return |
| 208 | } |
| 209 | if !ok { |
| 210 | httpError(w, http.StatusNotFound, "no such webhook") |
| 211 | return |
| 212 | } |
| 213 | writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| 214 | } |
| 215 | |
| 216 | // listWebhooksHandler is the admin app's list view: meta blobs (which only |
| 217 | // members can decrypt) plus the plaintext deploy state. |
| 218 | func (s *Server) listWebhooksHandler(w http.ResponseWriter, r *http.Request) { |
| 219 | if !s.requireAdmin(w, r) { |
| 220 | return |
| 221 | } |
| 222 | hooks, err := s.db.listWebhooks() |
| 223 | if err != nil { |
| 224 | httpError(w, http.StatusInternalServerError, "db error") |
| 225 | return |
| 226 | } |
| 227 | writeJSON(w, http.StatusOK, map[string]any{"hooks": hooks}) |
| 228 | } |
| 229 | |
| 230 | // hooksServiceCaller reports whether tok belongs to the RECORDED hooks service |
| 231 | // identity — the narrowest honest gate for the sidecar-facing endpoints. (The |
| 232 | // real protection on definitions is still the seal: only the service |
| 233 | // identity's private key opens one.) |
| 234 | func (s *Server) hooksServiceCaller(tok string) bool { |
| 235 | uid, ok := s.db.deviceTokenUser(tok) |
| 236 | if !ok || !s.db.isAgent(uid) { |
| 237 | return false |
| 238 | } |
| 239 | svc, found, err := s.db.getConfig(hooksServiceUserKey) |
| 240 | return err == nil && found && svc == uid |
| 241 | } |
| 242 | |
| 243 | // listWebhookDefsHandler is the sidecar's poll: definition blobs only. |
| 244 | func (s *Server) listWebhookDefsHandler(w http.ResponseWriter, r *http.Request) { |
| 245 | if !s.hooksServiceCaller(r.URL.Query().Get("token")) { |
| 246 | httpError(w, http.StatusForbidden, "hooks service token required") |
| 247 | return |
| 248 | } |
| 249 | hooks, err := s.db.listWebhookDefs() |
| 250 | if err != nil { |
| 251 | httpError(w, http.StatusInternalServerError, "db error") |
| 252 | return |
| 253 | } |
| 254 | writeJSON(w, http.StatusOK, map[string]any{"hooks": hooks}) |
| 255 | } |
| 256 | |
| 257 | // webhookDeployedHandler records the sidecar's "route is live" ack — plaintext |
| 258 | // by design (a timestamp, no content), the signal the admin app shows as |
| 259 | // Deploying… → Active. |
| 260 | func (s *Server) webhookDeployedHandler(w http.ResponseWriter, r *http.Request) { |
| 261 | var body struct { |
| 262 | Token string `json:"token"` |
| 263 | } |
| 264 | if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body) != nil { |
| 265 | httpError(w, http.StatusBadRequest, "bad request") |
| 266 | return |
| 267 | } |
| 268 | if !s.hooksServiceCaller(body.Token) { |
| 269 | httpError(w, http.StatusForbidden, "hooks service token required") |
| 270 | return |
| 271 | } |
| 272 | id := r.PathValue("id") |
| 273 | if _, exists, err := s.db.getWebhook(id); err != nil { |
| 274 | httpError(w, http.StatusInternalServerError, "db error") |
| 275 | return |
| 276 | } else if !exists { |
| 277 | httpError(w, http.StatusNotFound, "no such webhook") |
| 278 | return |
| 279 | } |
| 280 | if err := s.db.setWebhookDeployed(id); err != nil { |
| 281 | httpError(w, http.StatusInternalServerError, "db error") |
| 282 | return |
| 283 | } |
| 284 | writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| 285 | } |
| 286 | |
| 287 | // deleteWebhookHandler retires a webhook: the row goes, and so does its whole |
| 288 | // identity — removeUser sweeps the device token, memberships, and roster row, |
| 289 | // so the sidecar's next poll drops the route and the URL dies with it. |
| 290 | func (s *Server) deleteWebhookHandler(w http.ResponseWriter, r *http.Request) { |
| 291 | if !s.requireAdmin(w, r) { |
| 292 | return |
| 293 | } |
| 294 | id := r.PathValue("id") |
| 295 | wh, exists, err := s.db.getWebhook(id) |
| 296 | if err != nil { |
| 297 | httpError(w, http.StatusInternalServerError, "db error") |
| 298 | return |
| 299 | } |
| 300 | if !exists { |
| 301 | httpError(w, http.StatusNotFound, "no such webhook") |
| 302 | return |
| 303 | } |
| 304 | if _, err := s.db.deleteWebhook(id); err != nil { |
| 305 | httpError(w, http.StatusInternalServerError, "db error") |
| 306 | return |
| 307 | } |
| 308 | // Sweep the identity only if it really is an agent — a belt-and-braces |
| 309 | // guard so this cascade can never take a person's account with it. |
| 310 | if s.db.isAgent(wh.UserID) { |
| 311 | if err := s.db.removeUser(wh.UserID); err != nil { |
| 312 | httpError(w, http.StatusInternalServerError, "db error") |
| 313 | return |
| 314 | } |
| 315 | } |
| 316 | writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| 317 | } |
| 318 | |