// Package hooks is the webhook-ingest sidecar (`lchat hooks run`, issue #610, // docs/webhooks.md) — the INVERSE of the bot sidecar. The bot sidecar is a // pure-outbound client that must never listen; this one exists to listen. It // serves `in.` (a registry route behind the instance router) and turns // each inbound HTTP request into an encrypted typed event posted into the one // thread that webhook feeds. // // It is strictly write-only against the instance: // // - it never opens a websocket and never calls GET /api/messages — nothing // here can read a conversation; // - it never holds the instance key — webhook definitions are sealed // directly to its service identity's pubkey (e2ee.OpenSealed); // - per webhook it holds exactly that webhook's private key + device token // (from the definition), which unwrap exactly one thread's key. // // The outbound path is the Announcer's: unwrap the wrapped thread key, encrypt // the payload, POST /api/messages. Compromise of this process yields the // threads its webhooks feed — never the space's other threads, never a bot's // brain, never the Anthropic key (it has none). package hooks import ( "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "sync" "time" "unicode/utf8" "lchat/internal/bot" "lchat/internal/client" "lchat/internal/e2ee" ) const ( // maxRawBody caps an inbound request body. The server's message-blob cap is // 64K; after JSON framing + encryption + base64 a 40K raw body is the safe // bound. Oversize is rejected with 413 — never silently truncated (#610). maxRawBody = 40 * 1024 // Per-webhook rate limit: a token bucket refilled 1/s with a 60-deep burst. // The server's content-blind message brake still backstops this. rateBurst = 60 ratePerSec = 1 // DefaultPoll is how often the sidecar re-reads its definitions. DefaultPoll = 10 * time.Second ) // headerAllowlist is the ONLY request-header subset captured into events. // Full headers routinely carry sender credentials (Authorization, cookies, // bespoke API keys) that must never enter the log — even encrypted, every // thread member could read them. Signature headers are fine: an HMAC over one // body is evidence, not a reusable secret. var headerAllowlist = []string{ "Content-Type", "User-Agent", "X-Github-Event", "X-Github-Delivery", "X-Github-Hook-Id", "X-Gitlab-Event", "X-Gitlab-Instance", "X-Hub-Signature-256", "X-Event-Key", "X-Request-Id", "X-Slack-Signature", "X-Slack-Request-Timestamp", "X-Webhook-Event", } // Definition is one webhook, as sealed into def_blob by the creating admin's // browser (webhooks.js buildDefinition — the two must stay in step). type Definition struct { V int `json:"v"` ID string `json:"id"` // webhook row id (wh-…) UserID string `json:"user_id"` // the webhook's agent user (event attribution) Name string `json:"name"` // display name (rides inside each event) NS string `json:"ns"` // event namespace → type "webhooks." ThreadID string `json:"thread_id"` // the ONE thread this webhook feeds Secret string `json:"secret"` // URL capability: /h/ Token string `json:"token"` // the webhook's device token (posting) Priv string `json:"priv"` // the webhook's private key (thread-key unwrap) } // route is one live webhook: its definition plus delivery state that survives // definition re-polls (the unwrapped thread key and the rate bucket). type route struct { def Definition mu sync.Mutex threadKey []byte // nil until first delivery fetches + unwraps it tokens float64 // rate-limit bucket lastFill time.Time } // Sidecar is the running ingest process: an HTTP handler over a route table the // definition poll rebuilds. type Sidecar struct { base string sess *client.Session logf func(string, ...any) // Two clients, split by hibernation semantics (bot.SidecarHeader): // polling is MARKED (recurring keepalive-ish traffic must never hold a // hibernating instance awake — it just 503s while the instance sleeps and // the stale route table keeps serving), while delivery is UNMARKED — an // inbound webhook is real external activity and must WAKE a sleeping // instance to land its event. poll *http.Client post *http.Client mu sync.RWMutex routes map[string]*route // secret → route polled bool // one successful poll has filled the table warned map[string]bool // def ids we've already logged as unreadable } // New builds a sidecar for base (the instance origin) with the provisioned // service-identity session. func New(base string, sess *client.Session, logf func(string, ...any)) *Sidecar { if logf == nil { logf = func(string, ...any) {} } return &Sidecar{ base: strings.TrimRight(base, "/"), sess: sess, logf: logf, poll: &http.Client{Timeout: 30 * time.Second, Transport: markTransport{}}, post: &http.Client{Timeout: 30 * time.Second}, routes: map[string]*route{}, warned: map[string]bool{}, } } type markTransport struct{} func (markTransport) RoundTrip(req *http.Request) (*http.Response, error) { req = req.Clone(req.Context()) req.Header.Set(bot.SidecarHeader, "1") return http.DefaultTransport.RoundTrip(req) } // Run polls definitions until ctx ends. The HTTP listener is the caller's // (hookscmd wires Handler() into its server); Run only keeps the table fresh. func (s *Sidecar) Run(ctx context.Context, every time.Duration) { if every <= 0 { every = DefaultPoll } t := time.NewTicker(every) defer t.Stop() for { if err := s.PollOnce(); err != nil { s.logf("hooks: definition poll: %v", err) } select { case <-ctx.Done(): return case <-t.C: } } } // PollOnce re-reads the definition registry, rebuilds the route table (carrying // over per-route delivery state), and acks any not-yet-deployed webhook. func (s *Sidecar) PollOnce() error { // A restarted sidecar has an EMPTY table until a poll succeeds — a // delivery in that window would 404 the sender. So the very first poll is // unmarked (it may wake a sleeping instance, once, at boot); the recurring // ones are marked and simply fail while the instance sleeps, leaving the // last-known table serving. s.mu.RLock() c := s.poll if !s.polled { c = s.post } s.mu.RUnlock() resp, err := c.Get(s.base + "/api/hooks/defs?token=" + url.QueryEscape(s.sess.Token)) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("GET /api/hooks/defs: HTTP %d", resp.StatusCode) } var body struct { Hooks []struct { ID string `json:"id"` DefBlob string `json:"def_blob"` DeployedAt int64 `json:"deployed_at"` } `json:"hooks"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return err } next := make(map[string]*route, len(body.Hooks)) var ack []string s.mu.RLock() prev := s.routes s.mu.RUnlock() prevByID := make(map[string]*route, len(prev)) for _, rt := range prev { prevByID[rt.def.ID] = rt } for _, h := range body.Hooks { var def Definition if err := e2ee.OpenSealed(h.DefBlob, s.sess.UserPriv, &def); err != nil { // Sealed to a different service identity (e.g. re-provisioned // sidecar) or corrupt — fail closed per row, loudly but once. if !s.warned[h.ID] { s.logf("hooks: definition %s is unreadable (sealed to another identity?): %v", h.ID, err) } s.warned[h.ID] = true continue } if def.Secret == "" || def.ThreadID == "" || def.Token == "" || def.Priv == "" || !validNS(def.NS) { if !s.warned[h.ID] { s.logf("hooks: definition %s is malformed — skipping", h.ID) } s.warned[h.ID] = true continue } rt := prevByID[def.ID] if rt == nil || rt.def != def { carried := &route{def: def, tokens: rateBurst, lastFill: time.Now()} if rt != nil { // Same webhook, changed definition: keep the bucket, drop the // cached key only if the thread or identity changed. rt.mu.Lock() carried.tokens, carried.lastFill = rt.tokens, rt.lastFill if rt.def.ThreadID == def.ThreadID && rt.def.Priv == def.Priv { carried.threadKey = rt.threadKey } rt.mu.Unlock() } rt = carried } next[def.Secret] = rt if h.DeployedAt == 0 { ack = append(ack, def.ID) } } s.mu.Lock() s.routes = next s.polled = true s.mu.Unlock() for _, id := range ack { if err := s.ackDeployed(id); err != nil { s.logf("hooks: deploy ack %s: %v", id, err) } else { s.logf("hooks: webhook %s deployed", id) } } return nil } func (s *Sidecar) ackDeployed(id string) error { raw, _ := json.Marshal(map[string]string{"token": s.sess.Token}) resp, err := s.poll.Post(s.base+"/api/hooks/"+url.PathEscape(id)+"/deployed", "application/json", strings.NewReader(string(raw))) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP %d", resp.StatusCode) } return nil } // Routes reports how many webhooks are currently live (logging/tests). func (s *Sidecar) Routes() int { s.mu.RLock() defer s.mu.RUnlock() return len(s.routes) } // Handler is the ingest surface: ANY method on /h/[/]. // Unknown secrets 404 — the sidecar alone knows which webhooks exist; the // router forwards blindly. func (s *Sidecar) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { fmt.Fprintln(w, "ok") }) mux.HandleFunc("/h/", s.serveHook) mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "not found", http.StatusNotFound) }) return mux } func (s *Sidecar) serveHook(w http.ResponseWriter, r *http.Request) { rest := strings.TrimPrefix(r.URL.Path, "/h/") secret, subpath, _ := strings.Cut(rest, "/") s.mu.RLock() rt := s.routes[secret] s.mu.RUnlock() if secret == "" || rt == nil { http.Error(w, "not found", http.StatusNotFound) return } if !rt.allow() { http.Error(w, "rate limited", http.StatusTooManyRequests) return } // Read the capped body; MaxBytesReader makes an over-cap send a hard error // rather than a silent prefix. raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxRawBody)) if err != nil { http.Error(w, "body too large", http.StatusRequestEntityTooLarge) return } event := s.buildEvent(rt.def, r, subpath, raw) if err := s.deliver(rt, event); err != nil { s.logf("hooks: deliver %s: %v", rt.def.ID, err) http.Error(w, "delivery failed", http.StatusBadGateway) return } writeJSON(w, map[string]any{"ok": true}) } // buildEvent shapes the typed event. Dumb ingest, smart render: the raw request // rides inside the event forever, so client-side rendering can improve later // without re-ingesting. Pinned by test/event-vectors/webhooks.json. func (s *Sidecar) buildEvent(def Definition, r *http.Request, subpath string, raw []byte) map[string]any { headers := map[string]string{} for _, h := range headerAllowlist { if v := r.Header.Get(h); v != "" { headers[h] = v } } query := map[string][]string(r.URL.Query()) body, b64 := string(raw), false if !utf8.ValidString(body) { body, b64 = base64.RawURLEncoding.EncodeToString(raw), true } req := map[string]any{ "method": r.Method, "path": "/" + subpath, // after the secret segment; the secret itself never enters the log "query": query, "headers": headers, "body": body, } if b64 { req["body_b64"] = true } return map[string]any{ "type": "webhooks." + def.NS, "name": def.Name, "uid": def.UserID, "ts": time.Now().UnixMilli(), "hook": map[string]any{"id": def.ID, "ns": def.NS}, "req": req, } } // deliver encrypts the event to the webhook's thread key and posts it — the // Announcer's outbound-only walk (unwrap → EncryptJSON → POST /api/messages). func (s *Sidecar) deliver(rt *route, event map[string]any) error { key, err := s.threadKey(rt) if err != nil { return err } blob, err := e2ee.EncryptJSON(key, event) if err != nil { return err } raw, _ := json.Marshal(map[string]string{"thread_id": rt.def.ThreadID, "blob": blob}) resp, err := s.post.Post(s.base+"/api/messages?token="+url.QueryEscape(rt.def.Token), "application/json", strings.NewReader(string(raw))) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("POST /api/messages: HTTP %d", resp.StatusCode) } return nil } // threadKey returns the webhook's unwrapped thread key, fetching it on first // use: GET /api/threads under the WEBHOOK'S OWN token lists exactly the one // thread it was added to, with the key wrapped to its pubkey at creation. func (s *Sidecar) threadKey(rt *route) ([]byte, error) { rt.mu.Lock() cached := rt.threadKey rt.mu.Unlock() if cached != nil { return cached, nil } // Unmarked like deliver: this fetch happens mid-delivery, so it too must // be able to wake a sleeping instance. resp, err := s.post.Get(s.base + "/api/threads?token=" + url.QueryEscape(rt.def.Token)) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GET /api/threads: HTTP %d", resp.StatusCode) } var body struct { Threads []struct { ID string `json:"id"` Wrapped string `json:"wrapped"` } `json:"threads"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return nil, err } for _, t := range body.Threads { if t.ID != rt.def.ThreadID || t.Wrapped == "" { continue } keyB64, err := e2ee.UnwrapThreadKey(t.Wrapped, rt.def.Priv) if err != nil { return nil, fmt.Errorf("unwrap thread key: %w", err) } key, err := e2ee.ImportKey(keyB64) if err != nil { return nil, err } rt.mu.Lock() rt.threadKey = key rt.mu.Unlock() return key, nil } return nil, fmt.Errorf("thread %s not visible to webhook %s (membership missing?)", rt.def.ThreadID, rt.def.ID) } // allow is the per-webhook token bucket. func (rt *route) allow() bool { rt.mu.Lock() defer rt.mu.Unlock() now := time.Now() rt.tokens += now.Sub(rt.lastFill).Seconds() * ratePerSec if rt.tokens > rateBurst { rt.tokens = rateBurst } rt.lastFill = now if rt.tokens < 1 { return false } rt.tokens-- return true } // validNS mirrors the client-side namespace rule: lowercase slug, so the event // type "webhooks." is always a well-formed app event type. func validNS(ns string) bool { if ns == "" || len(ns) > 32 { return false } for i, c := range ns { switch { case c >= 'a' && c <= 'z', c >= '0' && c <= '9': case c == '-' && i > 0: default: return false } } return true } func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) }