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>
9 files changed,
+655
−33
changelog/2026-07-06-custom-apps-admin.md+4 −0customapps.go+165 −31db.go+88 −0handlers_test.go+227 −0main.go+4 −0web/pages/admin.html+24 −0web/static/admin.js+122 −0web/static/shell.js+18 −2web/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); |