| 1 | package 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 | |
| 24 | import ( |
| 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. |
| 37 | var memberAppID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{5,47}$`) |
| 38 | |
| 39 | const ( |
| 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. |
| 58 | func (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). |
| 82 | func (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). |
| 184 | func (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. |
| 214 | func (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. |
| 265 | func (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). |
| 311 | var sandboxFmtImport = regexp.MustCompile(`(?s)import\s*\{[^}]*\}\s*from\s*"\./format\.js";`) |
| 312 | |
| 313 | var ( |
| 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). |
| 327 | func 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. |
| 347 | func (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 | |