eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package 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
32import (
33 "encoding/json"
34 "net/http"
35 "strings"
36)
37
38const (
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.
55func (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.
85func (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.
187func (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.
218func (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.)
234func (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.
244func (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.
260func (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.
290func (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