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-built mini-apps: apps written BY members (usually by asking a bot —
4// see docs/member-apps.md), as opposed to the admin-installed custom_apps.
5// The two tiers differ in trust, and everything here follows from that:
6//
7// - custom_apps are CODE the admin chose to ship: plaintext, served as ES
8// modules, run in the page with full trust (customapps.go).
9// - member_apps are USER CONTENT: the { source, manifest } document is
10// encrypted under the instance key before it reaches us, stored as an
11// opaque blob under a random id, and NEVER served as an executable. The
12// client decrypts it and runs it only inside an opaque-origin sandboxed
13// iframe (web/static/sandbox.js) — the code can't touch keys, tokens,
14// the page DOM, or the network.
15//
16// So the server learns nothing about a member app beyond (random id, author,
17// owner, size, times) — the same class of metadata a message row leaks; owner is
18// the member id the app belongs to, so the app is manageable by the person it was
19// built for. Deploying and listing are member-token-gated; overwriting is
20// author-or-owner (or an agent acting for that same recorded owner); deleting
21// is author-or-owner-or-admin, the moderation lever
22// ("an admin can pull any member app without being able to read it").
23
24import (
25 "crypto/sha256"
26 "encoding/base64"
27 "encoding/json"
28 "net/http"
29 "regexp"
30 "strings"
31 "sync"
32)
33
34// memberAppID is the deploy slug: client-minted, random, and meaningless by
35// design (an app's real name lives inside the encrypted blob). The pattern is
36// strict enough to double as an injection/path guard.
37var memberAppID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{5,47}$`)
38
39const (
40 // maxMemberAppBytes caps one encrypted { source, manifest } blob. Mini-apps
41 // are single files (the largest compiled-in one is ~100 KB); base64 plus
42 // encryption overhead still fits comfortably in 1 MB.
43 maxMemberAppBytes = 1 << 20
44 // maxMemberApps bounds the table so the endpoint can't be used as bulk
45 // storage. Generous: it's a cap per *instance*, and instances are small
46 // groups.
47 maxMemberApps = 200
48 // maxMemberAppsPerAuthor keeps one member from occupying the whole global
49 // cap and locking everyone else out — a fairness bound within the global one.
50 maxMemberAppsPerAuthor = 40
51)
52
53// listMemberAppsHandler returns every deployed member app, encrypted blobs
54// included — clients decrypt with the instance key and register each app in
55// the sandbox at boot. Member-token-gated: unlike the custom-app manifest
56// (plaintext names ≈ branding), these blobs are member content, so only
57// members may fetch them, exactly like messages.
58func (s *Server) listMemberAppsHandler(w http.ResponseWriter, r *http.Request) {
59 if !s.tokenOK(r.URL.Query().Get("token")) {
60 httpError(w, http.StatusForbidden, "invalid token")
61 return
62 }
63 apps, err := s.db.listMemberApps()
64 if err != nil {
65 httpError(w, http.StatusInternalServerError, "db error")
66 return
67 }
68 writeJSON(w, http.StatusOK, map[string]any{"apps": apps})
69}
70
71// putMemberAppHandler deploys (or updates) a member app. Any member may deploy a
72// new id; only the app's author, its owner, or an agent asserting that same
73// owner may overwrite an existing one — an app someone relies on can't be
74// swapped out from under them by a third party. (Admins moderate by deletion,
75// not by overwrite: they can't author content they can't read.)
76//
77// owner_id lets a deploy record who the app belongs to. A regular member can only
78// deploy apps owned by themselves (no spoofing); an AGENT may set it to the
79// member it's building for, so a bot-built app is owned+manageable by that member
80// even though the bot deployed it. Ownership is set at creation and preserved
81// across updates (it never transfers via an overwrite).
82func (s *Server) putMemberAppHandler(w http.ResponseWriter, r *http.Request) {
83 id := r.PathValue("id")
84 if !memberAppID.MatchString(id) {
85 httpError(w, http.StatusBadRequest, "app id must be 6-48 lowercase letters, digits, and dashes")
86 return
87 }
88 var body struct {
89 Token string `json:"token"`
90 Blob string `json:"blob"`
91 OwnerID string `json:"owner_id"`
92 BasedOn int `json:"based_on"` // on a revert: the version this code came from
93 }
94 if json.NewDecoder(http.MaxBytesReader(w, r.Body, maxMemberAppBytes+4096)).Decode(&body) != nil {
95 httpError(w, http.StatusBadRequest, "bad request")
96 return
97 }
98 uid, ok := s.db.deviceTokenUser(body.Token)
99 if !ok {
100 httpError(w, http.StatusForbidden, "invalid token")
101 return
102 }
103 // The apps gates stop new deploys (and edits); already-deployed apps keep
104 // serving — their threads and state live on miniapps.go, which isn't gated.
105 // member_apps_enabled is the narrower switch: the admin keeps built-in apps
106 // but turns off members building/installing their own.
107 if !s.db.appsEnabled() {
108 httpError(w, http.StatusForbidden, "apps are turned off for this space")
109 return
110 }
111 if !s.db.memberAppsEnabled() {
112 httpError(w, http.StatusForbidden, "making your own apps is turned off for this space")
113 return
114 }
115 if strings.TrimSpace(body.Blob) == "" || len(body.Blob) > maxMemberAppBytes {
116 httpError(w, http.StatusBadRequest, "blob missing or too large")
117 return
118 }
119 // Resolve the owner: a member owns what they deploy; only an agent may deploy
120 // on someone else's behalf (and only for a real user).
121 owner := uid
122 if body.OwnerID != "" && body.OwnerID != uid {
123 if s.db.isAgent(uid) && s.db.userExists(body.OwnerID) {
124 owner = body.OwnerID
125 }
126 }
127 existing, exists, err := s.db.getMemberApp(id)
128 if err != nil {
129 httpError(w, http.StatusInternalServerError, "db error")
130 return
131 }
132 version := 1
133 if exists {
134 // Author or owner may overwrite. An AGENT that is neither may also
135 // overwrite ON THE OWNER'S BEHALF when it asserts that same owner: the
136 // sidecar has already checked, against the decrypted manifest, that
137 // the member asking IS the recorded owner — the server can't read the
138 // blob, so the asserted owner_id is that check's visible half. This is
139 // what lets a bot fix an app the member wrote in App Studio (author =
140 // owner = member) or one a different bot built for them. Ownership
141 // still never transfers via an overwrite, and owner-less legacy rows
142 // stay closed to agents.
143 agentForOwner := existing.OwnerID != "" && owner == existing.OwnerID && s.db.isAgent(uid)
144 if existing.AuthorID != uid && existing.OwnerID != uid && !agentForOwner {
145 httpError(w, http.StatusForbidden, "only the app's author or owner may update it")
146 return
147 }
148 if version, err = s.db.putMemberApp(id, body.Blob, uid, body.BasedOn); err != nil {
149 httpError(w, http.StatusInternalServerError, "db error")
150 return
151 }
152 } else {
153 // New app: the cap check + insert are one atomic transaction so
154 // concurrent deploys can't race past the bounds.
155 switch err := s.db.createMemberApp(id, body.Blob, uid, owner, maxMemberApps, maxMemberAppsPerAuthor); {
156 case err == ErrMemberAppLimit:
157 httpError(w, http.StatusForbidden, "member app limit reached")
158 return
159 case err == ErrMemberAppAuthorLimit:
160 httpError(w, http.StatusForbidden, "you've reached your member app limit — retire one first")
161 return
162 case err != nil:
163 httpError(w, http.StatusInternalServerError, "db error")
164 return
165 }
166 }
167 // Same signal the admin console's installs use: connected clients reload
168 // and re-fetch both app tiers.
169 kind := "created"
170 if exists {
171 kind = "updated"
172 }
173 s.broadcastAppsChanged(kind)
174 // The version lets the deployer verify (and tell the member) exactly which
175 // version this deploy produced — the bot sidecar reads it back before it
176 // announces a build as done.
177 writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id, "version": version})
178}
179
180// deleteMemberAppHandler removes a member app: its author or owner retiring their
181// own, or an admin moderating one they can't read. Token rides in the query (like
182// the other member DELETEs); admin auth rides the Authorization header (like
183// every admin route).
184func (s *Server) deleteMemberAppHandler(w http.ResponseWriter, r *http.Request) {
185 id := r.PathValue("id")
186 app, exists, err := s.db.getMemberApp(id)
187 if err != nil {
188 httpError(w, http.StatusInternalServerError, "db error")
189 return
190 }
191 uid, member := s.db.deviceTokenUser(r.URL.Query().Get("token"))
192 authorised := member && exists && (app.AuthorID == uid || app.OwnerID == uid)
193 if !authorised && !s.adminBearer(r) {
194 httpError(w, http.StatusForbidden, "author or admin required")
195 return
196 }
197 if !exists {
198 httpError(w, http.StatusNotFound, "no such app")
199 return
200 }
201 if _, err := s.db.deleteMemberApp(id); err != nil {
202 httpError(w, http.StatusInternalServerError, "db error")
203 return
204 }
205 s.broadcastAppsChanged("removed")
206 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
207}
208
209// memberAppVersionsHandler lists an app's version history (blobs included — only
210// clients can open them). Gated to the app's author or owner: it's a management
211// view (App Studio's "History"). Restoring is not a separate route — the client
212// re-deploys the chosen version's source through the normal PUT, which records it
213// as a new version, so a restore is non-destructive.
214func (s *Server) memberAppVersionsHandler(w http.ResponseWriter, r *http.Request) {
215 id := r.PathValue("id")
216 app, exists, err := s.db.getMemberApp(id)
217 if err != nil {
218 httpError(w, http.StatusInternalServerError, "db error")
219 return
220 }
221 if !exists {
222 httpError(w, http.StatusNotFound, "no such app")
223 return
224 }
225 uid, member := s.db.deviceTokenUser(r.URL.Query().Get("token"))
226 if !member || (app.AuthorID != uid && app.OwnerID != uid) {
227 httpError(w, http.StatusForbidden, "author or owner required")
228 return
229 }
230 versions, err := s.db.listMemberAppVersions(id)
231 if err != nil {
232 httpError(w, http.StatusInternalServerError, "db error")
233 return
234 }
235 writeJSON(w, http.StatusOK, map[string]any{"current": app.Version, "versions": versions})
236}
237
238// serveAppSandbox serves the sandbox frame document — the cage member-app
239// code runs in. The chat page embeds it as
240// <iframe src="/app-sandbox" sandbox="allow-scripts">, which gives the
241// document an OPAQUE origin: no cookies, no storage, no reach into the parent
242// page. This handler supplies the second half of the containment, the
243// document's own CSP:
244//
245// - default-src/connect-src 'none' — no fetch/XHR/WebSocket/beacon to
246// anywhere, so nothing the app computes can leave (its only channel is
247// the audited postMessage bridge to the host page);
248// - script-src '<sha256 of the runtime>' 'unsafe-eval' — a HASH, not a
249// nonce, deliberately. A nonce is readable by same-document JS
250// (`document.querySelector('script').nonce`), and a nonced `<script src>`
251// bypasses the host allow-list — so hostile app code could read the nonce
252// and pull remote code / exfiltrate to any origin despite connect-src
253// 'none'. A hash authorises EXACTLY the one inline runtime and nothing
254// the app can mint: with no nonce and no host in script-src, every
255// `<script src>` (and every other inline script) is refused. 'unsafe-eval'
256// lets the runtime eval the member app's source (that's the only eval);
257// - img-src data: blob: / style-src 'unsafe-inline' — apps draw freely from
258// data they already have, but can't load remote subresources.
259//
260// The page must be embeddable by the chat page (and only by it), so it
261// overrides the global X-Frame-Options: DENY with SAMEORIGIN and pins
262// frame-ancestors 'self'. It is served ungated like the rest of the static
263// shell — it contains only the runtime, never app code or member data; those
264// arrive over postMessage from a client that already holds the keys.
265func (s *Server) serveAppSandbox(w http.ResponseWriter, r *http.Request) {
266 runtime, err := sandboxBundle()
267 if err != nil {
268 httpError(w, http.StatusInternalServerError, "sandbox runtime missing")
269 return
270 }
271 // The CSP hash is over the exact inline script CONTENT (the bytes between
272 // the <script> tags), which is precisely `runtime`.
273 sum := sha256.Sum256(runtime)
274 hash := "'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'"
275 w.Header().Set("Content-Security-Policy",
276 "default-src 'none'; "+
277 "script-src "+hash+" 'unsafe-eval'; "+
278 "style-src 'unsafe-inline'; "+
279 "img-src data: blob:; "+
280 "font-src data:; "+
281 "media-src data: blob:; "+
282 "connect-src 'none'; "+
283 "frame-src 'none'; object-src 'none'; base-uri 'none'; "+
284 "form-action 'none'; frame-ancestors 'self'")
285 w.Header().Set("X-Frame-Options", "SAMEORIGIN") // override the global DENY
286 w.Header().Set("X-DNS-Prefetch-Control", "off")
287 w.Header().Set("Content-Type", "text/html; charset=utf-8")
288 w.Header().Set("Cache-Control", "no-cache")
289 // The runtime is inlined (not <script src>) so the whole document is one
290 // response and an opaque-origin frame needs no cross-origin module fetch.
291 // sandbox-frame.js must never contain the literal "</script" (pinned by
292 // TestSandboxFrameInlinable), and its bytes must match the hash above.
293 // The app pane sits inside the thread view (.tt { background: var(--panel) }),
294 // so the frame's document defaults to --panel to match the chat panel it's
295 // embedded in — NOT --bg (the page/sidebar colour). applyTheme forwards
296 // --panel/--ink and color-scheme (so the light-dark() resolves right); with no
297 // theme yet it falls back to transparent, never the black UA canvas.
298 // box-sizing matches the parent app's global reset (style.css) — app CSS is
299 // written against that environment, and under content-box a common idiom
300 // like `height:100%; padding:16px` overflows the frame by its padding.
301 w.Write([]byte(`<!doctype html><html><head><meta charset="utf-8">` +
302 `<meta name="referrer" content="no-referrer">` +
303 `<style>*,*::before,*::after{box-sizing:border-box}` +
304 `html,body{margin:0;padding:0;height:100%;background:var(--panel);color:var(--ink)}</style></head><body>` +
305 `<script type="module">` + string(runtime) +
306 `</script></body></html>`))
307}
308
309// sandboxFmtImport matches sandbox-frame.js's `import { … } from "./format.js"`
310// line, which the browser bundle drops (format.js is prepended instead).
311var sandboxFmtImport = regexp.MustCompile(`(?s)import\s*\{[^}]*\}\s*from\s*"\./format\.js";`)
312
313var (
314 sandboxBundleOnce sync.Once
315 sandboxBundleBytes []byte
316 sandboxBundleErr error
317)
318
319// sandboxBundle builds the single module script inlined into /app-sandbox:
320// format.js VERBATIM (its pure text helpers, which member apps may import),
321// followed by sandbox-frame.js with its own `import … from "./format.js"`
322// removed — so the frame's references resolve to format.js's top-level
323// definitions in one self-contained module (an opaque-origin frame can't fetch
324// a second module). Computed once; the bytes are static. Both files are pinned
325// not to contain "</script" (TestSandboxFrameInlinable), and the concatenation
326// has no top-level name collisions (TestSandboxBundle).
327func sandboxBundle() ([]byte, error) {
328 sandboxBundleOnce.Do(func() {
329 fmtSrc, err := webFS.ReadFile("web/static/format.js")
330 if err != nil {
331 sandboxBundleErr = err
332 return
333 }
334 frame, err := webFS.ReadFile("web/static/sandbox-frame.js")
335 if err != nil {
336 sandboxBundleErr = err
337 return
338 }
339 frame = sandboxFmtImport.ReplaceAll(frame, []byte("/* format.js bundled above */"))
340 sandboxBundleBytes = append(append(append([]byte{}, fmtSrc...), '\n'), frame...)
341 })
342 return sandboxBundleBytes, sandboxBundleErr
343}
344
345// adminBearer is requireAdmin's check without the error write, for handlers
346// where admin is one of several ways in rather than the gate.
347func (s *Server) adminBearer(r *http.Request) bool {
348 const p = "Bearer "
349 h := r.Header.Get("Authorization")
350 if !strings.HasPrefix(h, p) {
351 return false
352 }
353 bearer := strings.TrimSpace(strings.TrimPrefix(h, p))
354 if s.db.checkAdmin(bearer) {
355 return true
356 }
357 uid, ok := s.db.deviceTokenUser(bearer)
358 return ok && s.db.userRole(uid) == "admin"
359}
360