Admin switch for members making their own apps (#863)
* server: admin switch for members making their own apps (member_apps_enabled)
A new per-instance feature gate, narrower than apps_enabled: the space keeps
the built-in apps while members building/installing their own switches off.
Default on; like the other gates, off only stops new use.
- Server: PUT /api/member-apps 403s (new deploys and edits); the member-app
guide 404s, which flips the bot sidecar's create_app builder off with no
protocol change; already-deployed apps keep listing/running and DELETE
stays open (cleanup + moderation are never gated).
- Exposed in /api/admin/settings, the member /api/group read, and an
enable-member-apps page meta.
- Web: App Studio hides from the app pickers when off; toggle added to the
admin console page and the Admin mini-app.
- Native: no change needed - there is no native surface for creating member
apps (they run only in the web sandbox); natives keep consuming the wider
apps_enabled gate as before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TZxyj11Ps9PWFjGB3fmwd
* web: App Studio deploy errors relay the server's reason, not a status guess
A refused deploy showed a guessed message keyed off the 403 alone — "you've
reached your app limit" for a new app — which misleads now that a 403 can
also mean the member-apps gate (or the wider apps gate) is off. httpError
already ships the reason in the {error} body, so show the server's words
and keep the status-code line only as the fallback for an unreadable body.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TZxyj11Ps9PWFjGB3fmwd
* web: App Studio respects the member-apps gate + breathing room on notes
With member apps off, App Studio stops offering what the server will refuse:
the editor (Check/Deploy), Edit code, and History's Revert all hide, and the
header explains the switch and where an admin flips it — the deployed-apps
list stays, with Open / History / Download / Remove. Notes also gain a
margin-top so feedback no longer sits flush against the button bar.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TZxyj11Ps9PWFjGB3fmwd
* web: the member-apps-off notice is a callout, not muted intro text
"Making your own apps is turned off for this space" explained the page's
whole reduced state in the same grey .sub style as the normal intro copy —
easy to read past. It now renders as a note-surface callout with a lock
glyph and a full-ink bold lead; the admin pointer and the "already-deployed
apps keep working" reassurance stay as the muted second line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TZxyj11Ps9PWFjGB3fmwd
---------
Co-authored-by: Claude <noreply@anthropic.com>
16 files changed,
+223
−53
changelog/2026-07-14-member-apps-switch.md+6 −0db.go+13 −0docs/member-apps.md+11 −0docs/no-plaintext.md+2 −1feature_gates_test.go+63 −5main.go+33 −22memberapps.go+7 −1web/pages/admin.html+7 −2web/pages/index.html+1 −0web/static/admin.js+1 −0web/static/apps/admin.js+7 −0web/static/apps/appstudio.js+51 −14web/static/newchat.js+11 −3web/static/sandbox.js+6 −2web/static/shell.js+3 −3web/static/shellstate.js+1 −0
diff --git a/changelog/2026-07-14-member-apps-switch.md b/changelog/2026-07-14-member-apps-switch.md| new file mode 100644 |
| index 0000000..522cafd |
| --- /dev/null |
| +++ b/changelog/2026-07-14-member-apps-switch.md |
| @@ -0,0 +1,6 @@ |
| +🤖 Admins can now decide whether members make their own apps. A new switch in |
| +Admin → Features — "Let members make their own apps" — controls App Studio and |
| +bot-built apps separately from the built-in ones, so a space can keep to-dos, |
| +polls, and whiteboards while turning off member-made apps entirely. It's on by |
| +default, and switching it off never breaks anything already there: apps members |
| +have deployed keep working, and everyone can still remove their own. |
diff --git a/db.go b/db.go| index b33c1f7..3a88124 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -160,6 +160,19 @@ func (db *DB) appsEnabled() bool { |
| } |
| func (db *DB) setAppsEnabled(on bool) error { return db.setConfig("apps_enabled", boolStr(on)) } |
| +// memberAppsEnabled gates one slice of the apps feature: members building and |
| +// installing their OWN apps (App Studio deploys + bot builds). Narrower than |
| +// appsEnabled — a space can keep the built-in apps while switching off member |
| +// authoring. Like the others it only stops new use; apps already deployed keep |
| +// working. |
| +func (db *DB) memberAppsEnabled() bool { |
| + v, ok, _ := db.getConfig("member_apps_enabled") |
| + return !ok || v == "1" |
| +} |
| +func (db *DB) setMemberAppsEnabled(on bool) error { |
| + return db.setConfig("member_apps_enabled", boolStr(on)) |
| +} |
| + |
| // Update settings: whether this instance follows its release channel (see |
| // docs/server-releases.md). Unlike usersCanInvite, the booleans default ON — |
| // an absent key reads true — so a fresh instance keeps itself current unless |
diff --git a/docs/member-apps.md b/docs/member-apps.md| index 554b50f..8f0c762 100644 |
| --- a/docs/member-apps.md |
| +++ b/docs/member-apps.md |
| @@ -137,6 +137,17 @@ untouched. |
| | `GET /app-sandbox` | none (static runtime) | the caged frame document | |
| | `GET /api/member-app-guide` | none | the authoring guide the bot builds against | |
| +Admins can switch the whole tier off with the `member_apps_enabled` setting |
| +(Admin → Features → "Let members make their own apps", default on) — narrower |
| +than the `apps_enabled` gate, which covers the entire apps feature. Off means: |
| +the `PUT` above 403s (new deploys *and* edits), the guide 404s (which flips the |
| +bot sidecar's `create_app` builder off — it treats a failed guide fetch as |
| +"building disabled"), and App Studio hides from the app pickers (an |
| +already-open App Studio thread drops its editor and says why; the deployed-apps |
| +list stays, minus Edit code / Revert). Apps already deployed keep listing and |
| +running, and `DELETE` stays open — the gate stops new use, never cleanup or |
| +moderation. |
| + |
| Ownership: the server's author is whoever deployed (the bot, for bot-built |
| apps). The *requester* is recorded inside the encrypted manifest (`owner`), |
| and the bot refuses to update an app for anyone but its requester — the gate |
diff --git a/docs/no-plaintext.md b/docs/no-plaintext.md| index 85bd816..867d3fa 100644 |
| --- a/docs/no-plaintext.md |
| +++ b/docs/no-plaintext.md |
| @@ -67,7 +67,8 @@ Not encryptable without redesigning the product; enumerated here so the claim |
| above stays honest about its scope: |
| - **Enforcement machinery** the server must read to act: policy flags |
| - (`user_invites_enabled`, `ai_enabled`, `apps_enabled`, `bots_enabled`), |
| + (`user_invites_enabled`, `ai_enabled`, `apps_enabled`, `member_apps_enabled`, |
| + `bots_enabled`), |
| roles, `is_agent`, presence, update channel/pin, `setup_complete`, |
| access-level pricing (`price_cents`/`currency`/`interval`) and Stripe ids. |
| - **Shape**: the membership graph, thread kinds/visibility, timestamps, |
diff --git a/feature_gates_test.go b/feature_gates_test.go| index a0815de..2d82ac8 100644 |
| --- a/feature_gates_test.go |
| +++ b/feature_gates_test.go |
| @@ -17,14 +17,14 @@ func TestFeatureGatesDefaultsAndSettings(t *testing.T) { |
| // A fresh instance has every gate ON (absent keys read true). |
| var s map[string]any |
| decodeJSON(t, adminGet(t, ts.URL+"/api/admin/settings"), &s) |
| - for _, k := range []string{"ai_enabled", "bots_enabled", "apps_enabled"} { |
| + for _, k := range []string{"ai_enabled", "bots_enabled", "apps_enabled", "member_apps_enabled"} { |
| if s[k] != true { |
| t.Fatalf("fresh settings %s = %v, want true", k, s[k]) |
| } |
| } |
| // Each flag flips independently (partial updates, like user_invites). |
| - for _, k := range []string{"ai_enabled", "bots_enabled", "apps_enabled"} { |
| + for _, k := range []string{"ai_enabled", "bots_enabled", "apps_enabled", "member_apps_enabled"} { |
| if r := adminPost(t, ts.URL+"/api/admin/settings", fmt.Sprintf(`{"%s":false}`, k)); r.StatusCode != http.StatusOK { |
| t.Fatalf("set %s=false: %d", k, r.StatusCode) |
| } |
| @@ -37,12 +37,12 @@ func TestFeatureGatesDefaultsAndSettings(t *testing.T) { |
| } |
| } |
| - // The member-visible /api/group read carries all three (native consumes it) |
| - // — and only with a valid token (it's also the public onboarding read). |
| + // The member-visible /api/group read carries all the gates (native consumes |
| + // it) — and only with a valid token (it's also the public onboarding read). |
| tok, _, _ := joinedMember(t, srv, "Gates") |
| var g map[string]any |
| decodeJSON(t, mustGet(t, ts.URL+"/api/group?token="+tok), &g) |
| - for _, k := range []string{"ai_enabled", "bots_enabled", "apps_enabled"} { |
| + for _, k := range []string{"ai_enabled", "bots_enabled", "apps_enabled", "member_apps_enabled"} { |
| if g[k] != true { |
| t.Fatalf("member /api/group %s = %v, want true", k, g[k]) |
| } |
| @@ -132,6 +132,64 @@ func TestAppsGate(t *testing.T) { |
| } |
| } |
| +// The narrower member-apps gate: the wider apps feature stays ON, only members |
| +// building/installing their OWN apps switches off — deploys and edits 403, the |
| +// authoring guide 404s (which flips the sidecar's builder off), and apps |
| +// already deployed keep listing. |
| +func TestMemberAppsGate(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + tok, _, _ := joinedMember(t, srv, "Maker") |
| + |
| + // On by default, independent of the wider apps gate. |
| + if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", |
| + fmt.Sprintf(`{"token":"%s","blob":"ciphertext-1"}`, tok)); r.StatusCode != http.StatusOK { |
| + t.Fatalf("deploy with member apps on: want 200, got %d", r.StatusCode) |
| + } |
| + |
| + adminPost(t, ts.URL+"/api/admin/settings", `{"member_apps_enabled":false}`) |
| + |
| + if r := mustGet(t, ts.URL+"/api/member-app-guide"); r.StatusCode != http.StatusNotFound { |
| + t.Fatalf("guide with member apps off: want 404, got %d", r.StatusCode) |
| + } |
| + // New deploys AND edits of an existing app are refused. |
| + if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-new456", |
| + fmt.Sprintf(`{"token":"%s","blob":"ciphertext-2"}`, tok)); r.StatusCode != http.StatusForbidden { |
| + t.Fatalf("new deploy with member apps off: want 403, got %d", r.StatusCode) |
| + } |
| + if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", |
| + fmt.Sprintf(`{"token":"%s","blob":"ciphertext-3"}`, tok)); r.StatusCode != http.StatusForbidden { |
| + t.Fatalf("edit with member apps off: want 403, got %d", r.StatusCode) |
| + } |
| + |
| + // The wider apps feature is untouched: built-in apps stay in the menu |
| + // (apps_enabled still reads true) and deployed member apps still list. |
| + var g map[string]any |
| + decodeJSON(t, mustGet(t, ts.URL+"/api/group?token="+tok), &g) |
| + if g["apps_enabled"] != true || g["member_apps_enabled"] != false { |
| + t.Fatalf("gates: apps_enabled = %v, member_apps_enabled = %v", g["apps_enabled"], g["member_apps_enabled"]) |
| + } |
| + r, _ := http.Get(ts.URL + "/api/member-apps?token=" + tok) |
| + var list struct{ Apps []MemberApp } |
| + decodeJSON(t, r, &list) |
| + if len(list.Apps) != 1 || list.Apps[0].Blob != "ciphertext-1" { |
| + t.Fatalf("existing apps should still list with member apps off: %+v", list.Apps) |
| + } |
| + // Retiring your own app stays open — the gate stops new use, never cleanup. |
| + if r := doReq(t, "DELETE", ts.URL+"/api/member-apps/app-abc123?token="+tok, ""); r.StatusCode != http.StatusOK { |
| + t.Fatalf("retire with member apps off: want 200, got %d", r.StatusCode) |
| + } |
| + |
| + // Back on: deploys land again. |
| + adminPost(t, ts.URL+"/api/admin/settings", `{"member_apps_enabled":true}`) |
| + if r := mustGet(t, ts.URL+"/api/member-app-guide"); r.StatusCode != http.StatusOK { |
| + t.Fatalf("guide with member apps back on: want 200, got %d", r.StatusCode) |
| + } |
| + if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-new456", |
| + fmt.Sprintf(`{"token":"%s","blob":"ciphertext-4"}`, tok)); r.StatusCode != http.StatusOK { |
| + t.Fatalf("deploy with member apps back on: want 200, got %d", r.StatusCode) |
| + } |
| +} |
| + |
| func TestAIGateOnBotConfig(t *testing.T) { |
| ts, srv := newTestServer(t) |
| tok, _, _ := joinedMember(t, srv, "Bot") |
diff --git a/main.go b/main.go| index 9aa6b93..4886941 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -719,6 +719,7 @@ func (s *Server) servePage(name string) http.HandlerFunc { |
| page = strings.ReplaceAll(page, "__ENABLE_AI__", boolStr(s.db.aiEnabled())) |
| page = strings.ReplaceAll(page, "__ENABLE_BOTS__", boolStr(s.db.botsEnabled())) |
| page = strings.ReplaceAll(page, "__ENABLE_APPS__", boolStr(s.db.appsEnabled())) |
| + page = strings.ReplaceAll(page, "__ENABLE_MEMBER_APPS__", boolStr(s.db.memberAppsEnabled())) |
| // The welcome page's "Get the app" destination; empty hides the button. |
| page = strings.ReplaceAll(page, "__APP_STORE_URL__", html.EscapeString(s.appStoreURL)) |
| // The home server the chat page enrols into for passkey backup. |
| @@ -847,10 +848,10 @@ func (s *Server) getAppGuide(w http.ResponseWriter, r *http.Request) { |
| // endpoint, so bot and instance can never disagree on the rules), and what a |
| // member points an outside LLM at. Public like /api/app-guide. |
| func (s *Server) getMemberAppGuide(w http.ResponseWriter, r *http.Request) { |
| - // Apps off reads as "no guide": the sidecar already treats a failed fetch |
| - // as app building disabled, so a 404 switches its builder off with no new |
| - // protocol between them. |
| - if !s.db.appsEnabled() { |
| + // Apps off — or just member-built apps off — reads as "no guide": the |
| + // sidecar already treats a failed fetch as app building disabled, so a 404 |
| + // switches its builder off with no new protocol between them. |
| + if !s.db.appsEnabled() || !s.db.memberAppsEnabled() { |
| http.NotFound(w, r) |
| return |
| } |
| @@ -4092,6 +4093,7 @@ func (s *Server) getGroup(w http.ResponseWriter, r *http.Request) { |
| out["ai_enabled"] = s.db.aiEnabled() |
| out["bots_enabled"] = s.db.botsEnabled() |
| out["apps_enabled"] = s.db.appsEnabled() |
| + out["member_apps_enabled"] = s.db.memberAppsEnabled() |
| } |
| writeJSON(w, http.StatusOK, out) |
| } |
| @@ -4292,20 +4294,21 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| writeJSON(w, http.StatusOK, map[string]any{ |
| - "user_invites": s.db.usersCanInvite(), |
| - "ai_enabled": s.db.aiEnabled(), |
| - "bots_enabled": s.db.botsEnabled(), |
| - "apps_enabled": s.db.appsEnabled(), |
| - "admin_name": s.db.getAdminName(), |
| - "admin_email": s.db.getAdminEmail(), |
| - "description": s.db.getGroupDescription(), |
| - "setup_complete": s.db.setupComplete(), |
| - "update_auto": s.db.updateAuto(), |
| - "update_download": s.db.updateDownload(), |
| - "update_channel": s.db.updateChannel(), |
| - "update_pin": s.db.updatePin(), |
| - "update_major": s.db.updateMajor(), |
| - "update": s.updateInfo(), |
| + "user_invites": s.db.usersCanInvite(), |
| + "ai_enabled": s.db.aiEnabled(), |
| + "bots_enabled": s.db.botsEnabled(), |
| + "apps_enabled": s.db.appsEnabled(), |
| + "member_apps_enabled": s.db.memberAppsEnabled(), |
| + "admin_name": s.db.getAdminName(), |
| + "admin_email": s.db.getAdminEmail(), |
| + "description": s.db.getGroupDescription(), |
| + "setup_complete": s.db.setupComplete(), |
| + "update_auto": s.db.updateAuto(), |
| + "update_download": s.db.updateDownload(), |
| + "update_channel": s.db.updateChannel(), |
| + "update_pin": s.db.updatePin(), |
| + "update_major": s.db.updateMajor(), |
| + "update": s.updateInfo(), |
| }) |
| } |
| @@ -4318,10 +4321,12 @@ func (s *Server) setSettings(w http.ResponseWriter, r *http.Request) { |
| var body struct { |
| UserInvites *bool `json:"user_invites"` |
| // The feature gates (default on; see db.go): AI replies, bot creation, |
| - // and apps. Enforcement lives at the gated endpoints. |
| - AIEnabled *bool `json:"ai_enabled"` |
| - BotsEnabled *bool `json:"bots_enabled"` |
| - AppsEnabled *bool `json:"apps_enabled"` |
| + // apps, and members building their own apps. Enforcement lives at the |
| + // gated endpoints. |
| + AIEnabled *bool `json:"ai_enabled"` |
| + BotsEnabled *bool `json:"bots_enabled"` |
| + AppsEnabled *bool `json:"apps_enabled"` |
| + MemberAppsEnabled *bool `json:"member_apps_enabled"` |
| // The setup conversation marks itself done (or the admin dismisses it). |
| SetupComplete *bool `json:"setup_complete"` |
| // The photo's alt text, editable without re-uploading the image (the |
| @@ -4407,6 +4412,12 @@ func (s *Server) setSettings(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| } |
| + if body.MemberAppsEnabled != nil { |
| + if err := s.db.setMemberAppsEnabled(*body.MemberAppsEnabled); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + } |
| if body.SetupComplete != nil { |
| if err := s.db.setSetupComplete(*body.SetupComplete); err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
diff --git a/memberapps.go b/memberapps.go| index fe21946..7381d9f 100644 |
| --- a/memberapps.go |
| +++ b/memberapps.go |
| @@ -99,12 +99,18 @@ func (s *Server) putMemberAppHandler(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusForbidden, "invalid token") |
| return |
| } |
| - // The apps gate stops new deploys (and edits); already-deployed apps keep |
| + // 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 |
diff --git a/web/pages/admin.html b/web/pages/admin.html| index 3ecdb9c..25347d7 100644 |
| --- a/web/pages/admin.html |
| +++ b/web/pages/admin.html |
| @@ -92,7 +92,8 @@ |
| Switch off the parts of the app your space doesn't want. Existing chats |
| keep working either way — turning something off just stops new use: AI off |
| means helpers answer commands only, bots off stops new bots being created, |
| - apps off hides them from the New-chat menu. |
| + apps off hides them from the New-chat menu, member-made apps off keeps the |
| + built-in apps but stops members building their own. |
| </p> |
| <label class="toggle-row" style="margin:0 0 8px"> |
| <input type="checkbox" id="aiEnabled" /> |
| @@ -102,10 +103,14 @@ |
| <input type="checkbox" id="botsEnabled" /> |
| <span>Enable bots</span> |
| </label> |
| - <label class="toggle-row"> |
| + <label class="toggle-row" style="margin:0 0 8px"> |
| <input type="checkbox" id="appsEnabled" /> |
| <span>Enable apps</span> |
| </label> |
| + <label class="toggle-row"> |
| + <input type="checkbox" id="memberAppsEnabled" /> |
| + <span>Let members make their own apps</span> |
| + </label> |
| </section> |
| <section> |
diff --git a/web/pages/index.html b/web/pages/index.html| index 234cf09..48f4aa1 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -10,6 +10,7 @@ |
| <meta name="enable-ai" content="__ENABLE_AI__" /> |
| <meta name="enable-bots" content="__ENABLE_BOTS__" /> |
| <meta name="enable-apps" content="__ENABLE_APPS__" /> |
| +<meta name="enable-member-apps" content="__ENABLE_MEMBER_APPS__" /> |
| <title>__GROUP_NAME__</title> |
| <!-- Native wrapper detection: the Mac app's user agent carries "lchatMac". Tag the |
| document so CSS can adopt native conventions (e.g. drop web hand cursors). --> |
diff --git a/web/static/admin.js b/web/static/admin.js| index 484bede..a2e177d 100644 |
| --- a/web/static/admin.js |
| +++ b/web/static/admin.js |
| @@ -78,6 +78,7 @@ async function wireFeatures() { |
| ["aiEnabled", "ai_enabled", "AI replies are on", "AI replies are off — helpers answer commands only"], |
| ["botsEnabled", "bots_enabled", "Members can create bots again", "Bot creation is off — existing bots keep running"], |
| ["appsEnabled", "apps_enabled", "Apps are back in the New-chat menu", "Apps are off — existing app chats keep working"], |
| + ["memberAppsEnabled", "member_apps_enabled", "Members can make their own apps again", "Member-made apps are off — apps already deployed keep working"], |
| ]; |
| const agentSection = document.getElementById("agentSection"); |
| const syncAgentSection = (botsOn) => agentSection.classList.toggle("hidden", !botsOn); |
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js| index fe0a87e..8d79b44 100644 |
| --- a/web/static/apps/admin.js |
| +++ b/web/static/apps/admin.js |
| @@ -472,6 +472,12 @@ export const app = defineApp({ |
| "Apps are back in the New-chat menu.", |
| "Apps are off — existing app chats keep working.", |
| ); |
| + const memberAppsCheck = featRow( |
| + "Let members make their own apps", |
| + "member_apps_enabled", |
| + "Members can make their own apps again.", |
| + "Member-made apps are off — apps already deployed keep working.", |
| + ); |
| const featStatus = statusLine(featSec); |
| // ---- Access levels --------------------------------------------------------- |
| @@ -1392,6 +1398,7 @@ export const app = defineApp({ |
| aiCheck.checked = info.aiEnabled !== false; |
| botsCheck.checked = info.botsEnabled !== false; |
| appsCheck.checked = info.appsEnabled !== false; |
| + memberAppsCheck.checked = info.memberAppsEnabled !== false; |
| autoCheck.checked = !!info.updateAuto; |
| dlCheck.checked = !!info.updateDownload; |
| updState.channel = info.updateChannel || "stable"; |
diff --git a/web/static/apps/appstudio.js b/web/static/apps/appstudio.js| index ff9bcae..ab62982 100644 |
| --- a/web/static/apps/appstudio.js |
| +++ b/web/static/apps/appstudio.js |
| @@ -12,6 +12,7 @@ |
| // is just the UI over it. The authoring rules are at GET /api/member-app-guide. |
| import { defineApp } from "../apps.js"; |
| import { ICON_PUZZLE } from "../icons.js"; |
| +import { ENABLE_MEMBER_APPS } from "../shellstate.js"; |
| import { |
| listDeployedApps, |
| listAppVersions, |
| @@ -121,7 +122,12 @@ export const app = defineApp({ |
| .studio .bar button { cursor: var(--ui-cursor, pointer); border-radius: var(--radius); padding: 7px 14px; font-size: 13px; border: 1px solid var(--border); background: var(--panel); color: var(--ink); } |
| .studio .bar button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); } |
| .studio .bar button[disabled] { opacity: .5; cursor: default; } |
| -.studio .note { font-size: 13px; line-height: 1.5; padding: 8px 10px; border-radius: var(--radius); background: var(--bubble-them); } |
| +.studio .note { font-size: 13px; line-height: 1.5; margin: 10px 0 0; padding: 8px 10px; border-radius: var(--radius); background: var(--bubble-them); } |
| +/* The member-apps-off callout: the one notice that explains the whole page's |
| + reduced state, so it gets the note surface + full-ink lead, not muted sub. */ |
| +.studio .gate-note { display: flex; align-items: flex-start; gap: 10px; padding: 12px 14px; border-radius: var(--radius); background: var(--bubble-them); } |
| +.studio .gate-note .gg { font-size: 20px; line-height: 1.3; } |
| +.studio .gate-note .gt { font-weight: 600; font-size: 14px; line-height: 1.4; } |
| .studio .note.err { color: var(--danger); } |
| .studio .note.ok { color: var(--accent); } |
| .studio .spacer { flex: 1 1 auto; } |
| @@ -133,9 +139,26 @@ export const app = defineApp({ |
| root.appendChild(wrap); |
| wrap.appendChild(el("h2", null, "App Studio 🧩")); |
| - wrap.appendChild( |
| - el("p", "sub", "Make your own apps: paste a single-file app module below, Check it, and Deploy. It runs sandboxed like any app — it can't reach your messages, keys, or the network. Prefer to just describe one? Ask a bot to build it for you."), |
| - ); |
| + // The member-apps gate (an admin setting; the server enforces it on every |
| + // deploy). Off hides the whole authoring surface — no editor to type into |
| + // only to be refused — while apps already deployed stay listed and usable. |
| + // The why gets a callout, not the muted intro style: it explains the whole |
| + // page's reduced state, so it has to be the first thing a visitor spots. |
| + if (ENABLE_MEMBER_APPS) { |
| + wrap.appendChild( |
| + el("p", "sub", "Make your own apps: paste a single-file app module below, Check it, and Deploy. It runs sandboxed like any app — it can't reach your messages, keys, or the network. Prefer to just describe one? Ask a bot to build it for you."), |
| + ); |
| + } else { |
| + const gate = el("div", "gate-note"); |
| + const gg = el("span", "gg", "🔒"); // decorative — the text carries the meaning |
| + gg.setAttribute("aria-hidden", "true"); |
| + gate.appendChild(gg); |
| + const txt = el("div"); |
| + txt.appendChild(el("div", "gt", "Making your own apps is turned off for this space")); |
| + txt.appendChild(el("p", "sub", "An admin can turn it back on in Admin → Features. Apps already deployed keep working.")); |
| + gate.appendChild(txt); |
| + wrap.appendChild(gate); |
| + } |
| // ---- Version history (replaces the list/editor while open) -------------- |
| if (historyFor) { |
| @@ -168,7 +191,7 @@ export const app = defineApp({ |
| dl.onclick = () => downloadSource(v.source, `${historyTitle}-v${v.version}`); |
| row.appendChild(dl); |
| } |
| - if (!v.current && v.source) { |
| + if (ENABLE_MEMBER_APPS && !v.current && v.source) { |
| const revert = el("button", "primary", "Revert to this"); |
| revert.onclick = async () => { |
| if (!confirm(`Revert to version ${v.version}? It deploys as a new version (marked "based on version ${v.version}") — nothing is lost.`)) return; |
| @@ -208,7 +231,9 @@ export const app = defineApp({ |
| return; |
| } |
| if (!res.apps.length) { |
| - listBody.appendChild(el("div", "empty", "No apps yet — deploy one below, or ask a bot to build you one.")); |
| + listBody.appendChild( |
| + el("div", "empty", ENABLE_MEMBER_APPS ? "No apps yet — deploy one below, or ask a bot to build you one." : "No apps yet."), |
| + ); |
| return; |
| } |
| for (const a of res.apps) { |
| @@ -248,14 +273,18 @@ export const app = defineApp({ |
| row.appendChild(open); |
| } |
| if (a.mine && a.source) { |
| - const edit = el("button", null, "Edit code"); |
| - edit.onclick = () => { |
| - draft = a.source; |
| - editing = a.id; |
| - editingName = m.name || ""; |
| - rerender(); |
| - }; |
| - row.appendChild(edit); |
| + // Edit code leads to the (gated) editor — with member apps off it |
| + // hides with it; History, Download, and Remove stay open. |
| + if (ENABLE_MEMBER_APPS) { |
| + const edit = el("button", null, "Edit code"); |
| + edit.onclick = () => { |
| + draft = a.source; |
| + editing = a.id; |
| + editingName = m.name || ""; |
| + rerender(); |
| + }; |
| + row.appendChild(edit); |
| + } |
| const hist = el("button", null, "History"); |
| hist.onclick = () => { |
| historyFor = a.id; |
| @@ -288,6 +317,14 @@ export const app = defineApp({ |
| } |
| }; |
| + // With member apps off there's no authoring surface at all — the header |
| + // note above says why, and the list (Open / History / Download / Remove) |
| + // is the whole page. Deploys would 403 server-side regardless. |
| + if (!ENABLE_MEMBER_APPS) { |
| + refreshList(); |
| + return; |
| + } |
| + |
| // ---- Add / edit an app -------------------------------------------------- |
| const editor = el("div"); |
| editor.appendChild(el("h3", null, editing ? `Edit “${editingName}”` : "Write a new app")); |
diff --git a/web/static/newchat.js b/web/static/newchat.js| index 024254e..e780462 100644 |
| --- a/web/static/newchat.js |
| +++ b/web/static/newchat.js |
| @@ -3,7 +3,7 @@ |
| // verbatim from shell.js; behaviours ride the verbs seam, state shellstate. |
| import { verbs } from "/static/shellverbs.js"; |
| import { ICON_WRENCH } from "/static/icons.js"; |
| -import { session, roster, myUserId, threads, threadKeys, activeId, nicknames, threadName, threadKeyB64, hidden, dmClosed, embed, setActiveId, ENABLE_BOTS, ENABLE_APPS, instanceKey, ws, tiers, tierKeys, tierKeyB64 } from "/static/shellstate.js"; |
| +import { session, roster, myUserId, threads, threadKeys, activeId, nicknames, threadName, threadKeyB64, hidden, dmClosed, embed, setActiveId, ENABLE_BOTS, ENABLE_APPS, ENABLE_MEMBER_APPS, instanceKey, ws, tiers, tierKeys, tierKeyB64 } from "/static/shellstate.js"; |
| import { avatarFor, avatarEl, paintAvatar } from "/static/avatars.js"; |
| import { decrypt, encrypt, generateKey, importKey, wrapThreadKey } from "/static/crypto.js"; |
| import { appGlyphInto, personalApps } from "/static/apps.js"; |
| @@ -11,6 +11,14 @@ import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresence |
| // ---- new chat (roster picker) ------------------------------------------- |
| +// The app pickers' view of the registry: with member-made apps off, App Studio |
| +// (the make-your-own entry point) hides — deploys would 403 anyway. Member apps |
| +// already deployed stay listed: like the wider apps gate, off only stops new |
| +// use, it never breaks what members already rely on. |
| +export function pickerApps() { |
| + return personalApps().filter((a) => ENABLE_MEMBER_APPS || a.name !== "appstudio"); |
| +} |
| + |
| // The existing DM thread with a person, if any. There's exactly one DM per pair |
| // (the server enforces it too), so starting a chat with someone you already DM |
| // should just reopen it — never spawn a second. |
| @@ -94,7 +102,7 @@ export function showNewChat(startMode) { |
| const matchingApps = (q) => |
| !ENABLE_APPS |
| ? [] |
| - : personalApps().filter( |
| + : pickerApps().filter( |
| (a) => |
| (a.title || a.name).toLowerCase().includes(q) && |
| (!a.requiresService || (connectedServices && connectedServices.has(a.requiresService))), |
| @@ -900,7 +908,7 @@ export function showApps() { |
| let connectedServices = null; // null = unknown yet; a Set once loaded |
| const paint = () => { |
| list.innerHTML = ""; |
| - for (const app of personalApps()) { |
| + for (const app of pickerApps()) { |
| if (app.requiresService && !(connectedServices && connectedServices.has(app.requiresService))) |
| continue; |
| const b = document.createElement("button"); |
diff --git a/web/static/sandbox.js b/web/static/sandbox.js| index 50932d4..39e1da5 100644 |
| --- a/web/static/sandbox.js |
| +++ b/web/static/sandbox.js |
| @@ -1051,8 +1051,12 @@ export async function deployMemberApp(source, opts = {}) { |
| return { error: "couldn't reach the server" }; |
| } |
| if (!res.ok) { |
| - if (res.status === 403) return { error: updating ? "only the app's author can update it" : "you've reached your app limit" }; |
| - return { error: `deploy was rejected (${res.status})` }; |
| + // The server says why in httpError's {error} body — the feature gates |
| + // ("making your own apps is turned off for this space"), the app caps, |
| + // author-only updates. Show its words rather than guessing from the |
| + // status (a guessed "app limit" on a gated 403 misleads). |
| + const msg = await res.json().then((j) => (j && j.error) || "").catch(() => ""); |
| + return { error: msg || `deploy was rejected (${res.status})` }; |
| } |
| // A brand-new app registers live so it's usable without a reload (and we |
| // suppress this client's own apps_changed reload). An UPDATE changes code the |
diff --git a/web/static/shell.js b/web/static/shell.js| index 340aaa8..e2c8696 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -96,7 +96,6 @@ import { |
| appDefaultView, |
| appChatOnly, |
| appStateFor, |
| - personalApps, |
| groupCapableApps, |
| groupAppsFor, |
| emitAppEvent, |
| @@ -137,7 +136,7 @@ import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendN |
| import { avatarFor, threadAvatarFor, paintAvatar, avatarEl } from "/static/avatars.js"; |
| import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js"; |
| import { editGroup, openOrCreateDM, showMembers, viewProfile, updateRenameBanner, showSpaceInfo, closeChat, endConversation, toggleMute, togglePin, addPeople, manageGroupApps, leaveGroup } from "/static/groupactions.js"; |
| -import { existingDM, showNewChat, showMembership, flash, showDiscover, showApps, createThread } from "/static/newchat.js"; |
| +import { existingDM, showNewChat, showMembership, flash, showDiscover, showApps, createThread, pickerApps } from "/static/newchat.js"; |
| import { addDevice } from "/static/adddevice.js"; |
| import { sendScriptedBubble, sendTypedPayload, scriptedSaid, liveElevenAgent, maybeStartWelcome, openSetupQuickForm } from "/static/onboardingui.js"; |
| import { verbs } from "/static/shellverbs.js"; // the behaviour seam (docs/shell-modules.md) |
| @@ -2385,7 +2384,7 @@ actions.push({ id: "discover", label: "Discover groups" }); |
| // Membership: only when the space sells any level, or you already subscribe. |
| if (tiers.some((t) => t.price_cents > 0 || t.status)) actions.push({ id: "membership", label: "Membership" }); |
| if (inviteAllowed()) actions.push({ id: "invite", label: "Invite someone" }); |
| - if (ENABLE_APPS && personalApps().length) actions.push({ id: "apps", label: "Open apps" }); |
| + if (ENABLE_APPS && pickerApps().length) actions.push({ id: "apps", label: "Open apps" }); |
| // Deliberately outside the apps gate: Admin is how an admin would switch |
| // apps back on, so it must stay reachable when they're off. |
| if (isAdmin()) actions.push({ id: "admin", label: "Open admin" }); |
| @@ -5280,6 +5279,7 @@ setSetupBridge({ |
| aiEnabled: s.ai_enabled !== false, |
| botsEnabled: s.bots_enabled !== false, |
| appsEnabled: s.apps_enabled !== false, |
| + memberAppsEnabled: s.member_apps_enabled !== false, |
| updateAuto: !!s.update_auto, |
| updateDownload: !!s.update_download, |
| updateChannel: s.update_channel || "stable", |
diff --git a/web/static/shellstate.js b/web/static/shellstate.js| index 0e70de1..08cc08d 100644 |
| --- a/web/static/shellstate.js |
| +++ b/web/static/shellstate.js |
| @@ -12,6 +12,7 @@ const gateMeta = (name) => ((document.querySelector(`meta[name="${name}"]`) || { |
| export const ENABLE_AI = gateMeta("enable-ai"); |
| export const ENABLE_BOTS = gateMeta("enable-bots"); |
| export const ENABLE_APPS = gateMeta("enable-apps"); |
| +export const ENABLE_MEMBER_APPS = gateMeta("enable-member-apps"); |
| export let session = null; // { token, name } — set at boot (embed or localStorage) |
| export let roster = []; // [{user_id, label, public_key}] |
| export let myUserId = ""; |