package main // Member-built mini-apps: apps written BY members (usually by asking a bot — // see docs/member-apps.md), as opposed to the admin-installed custom_apps. // The two tiers differ in trust, and everything here follows from that: // // - custom_apps are CODE the admin chose to ship: plaintext, served as ES // modules, run in the page with full trust (customapps.go). // - member_apps are USER CONTENT: the { source, manifest } document is // encrypted under the instance key before it reaches us, stored as an // opaque blob under a random id, and NEVER served as an executable. The // client decrypts it and runs it only inside an opaque-origin sandboxed // iframe (web/static/sandbox.js) — the code can't touch keys, tokens, // the page DOM, or the network. // // So the server learns nothing about a member app beyond (random id, author, // owner, size, times) — the same class of metadata a message row leaks; owner is // the member id the app belongs to, so the app is manageable by the person it was // built for. Deploying and listing are member-token-gated; overwriting is // author-or-owner (or an agent acting for that same recorded owner); deleting // is author-or-owner-or-admin, the moderation lever // ("an admin can pull any member app without being able to read it"). import ( "crypto/sha256" "encoding/base64" "encoding/json" "net/http" "regexp" "strings" "sync" ) // memberAppID is the deploy slug: client-minted, random, and meaningless by // design (an app's real name lives inside the encrypted blob). The pattern is // strict enough to double as an injection/path guard. var memberAppID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{5,47}$`) const ( // maxMemberAppBytes caps one encrypted { source, manifest } blob. Mini-apps // are single files (the largest compiled-in one is ~100 KB); base64 plus // encryption overhead still fits comfortably in 1 MB. maxMemberAppBytes = 1 << 20 // maxMemberApps bounds the table so the endpoint can't be used as bulk // storage. Generous: it's a cap per *instance*, and instances are small // groups. maxMemberApps = 200 // maxMemberAppsPerAuthor keeps one member from occupying the whole global // cap and locking everyone else out — a fairness bound within the global one. maxMemberAppsPerAuthor = 40 ) // listMemberAppsHandler returns every deployed member app, encrypted blobs // included — clients decrypt with the instance key and register each app in // the sandbox at boot. Member-token-gated: unlike the custom-app manifest // (plaintext names ≈ branding), these blobs are member content, so only // members may fetch them, exactly like messages. func (s *Server) listMemberAppsHandler(w http.ResponseWriter, r *http.Request) { if !s.tokenOK(r.URL.Query().Get("token")) { httpError(w, http.StatusForbidden, "invalid token") return } apps, err := s.db.listMemberApps() if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } writeJSON(w, http.StatusOK, map[string]any{"apps": apps}) } // putMemberAppHandler deploys (or updates) a member app. Any member may deploy a // new id; only the app's author, its owner, or an agent asserting that same // owner may overwrite an existing one — an app someone relies on can't be // swapped out from under them by a third party. (Admins moderate by deletion, // not by overwrite: they can't author content they can't read.) // // owner_id lets a deploy record who the app belongs to. A regular member can only // deploy apps owned by themselves (no spoofing); an AGENT may set it to the // member it's building for, so a bot-built app is owned+manageable by that member // even though the bot deployed it. Ownership is set at creation and preserved // across updates (it never transfers via an overwrite). func (s *Server) putMemberAppHandler(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if !memberAppID.MatchString(id) { httpError(w, http.StatusBadRequest, "app id must be 6-48 lowercase letters, digits, and dashes") return } var body struct { Token string `json:"token"` Blob string `json:"blob"` OwnerID string `json:"owner_id"` BasedOn int `json:"based_on"` // on a revert: the version this code came from } if json.NewDecoder(http.MaxBytesReader(w, r.Body, maxMemberAppBytes+4096)).Decode(&body) != nil { httpError(w, http.StatusBadRequest, "bad request") return } uid, ok := s.db.deviceTokenUser(body.Token) if !ok { httpError(w, http.StatusForbidden, "invalid token") return } // The apps gates stop new deploys (and edits); already-deployed apps keep // serving — their threads and state live on miniapps.go, which isn't gated. // member_apps_enabled is the narrower switch: the admin keeps built-in apps // but turns off members building/installing their own. if !s.db.appsEnabled() { httpError(w, http.StatusForbidden, "apps are turned off for this space") return } if !s.db.memberAppsEnabled() { httpError(w, http.StatusForbidden, "making your own apps is turned off for this space") return } if strings.TrimSpace(body.Blob) == "" || len(body.Blob) > maxMemberAppBytes { httpError(w, http.StatusBadRequest, "blob missing or too large") return } // Resolve the owner: a member owns what they deploy; only an agent may deploy // on someone else's behalf (and only for a real user). owner := uid if body.OwnerID != "" && body.OwnerID != uid { if s.db.isAgent(uid) && s.db.userExists(body.OwnerID) { owner = body.OwnerID } } existing, exists, err := s.db.getMemberApp(id) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } version := 1 if exists { // Author or owner may overwrite. An AGENT that is neither may also // overwrite ON THE OWNER'S BEHALF when it asserts that same owner: the // sidecar has already checked, against the decrypted manifest, that // the member asking IS the recorded owner — the server can't read the // blob, so the asserted owner_id is that check's visible half. This is // what lets a bot fix an app the member wrote in App Studio (author = // owner = member) or one a different bot built for them. Ownership // still never transfers via an overwrite, and owner-less legacy rows // stay closed to agents. agentForOwner := existing.OwnerID != "" && owner == existing.OwnerID && s.db.isAgent(uid) if existing.AuthorID != uid && existing.OwnerID != uid && !agentForOwner { httpError(w, http.StatusForbidden, "only the app's author or owner may update it") return } if version, err = s.db.putMemberApp(id, body.Blob, uid, body.BasedOn); err != nil { httpError(w, http.StatusInternalServerError, "db error") return } } else { // New app: the cap check + insert are one atomic transaction so // concurrent deploys can't race past the bounds. switch err := s.db.createMemberApp(id, body.Blob, uid, owner, maxMemberApps, maxMemberAppsPerAuthor); { case err == ErrMemberAppLimit: httpError(w, http.StatusForbidden, "member app limit reached") return case err == ErrMemberAppAuthorLimit: httpError(w, http.StatusForbidden, "you've reached your member app limit — retire one first") return case err != nil: httpError(w, http.StatusInternalServerError, "db error") return } } // Same signal the admin console's installs use: connected clients reload // and re-fetch both app tiers. kind := "created" if exists { kind = "updated" } s.broadcastAppsChanged(kind) // The version lets the deployer verify (and tell the member) exactly which // version this deploy produced — the bot sidecar reads it back before it // announces a build as done. writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id, "version": version}) } // deleteMemberAppHandler removes a member app: its author or owner retiring their // own, or an admin moderating one they can't read. Token rides in the query (like // the other member DELETEs); admin auth rides the Authorization header (like // every admin route). func (s *Server) deleteMemberAppHandler(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") app, exists, err := s.db.getMemberApp(id) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } uid, member := s.db.deviceTokenUser(r.URL.Query().Get("token")) authorised := member && exists && (app.AuthorID == uid || app.OwnerID == uid) if !authorised && !s.adminBearer(r) { httpError(w, http.StatusForbidden, "author or admin required") return } if !exists { httpError(w, http.StatusNotFound, "no such app") return } if _, err := s.db.deleteMemberApp(id); err != nil { httpError(w, http.StatusInternalServerError, "db error") return } s.broadcastAppsChanged("removed") writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // memberAppVersionsHandler lists an app's version history (blobs included — only // clients can open them). Gated to the app's author or owner: it's a management // view (App Studio's "History"). Restoring is not a separate route — the client // re-deploys the chosen version's source through the normal PUT, which records it // as a new version, so a restore is non-destructive. func (s *Server) memberAppVersionsHandler(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") app, exists, err := s.db.getMemberApp(id) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } if !exists { httpError(w, http.StatusNotFound, "no such app") return } uid, member := s.db.deviceTokenUser(r.URL.Query().Get("token")) if !member || (app.AuthorID != uid && app.OwnerID != uid) { httpError(w, http.StatusForbidden, "author or owner required") return } versions, err := s.db.listMemberAppVersions(id) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } writeJSON(w, http.StatusOK, map[string]any{"current": app.Version, "versions": versions}) } // serveAppSandbox serves the sandbox frame document — the cage member-app // code runs in. The chat page embeds it as //