eleven / ocho Public

Custom apps: DB-stored apps + admin-console install UI (#244)

* server+admin: DB-stored custom apps with admin-console install

Stacks on custom-apps-sideload. Adds a custom_apps table (additive CREATE
TABLE IF NOT EXISTS: name/source/enabled/version/timestamps; version bumps
on every update) and admin endpoints — GET /api/admin/apps to list
name/version/size/updated, PUT /api/admin/apps/{name} to install or update
from raw JS source, DELETE to uninstall — gated by requireAdmin like every
other admin route. Names are locked to lowercase ^[a-z0-9-]+$.

/static/apps-custom/<name>.js now serves the DB copy first, falling back to
LCHAT_APPS_DIR: the admin console is authoritative and shadows a same-named
disk file, keeping the dir a dev tool. /api/apps merges both sources,
deduped.

The admin console gains an Apps section: list installed apps, install via a
file picker (FileReader) or a paste textarea, delete with confirm, and a
plain always-visible trust warning — an installed app runs its code on
every member's device with access to everything that device can decrypt.
Feedback is inline (no floating toasts); all server-provided strings render
via textContent.

Co-Authored-By: Claude <noreply@anthropic.com>

* server+admin+web: live app refresh, tidier console list, disk apps shown

Three follow-ups to the DB-stored custom apps commit:

- Installing, updating, or deleting an app now broadcasts apps_changed to
  every connected client over the existing hub fan-out; shell.js responds
  with a reload (modules are dynamic-imported once at boot) guarded by a
  new hasUnsentDraft() that checks the live chat-app composers — the old
  build-update guard was reading the retired classic composer's #text,
  which is always empty. Copy updated: "next time the app loads" →
  members' open apps refresh automatically.

- The admin Apps list uses the People list's row shape (.invite/.meta/
  .label/.status) instead of a cramped inline row, adds a View source
  link (the module route already serves the stored JS), and Delete
  matches the People list's link-style danger button.

- The list now also shows LCHAT_APPS_DIR sideloads (marked on_disk,
  "ON DISK" tag, read-only — no Delete; shadowed files omitted), so the
  console reflects everything members actually get.

Co-Authored-By: Claude <noreply@anthropic.com>

---------
Co-authored-by: Claude <noreply@anthropic.com>
James Adam b845cb021b4fe9ec6ea2114da225497a3e73ea9d parent 90bf877
9 files changed, +655 −33
  • changelog/2026-07-06-custom-apps-admin.md +4 −0
  • customapps.go +165 −31
  • db.go +88 −0
  • handlers_test.go +227 −0
  • main.go +4 −0
  • web/pages/admin.html +24 −0
  • web/static/admin.js +122 −0
  • web/static/shell.js +18 −2
  • web/static/style.css +3 −0
diff --git a/changelog/2026-07-06-custom-apps-admin.md b/changelog/2026-07-06-custom-apps-admin.md
new file mode 100644
index 0000000..fa08b5a
--- /dev/null
+++ b/changelog/2026-07-06-custom-apps-admin.md
@@ -0,0 +1,4 @@
+🤖 Space admins can now install custom mini-apps right from the admin console —
+pick a file or paste the code, and everyone's app refreshes on the spot to pick
+it up. There's a matching list to see what's installed, view an app's source,
+and remove anything you no longer want.
diff --git a/customapps.go b/customapps.go
index 1707175..649de71 100644
--- a/customapps.go
+++ b/customapps.go
@@ -1,22 +1,30 @@
package main
// Custom mini-apps: serve app modules that are NOT compiled into the binary.
-// An operator points LCHAT_APPS_DIR at a directory of *.js files and each is
-// served at /static/apps-custom/<name>.js; the client discovers them via the
-// /api/apps manifest at boot and dynamic-imports each one (see shell.js).
+// Two sources feed the same URL space:
+// - the DB (custom_apps): modules installed from the admin console — the
+// authoritative store;
+// - LCHAT_APPS_DIR: an operator-pointed directory of *.js files — a dev
+// tool for iterating on an app without touching the console.
+// Each app is served at /static/apps-custom/<name>.js; the client discovers
+// them via the /api/apps manifest at boot and dynamic-imports each one (see
+// shell.js). On a name collision the DB wins — what the admin installed is
+// what members run; the disk file is shadowed until the DB copy is deleted.
//
// The URL sits exactly ONE path segment below /static/ on purpose: a custom
// app's relative `import { defineApp } from "../apps.js"` then resolves to
// /static/apps.js, so a custom app is byte-for-byte the same shape as a
// compiled-in app — same imports, same framework, no build step.
//
-// Trust model: app code is CODE the operator chose to ship, not user content —
+// Trust model: app code is CODE the admin chose to ship, not user content —
// the compiled-in apps are served in the clear the same way, so this adds no
// new exposure class. The manifest and the modules are served ungated, like
// the static shell itself: they reveal only app names, comparable to the
-// plaintext instance branding (group name / server avatar).
+// plaintext instance branding (group name / server avatar). Installing and
+// deleting require the admin token, exactly like the other admin routes.
import (
+ "encoding/json"
"net/http"
"os"
"path/filepath"
@@ -25,36 +33,50 @@ import (
"strings"
)
-// customAppFile accepts only clean base names ("kanban.js") — lowercase
-// letters, digits, and dashes. Anything else (dots, slashes, uppercase) 404s,
-// which is also the path-traversal guard: a name that matches can't escape the
-// directory.
-var customAppFile = regexp.MustCompile(`^[a-z0-9-]+\.js$`)
+// customAppName accepts only lowercase letters, digits, and dashes — the app
+// id an admin installs under. customAppFile is the same rule with the .js
+// suffix, for URL path segments and LCHAT_APPS_DIR entries. Anything else
+// 404s, which is also the path-traversal guard: a name that matches can't
+// escape the directory.
+var (
+ customAppName = regexp.MustCompile(`^[a-z0-9-]+$`)
+ customAppFile = regexp.MustCompile(`^[a-z0-9-]+\.js$`)
+)
+
+// maxCustomAppBytes caps one app's source. Mini-apps are single-file ES
+// modules — the largest compiled-in one is ~100 KB — so 1 MB is generous
+// while keeping the admin endpoint from being abused as bulk storage.
+const maxCustomAppBytes = 1 << 20
-// customAppNames lists the sideloaded app names (without ".js") in appsDir,
-// sorted. Only top-level regular files with valid names count; an unset dir or
-// an unreadable one is just "no custom apps".
+// customAppNames lists the available app names (without ".js"), sorted and
+// deduped: DB-installed apps plus LCHAT_APPS_DIR files. An unset dir or an
+// unreadable one is just "no sideloaded apps".
func (s *Server) customAppNames() []string {
- if s.appsDir == "" {
- return nil
+ seen := map[string]bool{}
+ if apps, err := s.db.listCustomApps(); err == nil {
+ for _, a := range apps {
+ seen[a.Name] = true
+ }
}
- entries, err := os.ReadDir(s.appsDir)
- if err != nil {
- return nil
+ if s.appsDir != "" {
+ if entries, err := os.ReadDir(s.appsDir); err == nil {
+ for _, e := range entries {
+ if e.Type().IsRegular() && customAppFile.MatchString(e.Name()) {
+ seen[strings.TrimSuffix(e.Name(), ".js")] = true
+ }
+ }
+ }
}
names := []string{}
- for _, e := range entries {
- if !e.Type().IsRegular() || !customAppFile.MatchString(e.Name()) {
- continue
- }
- names = append(names, strings.TrimSuffix(e.Name(), ".js"))
+ for name := range seen {
+ names = append(names, name)
}
sort.Strings(names)
return names
}
// getCustomApps serves the /api/apps manifest the client loads at boot. Always
-// a list (empty when nothing is sideloaded), never an error — a client must be
+// a list (empty when nothing is installed), never an error — a client must be
// able to treat "no custom apps" and "feature unused" identically.
func (s *Server) getCustomApps(w http.ResponseWriter, r *http.Request) {
apps := []map[string]string{}
@@ -67,20 +89,132 @@ func (s *Server) getCustomApps(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"apps": apps})
}
-// serveCustomApp serves one sideloaded module. The name is validated against
-// the same strict pattern the manifest uses — everything else 404s, so the
-// handler can never read outside appsDir.
+// serveCustomApp serves one custom module: the DB copy when installed (the
+// admin console is authoritative), else the LCHAT_APPS_DIR file. The name is
+// validated against the same strict pattern the manifest uses — everything
+// else 404s, so the handler can never read outside appsDir.
func (s *Server) serveCustomApp(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
- if s.appsDir == "" || !customAppFile.MatchString(name) {
+ if !customAppFile.MatchString(name) {
http.NotFound(w, r)
return
}
- b, err := os.ReadFile(filepath.Join(s.appsDir, name))
+ src, ok, err := s.db.getCustomApp(strings.TrimSuffix(name, ".js"))
if err != nil {
- http.NotFound(w, r)
+ httpError(w, http.StatusInternalServerError, "db error")
return
}
+ if !ok {
+ if s.appsDir == "" {
+ http.NotFound(w, r)
+ return
+ }
+ b, err := os.ReadFile(filepath.Join(s.appsDir, name))
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ src = string(b)
+ }
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
- w.Write(b)
+ w.Write([]byte(src))
+}
+
+// broadcastAppsChanged tells every connected client the installed app set
+// changed (members:nil → everyone). Modules are dynamic-imported once at boot,
+// so clients respond by reloading — see the apps_changed handler in shell.js.
+func (s *Server) broadcastAppsChanged() {
+ out, _ := json.Marshal(map[string]any{"type": "apps_changed"})
+ s.hub.broadcast <- outbound{data: out}
+}
+
+// ---- admin API (requireAdmin, like every other admin route) --------------
+
+// listCustomAppsHandler returns the apps' metadata for the admin console —
+// name, version, size, updated. DB-installed apps are the manageable ones;
+// LCHAT_APPS_DIR sideloads are listed too (marked on_disk, so the console
+// shows what members actually get) but read-only: the console can't delete a
+// file. A disk file shadowed by a same-named DB copy is omitted — the DB copy
+// is what serves.
+func (s *Server) listCustomAppsHandler(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ apps, err := s.db.listCustomApps()
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ inDB := map[string]bool{}
+ for _, a := range apps {
+ inDB[a.Name] = true
+ }
+ if s.appsDir != "" {
+ if entries, err := os.ReadDir(s.appsDir); err == nil {
+ for _, e := range entries {
+ if !e.Type().IsRegular() || !customAppFile.MatchString(e.Name()) {
+ continue
+ }
+ name := strings.TrimSuffix(e.Name(), ".js")
+ if inDB[name] {
+ continue
+ }
+ info, err := e.Info()
+ if err != nil {
+ continue
+ }
+ apps = append(apps, CustomApp{Name: name, Size: int(info.Size()), UpdatedAt: info.ModTime().Unix(), OnDisk: true})
+ }
+ }
+ }
+ sort.Slice(apps, func(i, j int) bool { return apps[i].Name < apps[j].Name })
+ writeJSON(w, http.StatusOK, map[string]any{"apps": apps})
+}
+
+// putCustomAppHandler installs or updates an app from raw JS source.
+func (s *Server) putCustomAppHandler(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ name := r.PathValue("name")
+ if !customAppName.MatchString(name) {
+ httpError(w, http.StatusBadRequest, "app name must be lowercase letters, digits, and dashes")
+ return
+ }
+ var body struct {
+ Source string `json:"source"`
+ }
+ if json.NewDecoder(http.MaxBytesReader(w, r.Body, maxCustomAppBytes)).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if strings.TrimSpace(body.Source) == "" {
+ httpError(w, http.StatusBadRequest, "missing source")
+ return
+ }
+ if err := s.db.putCustomApp(name, body.Source); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ s.broadcastAppsChanged()
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "url": "/static/apps-custom/" + name + ".js"})
+}
+
+// deleteCustomAppHandler uninstalls an app.
+func (s *Server) deleteCustomAppHandler(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ name := r.PathValue("name")
+ done, err := s.db.deleteCustomApp(name)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ if !done {
+ httpError(w, http.StatusNotFound, "no such app")
+ return
+ }
+ s.broadcastAppsChanged()
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
diff --git a/db.go b/db.go
index 552e4e3..f8f1c20 100644
--- a/db.go
+++ b/db.go
@@ -317,6 +317,19 @@ CREATE TABLE IF NOT EXISTS connected_services (
blob TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, service)
+);
+-- Custom mini-apps installed via the admin console (customapps.go): plain JS
+-- module source served to every member at /static/apps-custom/<name>.js. This
+-- is admin-chosen CODE, not user content — the compiled-in apps ship in the
+-- clear the same way — so, like branding, it lives unencrypted. version bumps
+-- on every update so the admin list can tell revisions apart.
+CREATE TABLE IF NOT EXISTS custom_apps (
+ name TEXT PRIMARY KEY,
+ source TEXT NOT NULL,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ version INTEGER NOT NULL DEFAULT 1,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
);`)
if err != nil {
return err
@@ -2232,6 +2245,81 @@ func (db *DB) listConnectedServices(userID string) ([]string, error) {
return out, rows.Err()
}
+// ---- custom apps (customapps.go) -----------------------------------------
+//
+// Admin-installed mini-app module source, served to members like the compiled-in
+// apps. The DB is the admin console's store and shadows a same-named file in
+// LCHAT_APPS_DIR — the console is authoritative; the disk dir stays a dev tool.
+
+// CustomApp is one installed app's metadata for the admin list (the source is
+// read separately — it can be large and the list never needs it).
+type CustomApp struct {
+ Name string `json:"name"`
+ Version int `json:"version"`
+ Size int `json:"size"`
+ UpdatedAt int64 `json:"updated_at"`
+ // OnDisk marks an LCHAT_APPS_DIR sideload in the admin list — never a DB
+ // row; the console shows it read-only (see listCustomAppsHandler).
+ OnDisk bool `json:"on_disk,omitempty"`
+}
+
+// putCustomApp installs an app or replaces its source, bumping version on update
+// so the admin list shows that a revision landed.
+func (db *DB) putCustomApp(name, source string) error {
+ now := time.Now().Unix()
+ _, err := db.sql.Exec(
+ "INSERT INTO custom_apps(name, source, enabled, version, created_at, updated_at) VALUES(?, ?, 1, 1, ?, ?) "+
+ "ON CONFLICT(name) DO UPDATE SET source = excluded.source, enabled = 1, "+
+ "version = custom_apps.version + 1, updated_at = excluded.updated_at",
+ name, source, now, now,
+ )
+ return err
+}
+
+// getCustomApp returns an installed, enabled app's source (ok=false if none).
+func (db *DB) getCustomApp(name string) (string, bool, error) {
+ var source string
+ err := db.sql.QueryRow(
+ "SELECT source FROM custom_apps WHERE name = ? AND enabled = 1", name,
+ ).Scan(&source)
+ if err == sql.ErrNoRows {
+ return "", false, nil
+ }
+ if err != nil {
+ return "", false, err
+ }
+ return source, true, nil
+}
+
+// listCustomApps returns the installed apps' metadata, sorted by name.
+func (db *DB) listCustomApps() ([]CustomApp, error) {
+ rows, err := db.sql.Query(
+ "SELECT name, version, length(source), updated_at FROM custom_apps ORDER BY name")
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []CustomApp{}
+ for rows.Next() {
+ var a CustomApp
+ if err := rows.Scan(&a.Name, &a.Version, &a.Size, &a.UpdatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, a)
+ }
+ return out, rows.Err()
+}
+
+// deleteCustomApp uninstalls an app; reports whether it existed.
+func (db *DB) deleteCustomApp(name string) (bool, error) {
+ res, err := db.sql.Exec("DELETE FROM custom_apps WHERE name = ?", name)
+ if err != nil {
+ return false, err
+ }
+ n, _ := res.RowsAffected()
+ return n > 0, nil
+}
+
// ---- reactions ----------------------------------------------------------
//
// A reaction is (message_id, token, emoji). The emoji is stored in the clear so
diff --git a/handlers_test.go b/handlers_test.go
index 2a9e3ea..a99fafa 100644
--- a/handlers_test.go
+++ b/handlers_test.go
@@ -2,6 +2,7 @@ package main
import (
"bytes"
+ "context"
"encoding/json"
"io"
"net/http"
@@ -12,6 +13,8 @@ import (
"strings"
"testing"
"time"
+
+ "github.com/coder/websocket"
)
func newTestServer(t *testing.T) (*httptest.Server, *Server) {
@@ -1358,3 +1361,227 @@ func TestAppChecker(t *testing.T) {
t.Fatal("checker body missing defineApp — wrong file served?")
}
}
+
+// adminJSON issues a method+JSON-body request with the admin bearer token
+// (adminReq above takes raw bytes; this wraps it for the string-literal cases).
+func adminJSON(t *testing.T, method, url, body string) *http.Response {
+ t.Helper()
+ ct := ""
+ if body != "" {
+ ct = "application/json"
+ }
+ return adminReq(t, method, url, ct, []byte(body))
+}
+
+func TestCustomAppsAdminAuthGating(t *testing.T) {
+ ts, _ := newTestServer(t)
+
+ for _, c := range []struct{ method, path string }{
+ {"GET", "/api/admin/apps"},
+ {"PUT", "/api/admin/apps/kanban"},
+ {"DELETE", "/api/admin/apps/kanban"},
+ } {
+ // No token at all.
+ req, _ := http.NewRequest(c.method, ts.URL+c.path, strings.NewReader(`{"source":"x"}`))
+ resp, _ := http.DefaultClient.Do(req)
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("%s %s no auth: want 401, got %d", c.method, c.path, resp.StatusCode)
+ }
+ // Wrong token.
+ req, _ = http.NewRequest(c.method, ts.URL+c.path, strings.NewReader(`{"source":"x"}`))
+ req.Header.Set("Authorization", "Bearer wrong")
+ resp, _ = http.DefaultClient.Do(req)
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("%s %s bad auth: want 401, got %d", c.method, c.path, resp.StatusCode)
+ }
+ }
+}
+
+func TestCustomAppsAdminRoundTrip(t *testing.T) {
+ ts, _ := newTestServer(t)
+ src := `import { defineApp } from "../apps.js"; defineApp({ name: "kanban" });`
+ b, _ := json.Marshal(map[string]string{"source": src})
+
+ // Install.
+ resp := adminJSON(t, "PUT", ts.URL+"/api/admin/apps/kanban", string(b))
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("install: want 200, got %d", resp.StatusCode)
+ }
+
+ // The public manifest lists it.
+ resp, _ = http.Get(ts.URL + "/api/apps")
+ var manifest struct {
+ Apps []map[string]string `json:"apps"`
+ }
+ json.NewDecoder(resp.Body).Decode(&manifest)
+ if len(manifest.Apps) != 1 || manifest.Apps[0]["url"] != "/static/apps-custom/kanban.js" {
+ t.Fatalf("manifest after install: got %v", manifest.Apps)
+ }
+
+ // The module serves the stored source.
+ resp, _ = http.Get(ts.URL + "/static/apps-custom/kanban.js")
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("module: want 200, got %d", resp.StatusCode)
+ }
+ got, _ := io.ReadAll(resp.Body)
+ if string(got) != src {
+ t.Fatalf("module body: got %q", got)
+ }
+
+ // Updating bumps the version; the admin list reports name/version/size.
+ resp = adminJSON(t, "PUT", ts.URL+"/api/admin/apps/kanban", string(b))
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("update: want 200, got %d", resp.StatusCode)
+ }
+ resp = adminJSON(t, "GET", ts.URL+"/api/admin/apps", "")
+ var listed struct {
+ Apps []CustomApp `json:"apps"`
+ }
+ json.NewDecoder(resp.Body).Decode(&listed)
+ if len(listed.Apps) != 1 || listed.Apps[0].Name != "kanban" || listed.Apps[0].Version != 2 || listed.Apps[0].Size != len(src) {
+ t.Fatalf("admin list: got %+v", listed.Apps)
+ }
+
+ // Delete → gone from the manifest and the module route.
+ resp = adminJSON(t, "DELETE", ts.URL+"/api/admin/apps/kanban", "")
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("delete: want 200, got %d", resp.StatusCode)
+ }
+ resp, _ = http.Get(ts.URL + "/api/apps")
+ manifest.Apps = nil
+ json.NewDecoder(resp.Body).Decode(&manifest)
+ if len(manifest.Apps) != 0 {
+ t.Fatalf("manifest after delete: got %v", manifest.Apps)
+ }
+ resp, _ = http.Get(ts.URL + "/static/apps-custom/kanban.js")
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("module after delete: want 404, got %d", resp.StatusCode)
+ }
+ // Deleting again 404s.
+ resp = adminJSON(t, "DELETE", ts.URL+"/api/admin/apps/kanban", "")
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("re-delete: want 404, got %d", resp.StatusCode)
+ }
+}
+
+func TestCustomAppsAdminRejectsInvalidNames(t *testing.T) {
+ ts, _ := newTestServer(t)
+
+ for _, name := range []string{"Kanban", "kanban.js", "a_b", "a%20b", "-"} {
+ resp := adminJSON(t, "PUT", ts.URL+"/api/admin/apps/"+name, `{"source":"x"}`)
+ // "-" is actually valid per the pattern; skip it (documents the boundary).
+ if name == "-" {
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("install %q: want 200, got %d", name, resp.StatusCode)
+ }
+ continue
+ }
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("install %q: want 400, got %d", name, resp.StatusCode)
+ }
+ }
+ // Empty source is rejected too.
+ resp := adminJSON(t, "PUT", ts.URL+"/api/admin/apps/kanban", `{"source":" "}`)
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("empty source: want 400, got %d", resp.StatusCode)
+ }
+}
+
+func TestCustomAppsDBWinsOverDir(t *testing.T) {
+ ts, s := newTestServer(t)
+ dir := t.TempDir()
+ os.WriteFile(dir+"/kanban.js", []byte("// disk copy"), 0o644)
+ os.WriteFile(dir+"/other.js", []byte("// disk only"), 0o644)
+ s.appsDir = dir
+
+ // Install a DB copy under the same name: it shadows the disk file.
+ resp := adminJSON(t, "PUT", ts.URL+"/api/admin/apps/kanban", `{"source":"// db copy"}`)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("install: want 200, got %d", resp.StatusCode)
+ }
+ resp, _ = http.Get(ts.URL + "/static/apps-custom/kanban.js")
+ got, _ := io.ReadAll(resp.Body)
+ if string(got) != "// db copy" {
+ t.Fatalf("db should win over dir: got %q", got)
+ }
+
+ // The manifest merges both sources, deduped.
+ resp, _ = http.Get(ts.URL + "/api/apps")
+ var manifest struct {
+ Apps []map[string]string `json:"apps"`
+ }
+ json.NewDecoder(resp.Body).Decode(&manifest)
+ if len(manifest.Apps) != 2 || manifest.Apps[0]["name"] != "kanban" || manifest.Apps[1]["name"] != "other" {
+ t.Fatalf("merged manifest: got %v", manifest.Apps)
+ }
+
+ // The admin list shows both: the DB copy manageable, the disk-only file
+ // marked on_disk — and the shadowed disk kanban.js doesn't double-list.
+ resp = adminJSON(t, "GET", ts.URL+"/api/admin/apps", "")
+ var listed struct {
+ Apps []CustomApp `json:"apps"`
+ }
+ json.NewDecoder(resp.Body).Decode(&listed)
+ if len(listed.Apps) != 2 || listed.Apps[0].Name != "kanban" || listed.Apps[0].OnDisk ||
+ listed.Apps[1].Name != "other" || !listed.Apps[1].OnDisk {
+ t.Fatalf("admin list with dir: got %+v", listed.Apps)
+ }
+
+ // Deleting the DB copy unshadows the disk file.
+ adminJSON(t, "DELETE", ts.URL+"/api/admin/apps/kanban", "")
+ resp, _ = http.Get(ts.URL + "/static/apps-custom/kanban.js")
+ got, _ = io.ReadAll(resp.Body)
+ if string(got) != "// disk copy" {
+ t.Fatalf("after delete the disk copy should serve: got %q", got)
+ }
+}
+
+// Installing or deleting an app broadcasts apps_changed to every connected
+// client, so open tabs reload and pick the change up right away.
+func TestCustomAppsBroadcastsAppsChanged(t *testing.T) {
+ ts, srv := newTestServer(t)
+ tok, _, tid := joinedMember(t, srv, "Alice")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?token=" + tok
+ conn, _, err := websocket.Dial(ctx, wsURL, nil)
+ if err != nil {
+ t.Fatalf("ws dial: %v", err)
+ }
+ defer conn.CloseNow()
+ // Registration races the admin call (the hub processes register/broadcast on
+ // one loop, but Dial returns before the server's register lands). A stored
+ // message round-trip proves our registration landed first.
+ if err := conn.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"warmup"}`)); err != nil {
+ t.Fatalf("warmup write: %v", err)
+ }
+ if _, _, err := conn.Read(ctx); err != nil {
+ t.Fatalf("warmup read: %v", err)
+ }
+
+ expectAppsChanged := func(stage string) {
+ var msg struct {
+ Type string `json:"type"`
+ }
+ _, data, err := conn.Read(ctx)
+ if err != nil {
+ t.Fatalf("%s read: %v", stage, err)
+ }
+ if json.Unmarshal(data, &msg) != nil || msg.Type != "apps_changed" {
+ t.Fatalf("%s: want apps_changed, got %s", stage, data)
+ }
+ }
+
+ resp := adminJSON(t, "PUT", ts.URL+"/api/admin/apps/kanban", `{"source":"x"}`)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("install: want 200, got %d", resp.StatusCode)
+ }
+ expectAppsChanged("install")
+
+ resp = adminJSON(t, "DELETE", ts.URL+"/api/admin/apps/kanban", "")
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("delete: want 200, got %d", resp.StatusCode)
+ }
+ expectAppsChanged("delete")
+}
diff --git a/main.go b/main.go
index df863b6..3e4ffe8 100644
--- a/main.go
+++ b/main.go
@@ -299,6 +299,10 @@ func (s *Server) handler() http.Handler {
// same imports as a compiled-in app. See customapps.go for the trust model.
mux.Handle("GET /static/apps-custom/{name}", noCache(http.HandlerFunc(s.serveCustomApp)))
mux.HandleFunc("GET /api/apps", s.getCustomApps)
+ // Installing/removing custom apps is admin-only, like the rest of /api/admin.
+ mux.HandleFunc("GET /api/admin/apps", s.listCustomAppsHandler)
+ mux.HandleFunc("PUT /api/admin/apps/{name}", s.putCustomAppHandler)
+ mux.HandleFunc("DELETE /api/admin/apps/{name}", s.deleteCustomAppHandler)
// Chat API (transport auth via invite token; never sees plaintext)
mux.HandleFunc("GET /api/messages", s.getMessages)
diff --git a/web/pages/admin.html b/web/pages/admin.html
index 28195f9..83d3c62 100644
--- a/web/pages/admin.html
+++ b/web/pages/admin.html
@@ -110,6 +110,30 @@
</form>
</section>
+ <section>
+ <h2>Apps</h2>
+ <p class="sub" style="margin:0 0 8px">
+ Install custom mini-apps for this space — each is a single JavaScript
+ module file, just like the built-in To-do or Polls. Members' open apps
+ refresh automatically when you install or remove one.
+ </p>
+ <p class="sub" style="margin:0 0 8px">
+ <strong>Install only code you trust.</strong> An installed app runs its
+ code on every member's device, with access to everything that device can
+ decrypt — messages, names, and photos included.
+ </p>
+ <div id="appsList" style="margin:0 0 10px"><p class="sub">Loading…</p></div>
+ <div class="row" style="margin:0 0 8px">
+ <button type="button" id="appPick">Install from file…</button>
+ </div>
+ <input type="file" id="appFile" accept=".js,text/javascript" style="display:none" />
+ <label class="field-label" for="appName">Or paste the source</label>
+ <input id="appName" placeholder="App name (lowercase letters, digits, dashes)" maxlength="60" style="display:block;width:100%;margin:0 0 8px" />
+ <textarea id="appSource" rows="6" placeholder="// paste the app module's JavaScript here" class="mono" style="display:block;width:100%;margin:0 0 8px"></textarea>
+ <button type="button" id="appInstall">Install</button>
+ <p id="appsStatus" class="sub" style="margin:8px 0 0" aria-live="polite"></p>
+ </section>
+
<section>
<h2>Notifications</h2>
<p class="sub" style="margin:0 0 8px">Send a test notification to everyone who has them enabled — a quick way to check delivery.</p>
diff --git a/web/static/admin.js b/web/static/admin.js
index 9ed4c04..14ade1c 100644
--- a/web/static/admin.js
+++ b/web/static/admin.js
@@ -1,6 +1,7 @@
import { randomSecret, deriveInvite } from "/static/crypto.js";
import { buildInviteFragment } from "/static/invite.js";
import { qrCanvas } from "/static/qrcode.js";
+import { formatBytes } from "/static/format.js";
const ADMIN_KEY = "lchat_admin_token";
@@ -38,6 +39,7 @@ async function init() {
wireGroupName();
wireServerAvatar();
wireUserInvites();
+ wireCustomApps();
wireTestPush();
wireCreate();
wireCreateAgent();
@@ -178,6 +180,126 @@ async function downscaleImage(file, maxDim) {
return await blob.arrayBuffer();
}
+// ---- custom apps ---------------------------------------------------------
+
+// Install / remove custom mini-apps (customapps.go). The DB copy this console
+// manages shadows any same-named LCHAT_APPS_DIR file — the console is
+// authoritative; the disk dir is a dev tool. Feedback is inline (appsStatus),
+// never a floating toast, and every server-provided string renders via
+// textContent — app names and metadata are never innerHTML'd.
+function wireCustomApps() {
+ const list = document.getElementById("appsList");
+ const pick = document.getElementById("appPick");
+ const file = document.getElementById("appFile");
+ const nameInput = document.getElementById("appName");
+ const source = document.getElementById("appSource");
+ const installBtn = document.getElementById("appInstall");
+ const status = document.getElementById("appsStatus");
+
+ const setStatus = (msg, isError = false) => {
+ status.textContent = msg || "";
+ status.style.color = isError ? "#c0392b" : "";
+ };
+
+ // Mirrors the server's rule (customAppName in customapps.go) so a bad name
+ // gets a helpful message instead of a bare 400.
+ const NAME_RE = /^[a-z0-9-]+$/;
+
+ async function refresh() {
+ let apps = [];
+ try {
+ const res = await api("GET", "/api/admin/apps");
+ if (res.ok) apps = (await res.json()).apps || [];
+ } catch {}
+ list.replaceChildren();
+ if (!apps.length) {
+ const p = document.createElement("p");
+ p.className = "sub";
+ p.textContent = "No custom apps installed.";
+ list.appendChild(p);
+ return;
+ }
+ // Same row shape as the People list (.invite/.meta/.label/.status), so the
+ // two lists read as one console.
+ for (const a of apps) {
+ const row = document.createElement("div");
+ row.className = "invite";
+ const meta = document.createElement("div");
+ meta.className = "meta";
+ const label = document.createElement("div");
+ label.className = "label mono";
+ label.textContent = a.name;
+ if (a.on_disk) {
+ // Sideloaded from LCHAT_APPS_DIR — the console can't manage the file,
+ // so the row is read-only (no Delete) with a provenance tag.
+ const tag = document.createElement("span");
+ tag.textContent = "ON DISK";
+ tag.className = "bot-tag";
+ label.appendChild(tag);
+ }
+ const status = document.createElement("div");
+ status.className = "status";
+ const updated = `${formatBytes(a.size)} · updated ${new Date(a.updated_at * 1000).toLocaleDateString()}`;
+ status.textContent = a.on_disk ? updated : `v${a.version} · ${updated}`;
+ meta.append(label, status);
+ // The module route serves the stored source as text/javascript, so a plain
+ // link is a source viewer.
+ const view = document.createElement("a");
+ view.className = "linkish";
+ view.textContent = "View source";
+ view.href = `/static/apps-custom/${encodeURIComponent(a.name)}.js`;
+ view.target = "_blank";
+ view.rel = "noopener";
+ row.append(meta, view);
+ if (!a.on_disk) {
+ const del = document.createElement("button");
+ del.type = "button";
+ del.className = "linkish danger";
+ del.textContent = "Delete";
+ del.onclick = async () => {
+ if (!confirm(`Delete the app “${a.name}” for everyone?`)) return;
+ const res = await api("DELETE", `/api/admin/apps/${encodeURIComponent(a.name)}`);
+ setStatus(res.ok ? `Deleted ${a.name}.` : "Couldn't delete that app.", !res.ok);
+ refresh();
+ };
+ row.append(del);
+ }
+ list.appendChild(row);
+ }
+ }
+
+ async function install(name, src) {
+ if (!NAME_RE.test(name)) {
+ return setStatus("App names are lowercase letters, digits, and dashes only.", true);
+ }
+ if (!src.trim()) return setStatus("The app source is empty.", true);
+ setStatus("Installing…");
+ const res = await api("PUT", `/api/admin/apps/${encodeURIComponent(name)}`, { source: src });
+ if (!res.ok) return setStatus("Couldn't install that app.", true);
+ setStatus(`Installed ${name}. Members' open apps refresh to pick it up.`);
+ nameInput.value = "";
+ source.value = "";
+ refresh();
+ }
+
+ pick.onclick = () => file.click();
+ file.onchange = () => {
+ const f = file.files[0];
+ file.value = "";
+ if (!f) return;
+ // The app name comes from the file name (kanban.js → kanban); install()
+ // rejects anything that doesn't survive as a valid name.
+ const name = f.name.replace(/\.js$/i, "").toLowerCase();
+ const reader = new FileReader();
+ reader.onload = () => install(name, String(reader.result || ""));
+ reader.onerror = () => setStatus("Couldn't read that file.", true);
+ reader.readAsText(f);
+ };
+ installBtn.onclick = () => install(nameInput.value.trim(), source.value);
+
+ refresh();
+}
+
// ---- notifications ------------------------------------------------------
function wireTestPush() {
diff --git a/web/static/shell.js b/web/static/shell.js
index 3416371..b9d6863 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -993,6 +993,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";
+// True when a composer holds unsent text — reload paths check this so picking up
+// an update never eats someone's draft. Covers every mounted chat-app composer
+// (Split view shows two) plus the legacy #text box.
+function hasUnsentDraft() {
+ for (const t of document.querySelectorAll(".composer textarea, #text")) {
+ if (t.value && t.value.trim()) return true;
+ }
+ return false;
+}
+
// On returning to the app, ask the server for the current build hash; if it shipped
// a newer frontend, reload to pick it up — unless that would clobber an unsent draft.
let updateChecking = false;
@@ -1003,8 +1013,7 @@ async function checkForUpdate() {
const r = await fetch("/api/version", { cache: "no-store" });
if (r.ok) {
const v = (await r.json()).version;
- const text = document.getElementById("text");
- if (v && v !== APP_VERSION && !(text && text.value.trim())) location.reload();
+ if (v && v !== APP_VERSION && !hasUnsentDraft()) location.reload();
}
} catch {
} finally {
@@ -4081,6 +4090,13 @@ function connectWS() {
refreshAvatars();
return;
}
+ if (msg.type === "apps_changed") {
+ // The admin installed, updated, or removed a custom app. Modules are
+ // dynamic-imported once at boot (ES modules can't be hot-swapped), so a
+ // reload is the pickup — skipped if it would clobber an unsent draft.
+ if (!hasUnsentDraft()) location.reload();
+ return;
+ }
if (msg.type === "reaction") {
// Chat app: reactions fold into the app's state (rev-bumped, so exactly
// that bubble re-renders). The frame is server-computed — hostEvents keep
diff --git a/web/static/style.css b/web/static/style.css
index 5e3656d..28dc2b7 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -1371,6 +1371,9 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.invite .meta .label { font-weight: 600; }
.invite .meta .status { font-size: 12px; color: var(--muted); }
.invite .device-note { flex: none; max-width: 220px; font-size: 12px; color: var(--muted); text-align: right; }
+/* custom-app rows: the name is an identifier, so it's mono — but row-title sized,
+ not .mono's 12px */
+.invite .label.mono { font-size: 14px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
.key-box {
background: var(--bubble-them);