package main // Incoming webhooks (issue #610, docs/webhooks.md): the server half of the // hooks sidecar (`lchat hooks run`) — the INVERSE of the bot sidecar. Where a // bot is a pure-outbound member that must never listen, a webhook is a // pure-INBOUND relay: external services POST plaintext to `in.`, the // sidecar encrypts each request into a typed event (`webhooks.`) and posts // it into the one thread that webhook belongs to. The sidecar can never read // anything back — it never opens a websocket, never fetches messages, and // never holds the instance key. // // The server's role is the member-bots one: store OPAQUE blobs and mint the // identities' plumbing. // // - Each webhook is an ordinary agent member (roster-visible, labelled with // the webhook's name) whose keypair is minted by the CREATING ADMIN'S // BROWSER. Its private key + device token + URL secret travel inside // def_blob, sealed (ECIES) to the hooks sidecar's service identity — the // server stores ciphertext it cannot open and never sees a usable key. // - meta_blob (name, namespace, thread, secret → the displayed URL) is // instance-key-encrypted for the admin app's list view. // - Thread membership is the routing: the creating admin wraps the thread // key to the webhook's pubkey, and the webhook may post only there. The // space thread works via an EXPLICIT membership row — the deliberate // agent opt-in threadMember() already anticipates. // // Management is admin-only (requireAdmin: the break-glass admin token or an // admin member's own device token). The sidecar authenticates with its agent // device token; the defs it polls are protected by crypto, not by the gate — // any member could fetch the ciphertext, only the service identity can open it. import ( "encoding/json" "net/http" "strings" ) const ( // maxWebhookBlobBytes caps each encrypted blob: a definition or meta blob // is well under 2 KB of JSON; 16 K is lavish headroom. maxWebhookBlobBytes = 16 * 1024 // maxWebhooks bounds the registry — every webhook is a roster identity and // a standing inbound surface, so the cap is an abuse dial like member bots'. maxWebhooks = 50 // hooksServiceUserKey is the config row naming the sidecar's service // identity (set when its agent invite is minted with hooks_service, see // createKeyInvite). Its pubkey is what creation seals definitions to. hooksServiceUserKey = "hooks_service_user" ) // getHooksService tells a member whether this space has a hooks sidecar and, if // so, which roster identity it is — the seal target for definitions. {} when // none is provisioned (the webhooks app shows its not-set-up empty state). // Member-token-gated like the roster: the pubkey is roster-public anyway. func (s *Server) getHooksService(w http.ResponseWriter, r *http.Request) { if !s.tokenOK(r.URL.Query().Get("token")) { httpError(w, http.StatusForbidden, "invalid token") return } uid, ok, err := s.db.getConfig(hooksServiceUserKey) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } if !ok || uid == "" || !s.db.userExists(uid) { writeJSON(w, http.StatusOK, map[string]any{}) return } resp := map[string]any{"user_id": uid, "public_key": s.db.userPublicKey(uid)} // ingest_base overrides the client's https://in. derivation — set // only where that derivation is wrong (LCHAT_HOOKS_BASE; local dev runs // the sidecar on a bare port with no router in front). if s.hooksBase != "" { resp["ingest_base"] = s.hooksBase } writeJSON(w, http.StatusOK, resp) } // createWebhookHandler mints a webhook: the agent user row (plaintext label, // like bot labels — the roster discloses the webhook by name), its device // token (returned ONCE, for the creator to seal into the definition), and its // thread membership carrying the wrapped thread key. The blobs land in a // second step (putWebhookBlobsHandler) because the definition can't be sealed // until the token exists. func (s *Server) createWebhookHandler(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var body struct { Name string `json:"name"` PublicKey string `json:"public_key"` ThreadID string `json:"thread_id"` Wrapped string `json:"wrapped"` } if json.NewDecoder(http.MaxBytesReader(w, r.Body, 16*1024)).Decode(&body) != nil { httpError(w, http.StatusBadRequest, "bad request") return } name := strings.TrimSpace(body.Name) if name == "" || len(name) > 64 { httpError(w, http.StatusBadRequest, "name must be 1-64 characters") return } if body.PublicKey == "" || len(body.PublicKey) > 200 || body.Wrapped == "" || len(body.Wrapped) > 4096 { httpError(w, http.StatusBadRequest, "bad key material") return } switch s.db.threadKind(body.ThreadID) { case "group", "space": default: // DM/self threads keep their one-thread-per-pair invariants; a webhook // feeds a group or the space thread. httpError(w, http.StatusBadRequest, "webhooks post into a group or the space thread") return } // When the caller is an admin MEMBER (not the break-glass console token), // they must be in the target thread — that's whose browser wrapped the // thread key, so it's also the only honest case. if uid, ok := s.db.deviceTokenUser(strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))); ok { if !s.db.threadMember(body.ThreadID, uid) { httpError(w, http.StatusForbidden, "not a member of that thread") return } } if n, err := s.db.countWebhooks(); err != nil { httpError(w, http.StatusInternalServerError, "db error") return } else if n >= maxWebhooks { httpError(w, http.StatusForbidden, "webhook limit reached") return } // The webhook's identity: an agent user labelled with the webhook's name. // Deliberately NOT gated on botsEnabled — webhooks are integrations with // their own admin-only gate, not AI bots. userID, err := s.db.createUser(name) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } // Failures past this point best-effort sweep the half-made identity so a // retry doesn't strand roster rows (removeUser clears tokens + memberships). fail := func() { _ = s.db.removeUser(userID) httpError(w, http.StatusInternalServerError, "db error") } if err := s.db.setUserAgent(userID); err != nil { fail() return } // The roster badge: a distinct kind so clients can render webhooks as what // they are (declareBotKind stays bots-only; this is server-assigned). if err := s.db.setBotKind(userID, "webhook"); err != nil { fail() return } // The creator's browser minted the keypair, so the pubkey registers here // (not via POST /api/pubkey — the webhook has no session of its own yet). // No bind-MAC: webhook identities aren't invite-bootstrapped, so there is // no invite secret to bind key transparency to; the KT join event still // journals the key. if err := s.db.setUserPublicKey(userID, body.PublicKey, ""); err != nil { fail() return } tok, err := s.db.createDeviceToken(userID, "") if err != nil { fail() return } // Membership = routing. For the space thread this is the explicit // membership row that opts an agent in (threadMember checks rows first); // for groups it's the ordinary add-member write. if err := s.db.addThreadMember(body.ThreadID, userID, body.Wrapped); err != nil { fail() return } id := "wh-" + userID[:16] if err := s.db.createWebhook(id, userID); err != nil { fail() return } writeJSON(w, http.StatusOK, map[string]any{"id": id, "user_id": userID, "token": tok}) } // putWebhookBlobsHandler stores the two encrypted blobs — the create flow's // second step, once the creator has sealed the definition around the token. func (s *Server) putWebhookBlobsHandler(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var body struct { DefBlob string `json:"def_blob"` MetaBlob string `json:"meta_blob"` } if json.NewDecoder(http.MaxBytesReader(w, r.Body, 2*maxWebhookBlobBytes+4096)).Decode(&body) != nil { httpError(w, http.StatusBadRequest, "bad request") return } if body.DefBlob == "" || len(body.DefBlob) > maxWebhookBlobBytes || body.MetaBlob == "" || len(body.MetaBlob) > maxWebhookBlobBytes { httpError(w, http.StatusBadRequest, "blob missing or too large") return } ok, err := s.db.setWebhookBlobs(r.PathValue("id"), body.DefBlob, body.MetaBlob) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } if !ok { httpError(w, http.StatusNotFound, "no such webhook") return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // listWebhooksHandler is the admin app's list view: meta blobs (which only // members can decrypt) plus the plaintext deploy state. func (s *Server) listWebhooksHandler(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } hooks, err := s.db.listWebhooks() if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } writeJSON(w, http.StatusOK, map[string]any{"hooks": hooks}) } // hooksServiceCaller reports whether tok belongs to the RECORDED hooks service // identity — the narrowest honest gate for the sidecar-facing endpoints. (The // real protection on definitions is still the seal: only the service // identity's private key opens one.) func (s *Server) hooksServiceCaller(tok string) bool { uid, ok := s.db.deviceTokenUser(tok) if !ok || !s.db.isAgent(uid) { return false } svc, found, err := s.db.getConfig(hooksServiceUserKey) return err == nil && found && svc == uid } // listWebhookDefsHandler is the sidecar's poll: definition blobs only. func (s *Server) listWebhookDefsHandler(w http.ResponseWriter, r *http.Request) { if !s.hooksServiceCaller(r.URL.Query().Get("token")) { httpError(w, http.StatusForbidden, "hooks service token required") return } hooks, err := s.db.listWebhookDefs() if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } writeJSON(w, http.StatusOK, map[string]any{"hooks": hooks}) } // webhookDeployedHandler records the sidecar's "route is live" ack — plaintext // by design (a timestamp, no content), the signal the admin app shows as // Deploying… → Active. func (s *Server) webhookDeployedHandler(w http.ResponseWriter, r *http.Request) { var body struct { Token string `json:"token"` } if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body) != nil { httpError(w, http.StatusBadRequest, "bad request") return } if !s.hooksServiceCaller(body.Token) { httpError(w, http.StatusForbidden, "hooks service token required") return } id := r.PathValue("id") if _, exists, err := s.db.getWebhook(id); err != nil { httpError(w, http.StatusInternalServerError, "db error") return } else if !exists { httpError(w, http.StatusNotFound, "no such webhook") return } if err := s.db.setWebhookDeployed(id); err != nil { httpError(w, http.StatusInternalServerError, "db error") return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // deleteWebhookHandler retires a webhook: the row goes, and so does its whole // identity — removeUser sweeps the device token, memberships, and roster row, // so the sidecar's next poll drops the route and the URL dies with it. func (s *Server) deleteWebhookHandler(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } id := r.PathValue("id") wh, exists, err := s.db.getWebhook(id) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } if !exists { httpError(w, http.StatusNotFound, "no such webhook") return } if _, err := s.db.deleteWebhook(id); err != nil { httpError(w, http.StatusInternalServerError, "db error") return } // Sweep the identity only if it really is an agent — a belt-and-braces // guard so this cascade can never take a person's account with it. if s.db.isAgent(wh.UserID) { if err := s.db.removeUser(wh.UserID); err != nil { httpError(w, http.StatusInternalServerError, "db error") return } } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) }