server: per-instance gates — Enable AI, bots, apps (default on) (#712)
* server: per-instance gates — ai_enabled / bots_enabled / apps_enabled (default on) Three admin-set config flags, absent-reads-true so existing spaces keep today's behavior. Enforcement: agent-invite minting + member-bot PUTs 403 when bots are off; the member-app guide 404s (the sidecar reads that as "app building off") and member-app PUTs 403 when apps are off; the sidecar drops its model client on the next (re)connect when /api/bot-config says ai_enabled=false — the deterministic command floor keeps working and declareKind re-badges the bot honestly. Flags ride /api/admin/settings, the member-gated /api/group, and servePage's __ENABLE_*__ injections. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: hide bot/app entry points when the gates are off ENABLE_AI/BOTS/APPS globals off the injected metas (default on, like USER_INVITES). Bots off empties the Helpers sections in the new-chat sheet and the add-to-group picker; apps off empties the picker's Apps section and drops the palette's "Open apps". Existing bot DMs and app threads stay. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * admin: Enable AI / bots / apps toggles in the console and the Admin app A Features section in both surfaces, each switch one partial /api/admin/settings write (the user_invites pattern). Bots off also hides the console's "Add an agent" section — minting would 403 anyway. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: consume the feature gates natively — hide picker entry points LChatAPI.featureGates() decodes the member /api/group flags (optionals defaulting true, so old servers stay unchanged); ThreadStore publishes botsEnabled/appsEnabled off refresh(). Bots off drops agents from the iOS and Mac pickers; apps off hides the APPS / SHARED APPS sections. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
16 files changed,
+485
−40
changelog/2026-07-11-feature-gates.md+1 −0db.go+26 −0feature_gates_test.go+153 −0internal/bot/brain.go+14 −0ios/LChat/Views/NewChatView.swift+32 −23ios/LChatCore/Sources/LChatCore/LChatAPI.swift+19 −0ios/LChatCore/Sources/LChatCore/ThreadStore.swift+9 −0ios/LChatMac/NewChatPopover.swift+15 −6main.go+52 −1memberapps.go+6 −0memberbots.go+6 −0web/pages/admin.html+23 −1web/pages/index.html+5 −0web/static/admin.js+34 −0web/static/apps/admin.js+46 −0web/static/shell.js+44 −9
diff --git a/changelog/2026-07-11-feature-gates.md b/changelog/2026-07-11-feature-gates.md| new file mode 100644 |
| index 0000000..9c4c816 |
| --- /dev/null |
| +++ b/changelog/2026-07-11-feature-gates.md |
| @@ -0,0 +1 @@ |
| +🤖 Admins can now switch off AI, bots, or apps for their space — three new toggles in Admin under Features (and in the admin console). Everything is on by default, so nothing changes unless you choose it. Turning AI off means helpers answer commands only; turning bots off stops new bots being created (ones you already have keep running); turning apps off hides them from the New-chat menu (app chats you already have keep working). |
diff --git a/db.go b/db.go| index 004225f..616df3d 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -119,6 +119,29 @@ func (db *DB) setUsersCanInvite(on bool) error { |
| return db.setConfig("user_invites_enabled", v) |
| } |
| +// Per-instance feature gates: whether this space offers AI replies, bot |
| +// creation, and (member/mini) apps. All default ON — an absent key reads |
| +// true — so existing spaces keep today's behavior and turning one off is an |
| +// explicit admin opt-out. The server enforces each gate at its endpoints; |
| +// clients only hide the entry points. |
| +func (db *DB) aiEnabled() bool { |
| + v, ok, _ := db.getConfig("ai_enabled") |
| + return !ok || v == "1" |
| +} |
| +func (db *DB) setAIEnabled(on bool) error { return db.setConfig("ai_enabled", boolStr(on)) } |
| + |
| +func (db *DB) botsEnabled() bool { |
| + v, ok, _ := db.getConfig("bots_enabled") |
| + return !ok || v == "1" |
| +} |
| +func (db *DB) setBotsEnabled(on bool) error { return db.setConfig("bots_enabled", boolStr(on)) } |
| + |
| +func (db *DB) appsEnabled() bool { |
| + v, ok, _ := db.getConfig("apps_enabled") |
| + return !ok || v == "1" |
| +} |
| +func (db *DB) setAppsEnabled(on bool) error { return db.setConfig("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 |
| @@ -1494,6 +1517,9 @@ type BotConfig struct { |
| Docs string `json:"docs"` |
| Listen string `json:"listen"` // ''/'mention' = @mention only, 'name' = also bare name |
| Welcome string `json:"welcome"` // admin-tweakable welcome DM (the guide uses it); "" = shipped default |
| + // The instance-wide AI gate, not a bot_configs column — getBotConfig fills |
| + // it from config so the sidecar learns a flip on its next (re)connect. |
| + AIEnabled bool `json:"ai_enabled"` |
| } |
| // setBotKind records an agent's kind ("ai" | "bot"), self-declared by the sidecar |
diff --git a/feature_gates_test.go b/feature_gates_test.go| new file mode 100644 |
| index 0000000..a0815de |
| --- /dev/null |
| +++ b/feature_gates_test.go |
| @@ -0,0 +1,153 @@ |
| +package main |
| + |
| +// Tests for the per-instance feature gates (ai/bots/apps): default ON, admin |
| +// flips via /api/admin/settings, and the enforcement points — agent invite |
| +// minting + member-bot storage (bots), the member-app guide + deploys (apps), |
| +// and the ai_enabled flag the sidecar reads off /api/bot-config (AI). |
| + |
| +import ( |
| + "fmt" |
| + "net/http" |
| + "testing" |
| +) |
| + |
| +func TestFeatureGatesDefaultsAndSettings(t *testing.T) { |
| + ts, srv := newTestServer(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"} { |
| + 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"} { |
| + 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) |
| + } |
| + decodeJSON(t, adminGet(t, ts.URL+"/api/admin/settings"), &s) |
| + if s[k] != false { |
| + t.Fatalf("after flip, %s = %v, want false", k, s[k]) |
| + } |
| + if r := adminPost(t, ts.URL+"/api/admin/settings", fmt.Sprintf(`{"%s":true}`, k)); r.StatusCode != http.StatusOK { |
| + t.Fatalf("set %s=true: %d", k, r.StatusCode) |
| + } |
| + } |
| + |
| + // 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). |
| + 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"} { |
| + if g[k] != true { |
| + t.Fatalf("member /api/group %s = %v, want true", k, g[k]) |
| + } |
| + } |
| + var pub map[string]any |
| + decodeJSON(t, mustGet(t, ts.URL+"/api/group"), &pub) |
| + if _, ok := pub["bots_enabled"]; ok { |
| + t.Fatal("public /api/group leaked the feature gates") |
| + } |
| +} |
| + |
| +func TestBotsGate(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + tok, _, _ := joinedMember(t, srv, "Maker") |
| + |
| + if r := adminPost(t, ts.URL+"/api/admin/settings", `{"bots_enabled":false}`); r.StatusCode != http.StatusOK { |
| + t.Fatalf("disable bots: %d", r.StatusCode) |
| + } |
| + |
| + // Minting an agent invite is refused — and refused before any user row is |
| + // created, so a rejected mint leaves no phantom member. |
| + before, _ := srv.db.listUsers() |
| + if r := adminPost(t, ts.URL+"/api/admin/key-invites", |
| + `{"label":"Robo","id":"agent-inv","claim_hash":"`+testClaimHash+`","agent":true}`); r.StatusCode != http.StatusForbidden { |
| + t.Fatalf("agent invite with bots off: want 403, got %d", r.StatusCode) |
| + } |
| + after, _ := srv.db.listUsers() |
| + if len(after) != len(before) { |
| + t.Fatalf("rejected agent mint left a user behind: %d -> %d", len(before), len(after)) |
| + } |
| + |
| + // A normal (person) invite still works with bots off. |
| + if r := adminPost(t, ts.URL+"/api/admin/key-invites", |
| + `{"id":"person-inv","claim_hash":"`+testClaimHash+`"}`); r.StatusCode != http.StatusOK { |
| + t.Fatalf("person invite with bots off: want 200, got %d", r.StatusCode) |
| + } |
| + |
| + // Member-bot charters can't be stored (created or edited) while off. |
| + if r := doReq(t, "PUT", ts.URL+"/api/member-bots/bot-abc123", |
| + fmt.Sprintf(`{"token":"%s","blob":"ciphertext"}`, tok)); r.StatusCode != http.StatusForbidden { |
| + t.Fatalf("member-bot PUT with bots off: want 403, got %d", r.StatusCode) |
| + } |
| + |
| + // Back on: both paths open up again. |
| + adminPost(t, ts.URL+"/api/admin/settings", `{"bots_enabled":true}`) |
| + if r := adminPost(t, ts.URL+"/api/admin/key-invites", |
| + `{"label":"Robo","id":"agent-inv2","claim_hash":"`+testClaimHash+`","agent":true}`); r.StatusCode != http.StatusOK { |
| + t.Fatalf("agent invite with bots back on: want 200, got %d", r.StatusCode) |
| + } |
| + if r := doReq(t, "PUT", ts.URL+"/api/member-bots/bot-abc123", |
| + fmt.Sprintf(`{"token":"%s","blob":"ciphertext"}`, tok)); r.StatusCode != http.StatusOK { |
| + t.Fatalf("member-bot PUT with bots back on: want 200, got %d", r.StatusCode) |
| + } |
| +} |
| + |
| +func TestAppsGate(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + tok, _, _ := joinedMember(t, srv, "Builder") |
| + |
| + // On by default: the guide serves and deploys land. |
| + if r := mustGet(t, ts.URL+"/api/member-app-guide"); r.StatusCode != http.StatusOK { |
| + t.Fatalf("guide with apps on: want 200, got %d", r.StatusCode) |
| + } |
| + 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 apps on: want 200, got %d", r.StatusCode) |
| + } |
| + |
| + adminPost(t, ts.URL+"/api/admin/settings", `{"apps_enabled":false}`) |
| + |
| + // The guide 404s — the sidecar reads that as "app building off" — and |
| + // deploys (new or edits) are refused. |
| + if r := mustGet(t, ts.URL+"/api/member-app-guide"); r.StatusCode != http.StatusNotFound { |
| + t.Fatalf("guide with apps off: want 404, got %d", r.StatusCode) |
| + } |
| + if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", |
| + fmt.Sprintf(`{"token":"%s","blob":"ciphertext-2"}`, tok)); r.StatusCode != http.StatusForbidden { |
| + t.Fatalf("deploy with apps off: want 403, got %d", r.StatusCode) |
| + } |
| + |
| + // Existing apps still LIST (threads keep working) — only new use is gated. |
| + 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 apps off: %+v", list.Apps) |
| + } |
| +} |
| + |
| +func TestAIGateOnBotConfig(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + tok, _, _ := joinedMember(t, srv, "Bot") |
| + |
| + get := func() map[string]any { |
| + var c map[string]any |
| + decodeJSON(t, mustGet(t, ts.URL+"/api/bot-config?token="+tok), &c) |
| + return c |
| + } |
| + if c := get(); c["ai_enabled"] != true { |
| + t.Fatalf("fresh bot-config ai_enabled = %v, want true", c["ai_enabled"]) |
| + } |
| + adminPost(t, ts.URL+"/api/admin/settings", `{"ai_enabled":false}`) |
| + // The sidecar re-reads this on every (re)connect (internal/bot loadConfig) |
| + // and drops its model client when it's false. |
| + if c := get(); c["ai_enabled"] != false { |
| + t.Fatalf("bot-config after AI off: ai_enabled = %v, want false", c["ai_enabled"]) |
| + } |
| +} |
diff --git a/internal/bot/brain.go b/internal/bot/brain.go| index 951af4c..4be450e 100644 |
| --- a/internal/bot/brain.go |
| +++ b/internal/bot/brain.go |
| @@ -62,6 +62,9 @@ type botConfig struct { |
| Model string `json:"model"` |
| Docs string `json:"docs"` |
| Listen string `json:"listen"` |
| + // The instance's AI gate. A pointer so a server that predates the field |
| + // (or a failed fetch) reads as enabled — the key alone decides then. |
| + AIEnabled *bool `json:"ai_enabled"` |
| } |
| func (r *Runtime) loadConfig() { |
| @@ -77,6 +80,17 @@ func (r *Runtime) loadConfig() { |
| r.model = firstOf(c.Model, os.Getenv("LCHAT_MODEL"), defaultModel) |
| r.docs = firstOf(c.Docs, builtinDocs(name)) |
| r.listen = firstOf(c.Listen, os.Getenv("LCHAT_LISTEN"), "mention") |
| + // The admin's AI gate: construct or drop the model client here, so a flip |
| + // lands on the next (re)connect with no sidecar restart. With r.ai nil the |
| + // deterministic command floor keeps answering, every model path stays |
| + // silent, and declareKind (which runs right after this) re-badges honestly. |
| + if r.AIKey != "" && (c.AIEnabled == nil || *c.AIEnabled) { |
| + if r.ai == nil { |
| + r.ai = newAnthropicClient(r.AIKey) |
| + } |
| + } else { |
| + r.ai = nil |
| + } |
| // The app-builder needs the model brain AND the instance's authoring |
| // guide; a fetch failure just disables the tool for this connection. |
| r.appGuide = "" |
diff --git a/ios/LChat/Views/NewChatView.swift b/ios/LChat/Views/NewChatView.swift| index 401bd07..9a0a68a 100644 |
| --- a/ios/LChat/Views/NewChatView.swift |
| +++ b/ios/LChat/Views/NewChatView.swift |
| @@ -16,7 +16,9 @@ struct NewChatView: View { |
| private var others: [RosterEntry] { store.roster.filter { $0.user_id != store.myUserId } } |
| private var people: [RosterEntry] { others.filter { !$0.isAgent } } |
| - private var helpers: [RosterEntry] { others.filter { $0.isAgent } } |
| + // Bots off empties the Helpers section (matching the web picker) — existing |
| + // bot DMs stay in the sidebar; only starting new ones hides. |
| + private var helpers: [RosterEntry] { store.botsEnabled ? others.filter { $0.isAgent } : [] } |
| var body: some View { |
| NavigationStack { |
| @@ -164,7 +166,11 @@ struct NewGroupView: View { |
| @State private var busy = false |
| @State private var error: String? |
| - private var others: [RosterEntry] { store.roster.filter { $0.user_id != store.myUserId } } |
| + // Bots off drops agents from the member picker, same as the web's |
| + // add-to-group sheet (adding a bot to a group is starting new bot use). |
| + private var others: [RosterEntry] { |
| + store.roster.filter { $0.user_id != store.myUserId && (store.botsEnabled || !$0.isAgent) } |
| + } |
| var body: some View { |
| Form { |
| @@ -206,31 +212,34 @@ struct NewGroupView: View { |
| } |
| // Shared apps, picked at birth — each one lands as a groupapps.add |
| // in the fresh log, so every member folds the same list (the same |
| - // apps you could add later from ⋯ → Manage Apps). |
| - Section { |
| - ForEach(GroupApps.catalog, id: \.id) { app in |
| - Button { toggleApp(app.id) } label: { |
| - HStack(spacing: 12) { |
| - Text(app.glyph) |
| - .font(.system(size: 15)) |
| - .frame(width: 32, height: 32) |
| - .background(Circle().fill(.primary.opacity(0.06))) |
| - Text(app.title).foregroundStyle(.primary) |
| - Spacer() |
| - if selectedApps.contains(app.id) { |
| - Image(systemName: "checkmark.circle.fill").foregroundStyle(.tint) |
| - } else { |
| - Image(systemName: "circle").foregroundStyle(.tertiary) |
| + // apps you could add later from ⋯ → Manage Apps). Hidden entirely |
| + // when the admin turned apps off. |
| + if store.appsEnabled { |
| + Section { |
| + ForEach(GroupApps.catalog, id: \.id) { app in |
| + Button { toggleApp(app.id) } label: { |
| + HStack(spacing: 12) { |
| + Text(app.glyph) |
| + .font(.system(size: 15)) |
| + .frame(width: 32, height: 32) |
| + .background(Circle().fill(.primary.opacity(0.06))) |
| + Text(app.title).foregroundStyle(.primary) |
| + Spacer() |
| + if selectedApps.contains(app.id) { |
| + Image(systemName: "checkmark.circle.fill").foregroundStyle(.tint) |
| + } else { |
| + Image(systemName: "circle").foregroundStyle(.tertiary) |
| + } |
| } |
| + .contentShape(Rectangle()) |
| } |
| - .contentShape(Rectangle()) |
| + .buttonStyle(.plain) |
| } |
| - .buttonStyle(.plain) |
| + } header: { |
| + Text("Apps") |
| + } footer: { |
| + Text("Apps are shared with everyone in the group — one list, one set of data.") |
| } |
| - } header: { |
| - Text("Apps") |
| - } footer: { |
| - Text("Apps are shared with everyone in the group — one list, one set of data.") |
| } |
| if let error { |
| Section { Text(error).foregroundStyle(.red) } |
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift| index 7c4b333..eb27477 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| @@ -323,6 +323,25 @@ public struct LChatAPI { |
| return n == "lchat" ? "" : n // pre-name_set servers: filter the default |
| } |
| + /// Per-instance feature gates (admin-set, default on): whether this space |
| + /// offers bot creation, apps, and AI replies. They ride the member-gated |
| + /// part of /api/group, so this read is authed; optionals in the decode so |
| + /// servers that predate the gates (and failed fetches) read as enabled. |
| + /// The server enforces each gate — these only hide entry points. |
| + public struct FeatureGates: Sendable { |
| + public let botsEnabled: Bool |
| + public let appsEnabled: Bool |
| + public let aiEnabled: Bool |
| + } |
| + |
| + public func featureGates() async -> FeatureGates { |
| + struct Resp: Codable { let bots_enabled: Bool?; let apps_enabled: Bool?; let ai_enabled: Bool? } |
| + let r = try? await getJSON("/api/group", Resp.self) |
| + return FeatureGates(botsEnabled: r?.bots_enabled ?? true, |
| + appsEnabled: r?.apps_enabled ?? true, |
| + aiEnabled: r?.ai_enabled ?? true) |
| + } |
| + |
| /// The instance's public branding photo bytes, or nil if none is set. Served |
| /// in the clear (no key exists yet at add-a-chat time), so this needs no token. |
| public func serverAvatar() async -> Data? { |
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift| index fa2b674..4ad69d1 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| @@ -15,6 +15,11 @@ public final class ThreadStore: ObservableObject { |
| @Published public var roster: [RosterEntry] = [] // joined members (for the new-chat picker) |
| + // Per-instance feature gates (admin-set, default on) — the pickers hide |
| + // their bot/app entry points when off; the server enforces regardless. |
| + @Published public private(set) var botsEnabled = true |
| + @Published public private(set) var appsEnabled = true |
| + |
| public let instance: SavedInstance |
| public private(set) var myUserId: String |
| private var instanceKeyB64: String? // for member avatars (optional) |
| @@ -96,6 +101,7 @@ public final class ThreadStore: ObservableObject { |
| // Independent of the fetches below — runs concurrently, off the hot |
| // path (refresh refires on every socket frame). |
| async let nickFetchAsync = api.nicknames() |
| + async let gatesAsync = api.featureGates() |
| let people = await api.roster() |
| if myUserId.isEmpty, let me = people.first(where: { $0.public_key == instance.userPub }) { |
| @@ -107,6 +113,9 @@ public final class ThreadStore: ObservableObject { |
| let tierRows = await api.tiers() |
| // nil = fetch failed (keep what we hold); "" = genuinely never set. |
| let nickFetch = await nickFetchAsync |
| + let gates = await gatesAsync |
| + botsEnabled = gates.botsEnabled |
| + appsEnabled = gates.appsEnabled |
| // ALL crypto in one detached pass — the instance-key unwrap, roster |
| // labels, per-thread ECDH unwraps, previews, titles, app ids. refresh() |
diff --git a/ios/LChatMac/NewChatPopover.swift b/ios/LChatMac/NewChatPopover.swift| index 65b4007..8465e83 100644 |
| --- a/ios/LChatMac/NewChatPopover.swift |
| +++ b/ios/LChatMac/NewChatPopover.swift |
| @@ -31,8 +31,12 @@ struct NewChatPopover: View { |
| @Environment(\.colorScheme) private var scheme |
| private var accent: Color { ChatTheme.classic.accent.resolved(scheme) } |
| + // Bots off drops agents from the picker (matching the web sheet) — existing |
| + // bot DMs stay in the sidebar; only starting new ones hides. |
| private var others: [RosterEntry] { |
| - store.roster.filter { $0.user_id != store.myUserId && !$0.label.isEmpty } |
| + store.roster.filter { |
| + $0.user_id != store.myUserId && !$0.label.isEmpty && (store.botsEnabled || !$0.isAgent) |
| + } |
| } |
| var body: some View { |
| @@ -95,26 +99,31 @@ struct NewChatPopover: View { |
| // click to open (created on first use). Not group members, |
| // so in group mode this becomes SHARED APPS instead — |
| // tick the apps the new group should start with (each one |
| - // lands as a groupapps.add in the fresh log). |
| - if !groupMode { |
| + // lands as a groupapps.add in the fresh log). Both vanish |
| + // when the admin turned apps off — except the Admin row, |
| + // which is how an admin would switch apps back on. |
| + if !groupMode && (store.appsEnabled || store.isAdmin) { |
| Text("APPS") |
| .font(.system(size: 10, weight: .semibold)) |
| .kerning(0.5) |
| .foregroundStyle(.tertiary) |
| .padding(EdgeInsets(top: 10, leading: 8, bottom: 2, trailing: 0)) |
| - ForEach(ThreadStore.personalApps, id: \.id) { appEntry in |
| - appRow(appEntry) |
| + if store.appsEnabled { |
| + ForEach(ThreadStore.personalApps, id: \.id) { appEntry in |
| + appRow(appEntry) |
| + } |
| } |
| // The Admin app is system-scope (never in personalApps); |
| // admins get its row here all the same — the discoverable |
| // way in beyond /admin. Same open action, popover closes |
| // into the thread via onCreated like every app row. |
| + // Deliberately exempt from the apps gate. |
| if store.isAdmin { |
| appRow(("admin", "Admin", "🛠️")) |
| .accessibilityLabel("Admin") |
| .accessibilityHint("Opens this space's admin panel") |
| } |
| - } else { |
| + } else if groupMode && store.appsEnabled { |
| Text("SHARED APPS") |
| .font(.system(size: 10, weight: .semibold)) |
| .kerning(0.5) |
diff --git a/main.go b/main.go| index f0132a6..d4c0e43 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -590,6 +590,11 @@ func (s *Server) servePage(name string) http.HandlerFunc { |
| // Whether ordinary members may invite people (gates the invite UI; the server |
| // enforces it regardless). Injected so the client needs no extra round trip. |
| page = strings.ReplaceAll(page, "__USER_INVITES__", boolStr(s.db.usersCanInvite())) |
| + // Per-instance feature gates (same deal: hide the entry points here, the |
| + // gated endpoints enforce). |
| + 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())) |
| // 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. |
| @@ -712,6 +717,13 @@ 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() { |
| + http.NotFound(w, r) |
| + return |
| + } |
| b, err := appGuideFS.ReadFile("docs/building-member-apps.md") |
| if err != nil { |
| httpError(w, http.StatusInternalServerError, "guide unavailable") |
| @@ -2406,7 +2418,7 @@ func (s *Server) getBotConfig(w http.ResponseWriter, r *http.Request) { |
| } |
| uid, ok := s.db.deviceTokenUser(token) |
| if !ok { |
| - writeJSON(w, http.StatusOK, BotConfig{}) |
| + writeJSON(w, http.StatusOK, BotConfig{AIEnabled: s.db.aiEnabled()}) |
| return |
| } |
| c, _, err := s.db.botConfig(uid) |
| @@ -2414,6 +2426,7 @@ func (s *Server) getBotConfig(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| + c.AIEnabled = s.db.aiEnabled() |
| writeJSON(w, http.StatusOK, c) |
| } |
| @@ -2496,6 +2509,13 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusBadRequest, "missing id") |
| return |
| } |
| + // The bots gate stops NEW bot identities being minted (checked before the |
| + // user row exists, so a rejected mint leaves nothing behind); bots already |
| + // on the roster keep running — the admin retires those by removal. |
| + if body.Agent && !s.db.botsEnabled() { |
| + httpError(w, http.StatusForbidden, "bots are turned off for this space") |
| + return |
| + } |
| userID := body.UserID |
| if userID == "" { |
| // A person gets no plaintext name — they publish theirs encrypted (under the |
| @@ -3150,6 +3170,11 @@ func (s *Server) getGroup(w http.ResponseWriter, r *http.Request) { |
| // endpoint is also the public onboarding read, and invitees get the |
| // description via their (unguessable) invite id instead — see getKeyInvite. |
| out["description"] = s.db.getGroupDescription() |
| + // The feature gates ride the member read too — the native apps consult |
| + // them to hide entry points (the gated endpoints enforce regardless). |
| + out["ai_enabled"] = s.db.aiEnabled() |
| + out["bots_enabled"] = s.db.botsEnabled() |
| + out["apps_enabled"] = s.db.appsEnabled() |
| } |
| writeJSON(w, http.StatusOK, out) |
| } |
| @@ -3343,6 +3368,9 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { |
| } |
| 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(), |
| @@ -3363,6 +3391,11 @@ 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"` |
| // 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 |
| @@ -3398,6 +3431,24 @@ func (s *Server) setSettings(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| } |
| + if body.AIEnabled != nil { |
| + if err := s.db.setAIEnabled(*body.AIEnabled); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + } |
| + if body.BotsEnabled != nil { |
| + if err := s.db.setBotsEnabled(*body.BotsEnabled); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + } |
| + if body.AppsEnabled != nil { |
| + if err := s.db.setAppsEnabled(*body.AppsEnabled); 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 ebf2848..fe21946 100644 |
| --- a/memberapps.go |
| +++ b/memberapps.go |
| @@ -99,6 +99,12 @@ 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 |
| + // serving — their threads and state live on miniapps.go, which isn't gated. |
| + if !s.db.appsEnabled() { |
| + httpError(w, http.StatusForbidden, "apps are 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/memberbots.go b/memberbots.go| index 055ab25..0489f8f 100644 |
| --- a/memberbots.go |
| +++ b/memberbots.go |
| @@ -76,6 +76,12 @@ func (s *Server) putMemberBotHandler(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusForbidden, "invalid token") |
| return |
| } |
| + // The bots gate stops new charters (and edits); already-stored charters |
| + // keep running in the sidecar — the admin retires those by deletion. |
| + if !s.db.botsEnabled() { |
| + httpError(w, http.StatusForbidden, "bots are turned off for this space") |
| + return |
| + } |
| if strings.TrimSpace(body.Blob) == "" || len(body.Blob) > maxMemberBotBytes { |
| httpError(w, http.StatusBadRequest, "blob missing or too large") |
| return |
diff --git a/web/pages/admin.html b/web/pages/admin.html| index a4821ab..fe43977 100644 |
| --- a/web/pages/admin.html |
| +++ b/web/pages/admin.html |
| @@ -86,6 +86,28 @@ |
| </label> |
| </section> |
| + <section> |
| + <h2>Features</h2> |
| + <p class="sub" style="margin:0 0 8px"> |
| + 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. |
| + </p> |
| + <label class="toggle-row" style="margin:0 0 8px"> |
| + <input type="checkbox" id="aiEnabled" /> |
| + <span>Enable AI</span> |
| + </label> |
| + <label class="toggle-row" style="margin:0 0 8px"> |
| + <input type="checkbox" id="botsEnabled" /> |
| + <span>Enable bots</span> |
| + </label> |
| + <label class="toggle-row"> |
| + <input type="checkbox" id="appsEnabled" /> |
| + <span>Enable apps</span> |
| + </label> |
| + </section> |
| + |
| <section> |
| <h2>Updates</h2> |
| <p class="sub" style="margin:0 0 8px"> |
| @@ -111,7 +133,7 @@ |
| <p id="updateFeedback" class="sub" style="margin:0" aria-live="polite"></p> |
| </section> |
| - <section> |
| + <section id="agentSection"> |
| <h2>Add an agent</h2> |
| <p class="sub" style="margin:0 0 8px">Adds an AI bot as a member. This mints a one-time link the agent sidecar uses to set up the bot's identity; it then appears in chats as a member with a BOT badge. The sidecar for this instance must be running.</p> |
| <form id="agentForm" autocomplete="off"> |
diff --git a/web/pages/index.html b/web/pages/index.html| index 64998e4..e51ca9d 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -5,6 +5,11 @@ |
| <!-- Whether ordinary members may invite people (admin setting). Gates the invite |
| UI only; the server enforces it regardless. "1" = on, "0" = off. --> |
| <meta name="user-invites" content="__USER_INVITES__" /> |
| +<!-- Per-instance feature gates (admin settings, default on). Same contract as |
| + user-invites: these only hide entry points; the server enforces each gate. --> |
| +<meta name="enable-ai" content="__ENABLE_AI__" /> |
| +<meta name="enable-bots" content="__ENABLE_BOTS__" /> |
| +<meta name="enable-apps" content="__ENABLE_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 2004121..c585d78 100644 |
| --- a/web/static/admin.js |
| +++ b/web/static/admin.js |
| @@ -39,6 +39,7 @@ async function init() { |
| wireGroupName(); |
| wireServerAvatar(); |
| wireUserInvites(); |
| + wireFeatures(); |
| wireUpdates(); |
| wireCustomApps(); |
| wireTestPush(); |
| @@ -66,6 +67,39 @@ async function wireUserInvites() { |
| }; |
| } |
| +// ---- feature gates (AI / bots / apps) ------------------------------------ |
| + |
| +// Each toggle is one partial settings write (like userInvites); the server |
| +// enforces the gates — the console just flips them. Bots off also hides the |
| +// "Add an agent" section: minting an agent invite would 403 anyway. |
| +async function wireFeatures() { |
| + const rows = [ |
| + ["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"], |
| + ]; |
| + const agentSection = document.getElementById("agentSection"); |
| + const syncAgentSection = (botsOn) => agentSection.classList.toggle("hidden", !botsOn); |
| + let settings = {}; |
| + try { |
| + settings = await (await api("GET", "/api/admin/settings")).json(); |
| + } catch {} |
| + for (const [domId, key, onMsg, offMsg] of rows) { |
| + const box = document.getElementById(domId); |
| + box.checked = settings[key] !== false; // default on (older servers omit it) |
| + if (key === "bots_enabled") syncAgentSection(box.checked); |
| + box.onchange = async () => { |
| + const res = await api("POST", "/api/admin/settings", { [key]: box.checked }); |
| + if (!res.ok) { |
| + box.checked = !box.checked; // revert on failure |
| + return toast("Couldn't save that"); |
| + } |
| + if (key === "bots_enabled") syncAgentSection(box.checked); |
| + toast(box.checked ? onMsg : offMsg); |
| + }; |
| + } |
| +} |
| + |
| // ---- updates (auto-update settings + what the channel offers) ----------- |
| // Feedback is inline (the status + feedback lines occupy layout space) — no |
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js| index acf197b..721a743 100644 |
| --- a/web/static/apps/admin.js |
| +++ b/web/static/apps/admin.js |
| @@ -369,6 +369,49 @@ export const app = defineApp({ |
| } |
| }; |
| + // ---- Features -------------------------------------------------------------- |
| + // The per-instance gates (default on). Each switch is one partial write; |
| + // the server enforces the gate — these entry points just follow suit on |
| + // members' next load. |
| + const featSec = section("Features"); |
| + const featDesc = document.createElement("p"); |
| + featDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 8px"; |
| + featDesc.textContent = |
| + "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."; |
| + featSec.appendChild(featDesc); |
| + const featRow = (label, key, onMsg, offMsg) => { |
| + const check = switchRow(featSec, label); |
| + check.onchange = async () => { |
| + const want = check.checked; |
| + if (await bridge.setFeatures({ [key]: want })) { |
| + flash(featStatus, want ? onMsg : offMsg); |
| + } else { |
| + check.checked = !want; // revert — the server didn't take it |
| + flash(featStatus, "Couldn't save — try again."); |
| + } |
| + }; |
| + return check; |
| + }; |
| + const aiCheck = featRow( |
| + "Enable AI", |
| + "ai_enabled", |
| + "AI replies are on.", |
| + "AI replies are off — helpers answer commands only.", |
| + ); |
| + const botsCheck = featRow( |
| + "Enable bots", |
| + "bots_enabled", |
| + "Members can create bots again.", |
| + "Bot creation is off — existing bots keep running.", |
| + ); |
| + const appsCheck = featRow( |
| + "Enable apps", |
| + "apps_enabled", |
| + "Apps are back in the New-chat menu.", |
| + "Apps are off — existing app chats keep working.", |
| + ); |
| + const featStatus = statusLine(featSec); |
| + |
| // ---- Access levels --------------------------------------------------------- |
| // Tiers server-side: named member sets, each with its own key held only by |
| // its members (the shell does all crypto — this section is pure UI). |
| @@ -770,6 +813,9 @@ export const app = defineApp({ |
| paintPreviewDesc(); |
| emailInput.value = info.email || ""; |
| invCheck.checked = !!info.userInvites; |
| + aiCheck.checked = info.aiEnabled !== false; |
| + botsCheck.checked = info.botsEnabled !== false; |
| + appsCheck.checked = info.appsEnabled !== false; |
| autoCheck.checked = !!info.updateAuto; |
| dlCheck.checked = !!info.updateDownload; |
| updState.channel = info.updateChannel || "stable"; |
diff --git a/web/static/shell.js b/web/static/shell.js| index 152b69f..e1794e1 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -1758,6 +1758,16 @@ const APP_VERSION = (document.querySelector('meta[name="app-version"]') || {}).c |
| // the invite UI only; the server enforces it regardless. |
| const USER_INVITES = ((document.querySelector('meta[name="user-invites"]') || {}).content || "") === "1"; |
| +// Per-instance feature gates (server-injected admin settings). Default ON — a |
| +// missing meta (an older server) reads as enabled — and, like USER_INVITES, |
| +// they only hide entry points; the server enforces each gate. ENABLE_AI has no |
| +// web entry point to hide (the sidecar enforces it), but rides along so copy |
| +// can consult it. |
| +const gateMeta = (name) => ((document.querySelector(`meta[name="${name}"]`) || {}).content || "") !== "0"; |
| +const ENABLE_AI = gateMeta("enable-ai"); |
| +const ENABLE_BOTS = gateMeta("enable-bots"); |
| +const ENABLE_APPS = gateMeta("enable-apps"); |
| + |
| // Whether THIS member may invite: the toggle governs ordinary members, but an |
| // admin always can — otherwise the person who just set the space up couldn't |
| // bring anyone in without a trip to the admin console (a path we're |
| @@ -1913,7 +1923,9 @@ window.lchatPaletteItems = () => { |
| if (others) actions.push({ id: "new-group", label: "New group" }); |
| actions.push({ id: "discover", label: "Discover groups" }); |
| if (inviteAllowed()) actions.push({ id: "invite", label: "Invite someone" }); |
| - if (personalApps().length) actions.push({ id: "apps", label: "Open apps" }); |
| + if (ENABLE_APPS && personalApps().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" }); |
| actions.push( |
| { id: "add-device", label: "Add a device" }, |
| @@ -4132,6 +4144,10 @@ setSetupBridge({ |
| avatar: !!g.avatar, |
| avatarAlt: g.avatar_alt || "", |
| userInvites: !!s.user_invites, |
| + // Feature gates (default on — a missing field is an older server). |
| + aiEnabled: s.ai_enabled !== false, |
| + botsEnabled: s.bots_enabled !== false, |
| + appsEnabled: s.apps_enabled !== false, |
| updateAuto: !!s.update_auto, |
| updateDownload: !!s.update_download, |
| updateChannel: s.update_channel || "stable", |
| @@ -4142,6 +4158,16 @@ setSetupBridge({ |
| return null; |
| } |
| }, |
| + // Feature gates — a partial update; pass only the flipped flag |
| + // (ai_enabled / bots_enabled / apps_enabled). Same settings path as |
| + // setUserInvites; the picker/palette gates read it on the next page load. |
| + setFeatures: async (patch) => { |
| + try { |
| + return (await fetch("/api/admin/settings", { method: "POST", headers: adminAuth(), body: JSON.stringify(patch) })).ok; |
| + } catch { |
| + return false; |
| + } |
| + }, |
| // Auto-update prefs — a partial update; pass only the changed field |
| // (update_auto / update_download / update_channel / update_pin). |
| setUpdatePrefs: async (patch) => { |
| @@ -7126,8 +7152,9 @@ function addPeople() { |
| }; |
| // Same People/Helpers split as the new-chat picker: adding a bot to a group |
| // is a distinct, visible act — never something buried among the people. |
| + // And the same bots gate: off hides the helpers here too. |
| const people = matches.filter((e) => !e.is_agent); |
| - const helpers = matches.filter((e) => e.is_agent); |
| + const helpers = ENABLE_BOTS ? matches.filter((e) => e.is_agent) : []; |
| if (people.length && helpers.length) list.appendChild(pickerSection("People")); |
| for (const e of people) addRow(e); |
| if (helpers.length) list.appendChild(pickerSection("Helpers")); |
| @@ -7350,15 +7377,21 @@ function showNewChat(startMode) { |
| render(currentMode); |
| }) |
| .catch(() => (connectedServices = new Set())); |
| + // The apps gate empties this (both the picker's Apps section and the |
| + // no-connections fallback list go through here); existing app THREADS still |
| + // open from the sidebar — only the entry point for new ones hides. |
| const matchingApps = (q) => |
| - personalApps().filter( |
| - (a) => |
| - (a.title || a.name).toLowerCase().includes(q) && |
| - (!a.requiresService || (connectedServices && connectedServices.has(a.requiresService))), |
| - ); |
| + !ENABLE_APPS |
| + ? [] |
| + : personalApps().filter( |
| + (a) => |
| + (a.title || a.name).toLowerCase().includes(q) && |
| + (!a.requiresService || (connectedServices && connectedServices.has(a.requiresService))), |
| + ); |
| // The Admin app is system-scope (deliberately absent from personalApps), but |
| // admins get its row in the Apps section all the same — the discoverable way |
| - // in, beyond the /admin command. Null for everyone else. |
| + // in, beyond the /admin command. Null for everyone else. Deliberately exempt |
| + // from the apps gate: Admin is how an admin switches apps back on. |
| const adminApp = isAdmin() ? { name: "admin", title: "Admin", glyph: "🛠️" } : null; |
| const adminMatches = (q) => !!adminApp && adminApp.title.toLowerCase().includes(q); |
| const addAppRow = (listEl, app) => { |
| @@ -7643,7 +7676,9 @@ const disc = document.createElement("button"); |
| // group sits alongside them (a lone people list needs no label); helpers and |
| // apps always get theirs, since each is deliberately "not a person". |
| const people = others.filter((e) => !e.is_agent && rosterMatches(e, q)); |
| - const helpers = others.filter((e) => e.is_agent && rosterMatches(e, q)); |
| + // Bots off empties the Helpers section — existing bot DMs stay in the |
| + // sidebar; only starting new ones hides. |
| + const helpers = ENABLE_BOTS ? others.filter((e) => e.is_agent && rosterMatches(e, q)) : []; |
| const apps = mode === "people" ? matchingApps(q) : []; |
| const showAdmin = mode === "people" && adminMatches(q); |
| if (!people.length && !helpers.length && !apps.length && !showAdmin) { |