eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// Member bots: autonomous, scheduled agents members create by asking a bot —
4// the other axis from member apps (docs/member-bots.md; issue #432):
5//
6// - member APPS are interactive surfaces you open — sandboxed, no network,
7// foreground-only (memberapps.go).
8// - member BOTS are agents that message you — a standing charter (persona +
9// schedule + task) run unattended by the bot sidecar, which fetches the
10// web and posts the result into a dedicated thread.
11//
12// The server's role here is identical to member apps: store an OPAQUE blob.
13// A charter is encrypted under the instance key before it reaches us; the
14// server learns (random id, author, owner, size, times) and nothing else —
15// not the bot's name, schedule, sources, or prompt. Everything that RUNS
16// lives in the sidecar (internal/bot/charter.go), which is an ordinary E2EE
17// member; the server process never holds a usable key (CLAUDE.md, the one
18// rule).
19//
20// Ownership mirrors putMemberAppHandler: any member may create; an AGENT may
21// set owner_id to the member it built for; overwrite is author-or-owner;
22// delete is author-or-owner-or-admin (moderation without readability).
23
24import (
25 "encoding/json"
26 "net/http"
27 "strings"
28)
29
30const (
31 // maxMemberBotBytes caps one encrypted charter blob — charters are a few
32 // KB of JSON; 64K leaves lavish headroom.
33 maxMemberBotBytes = 64 * 1024
34 // maxMemberBots / maxMemberBotsPerOwner bound the store. Tighter than
35 // apps: every bot is a standing scheduled model run, so the cap is also a
36 // cost/abuse dial.
37 maxMemberBots = 100
38 maxMemberBotsPerOwner = 10
39)
40
41// listMemberBotsHandler returns every stored charter, blobs included —
42// member-token-gated exactly like messages (the sidecar and clients decrypt
43// with the instance key).
44func (s *Server) listMemberBotsHandler(w http.ResponseWriter, r *http.Request) {
45 if !s.tokenOK(r.URL.Query().Get("token")) {
46 httpError(w, http.StatusForbidden, "invalid token")
47 return
48 }
49 bots, err := s.db.listMemberBots()
50 if err != nil {
51 httpError(w, http.StatusInternalServerError, "db error")
52 return
53 }
54 writeJSON(w, http.StatusOK, map[string]any{"bots": bots})
55}
56
57// putMemberBotHandler stores (or updates) a charter. Same id rules and
58// ownership gates as member apps; see the header comment.
59func (s *Server) putMemberBotHandler(w http.ResponseWriter, r *http.Request) {
60 id := r.PathValue("id")
61 if !memberAppID.MatchString(id) { // same slug shape as app ids ("bot-…")
62 httpError(w, http.StatusBadRequest, "bot id must be 6-48 lowercase letters, digits, and dashes")
63 return
64 }
65 var body struct {
66 Token string `json:"token"`
67 Blob string `json:"blob"`
68 OwnerID string `json:"owner_id"`
69 }
70 if json.NewDecoder(http.MaxBytesReader(w, r.Body, maxMemberBotBytes+4096)).Decode(&body) != nil {
71 httpError(w, http.StatusBadRequest, "bad request")
72 return
73 }
74 uid, ok := s.db.deviceTokenUser(body.Token)
75 if !ok {
76 httpError(w, http.StatusForbidden, "invalid token")
77 return
78 }
79 // The bots gate stops new charters (and edits); already-stored charters
80 // keep running in the sidecar — the admin retires those by deletion.
81 if !s.db.botsEnabled() {
82 httpError(w, http.StatusForbidden, "bots are turned off for this space")
83 return
84 }
85 if strings.TrimSpace(body.Blob) == "" || len(body.Blob) > maxMemberBotBytes {
86 httpError(w, http.StatusBadRequest, "blob missing or too large")
87 return
88 }
89 // Resolve the owner: a member owns what they store; only an agent may
90 // create on someone else's behalf (and only for a real user).
91 owner := uid
92 if body.OwnerID != "" && body.OwnerID != uid {
93 if s.db.isAgent(uid) && s.db.userExists(body.OwnerID) {
94 owner = body.OwnerID
95 }
96 }
97 existing, exists, err := s.db.getMemberBot(id)
98 if err != nil {
99 httpError(w, http.StatusInternalServerError, "db error")
100 return
101 }
102 if exists {
103 if existing.AuthorID != uid && existing.OwnerID != uid {
104 httpError(w, http.StatusForbidden, "only the bot's author or owner may update it")
105 return
106 }
107 if err := s.db.putMemberBot(id, body.Blob); err != nil {
108 httpError(w, http.StatusInternalServerError, "db error")
109 return
110 }
111 } else {
112 switch err := s.db.createMemberBot(id, body.Blob, uid, owner, maxMemberBots, maxMemberBotsPerOwner); {
113 case err == ErrMemberBotLimit:
114 httpError(w, http.StatusForbidden, "member bot limit reached")
115 return
116 case err == ErrMemberBotOwnerLimit:
117 httpError(w, http.StatusForbidden, "you've reached your bot limit — retire one first")
118 return
119 case err != nil:
120 httpError(w, http.StatusInternalServerError, "db error")
121 return
122 }
123 }
124 writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id})
125}
126
127// postCharterNextDue receives the charter host's wake-time report: the
128// EARLIEST next run across every member-bot charter the sidecar hosts for this
129// instance ({token, due} — unix seconds; 0 = no live charters). The activator's
130// due-time index (activator.go) reads it back through GET /api/next-due, so a
131// hibernating instance is woken in time for schedules only the sidecar can see.
132//
133// Privacy: this makes one charter wake-time server-visible metadata. That is
134// the same class of leak as scheduled messages, which already carry a plaintext
135// deliver_at (miniapps.go postSchedule) — indeed the charters' armed alarms ARE
136// scheduled messages (internal/bot/chartersched.go). The charter's CONTENT
137// (name, sources, prompt, even which charter is next) stays encrypted; the
138// server learns only "wake me at T", and only the single earliest T at that.
139//
140// Auth: the sidecar is an ordinary E2EE member — it holds device tokens, never
141// the admin token — so this is member-token-gated and then narrowed to
142// bot-kind users, the declareBotKind pattern (a sidecar self-reporting a fact
143// about what it hosts). The value is deliberately low-stakes: any member can
144// already cause wakes (a request wakes; POST /api/schedule feeds the same
145// index), so a forged report buys an attacker nothing new — the agent gate is
146// narrowness, not a security boundary.
147func (s *Server) postCharterNextDue(w http.ResponseWriter, r *http.Request) {
148 var body struct {
149 Token string `json:"token"`
150 Due int64 `json:"due"`
151 }
152 if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body) != nil {
153 httpError(w, http.StatusBadRequest, "bad request")
154 return
155 }
156 uid, ok := s.db.deviceTokenUser(body.Token)
157 if !ok || !s.db.isAgent(uid) {
158 httpError(w, http.StatusForbidden, "bot token required")
159 return
160 }
161 if body.Due < 0 {
162 httpError(w, http.StatusBadRequest, "bad due time")
163 return
164 }
165 if err := s.db.setCharterNextDue(body.Due); err != nil {
166 httpError(w, http.StatusInternalServerError, "db error")
167 return
168 }
169 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
170}
171
172// deleteMemberBotHandler removes a charter: its author or owner retiring their
173// own, or an admin moderating one they can't read.
174func (s *Server) deleteMemberBotHandler(w http.ResponseWriter, r *http.Request) {
175 id := r.PathValue("id")
176 bot, exists, err := s.db.getMemberBot(id)
177 if err != nil {
178 httpError(w, http.StatusInternalServerError, "db error")
179 return
180 }
181 uid, member := s.db.deviceTokenUser(r.URL.Query().Get("token"))
182 authorised := member && exists && (bot.AuthorID == uid || bot.OwnerID == uid)
183 if !authorised && !s.adminBearer(r) {
184 httpError(w, http.StatusForbidden, "author or admin required")
185 return
186 }
187 if !exists {
188 httpError(w, http.StatusNotFound, "no such bot")
189 return
190 }
191 if _, err := s.db.deleteMemberBot(id); err != nil {
192 httpError(w, http.StatusInternalServerError, "db error")
193 return
194 }
195 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
196}
197