server+web: sideload custom mini-apps from LCHAT_APPS_DIR (#242)
Set LCHAT_APPS_DIR to a directory of *.js mini-app modules and the server serves each at /static/apps-custom/<name>.js — exactly one segment below /static/, so a custom app's relative `import "../apps.js"` resolves to the compiled-in framework and a sideloaded app is byte-for-byte the same shape as a built-in one. GET /api/apps lists what's available; the shell fetches it at boot and dynamic-imports each module before the first render, with per-import catches and a short overall timeout so a broken or slow custom app can never block or white-screen boot. Names are locked to ^[a-z0-9-]+\.js$ (anything else 404s), which doubles as the path-traversal guard. The manifest and modules are ungated like the static shell itself — they reveal only app names, comparable to plaintext branding.
Co-authored-by: Claude <noreply@anthropic.com>
5 files changed,
+224
−1
changelog/2026-07-06-custom-apps-sideload.md+4 −0customapps.go+86 −0handlers_test.go+94 −0main.go+10 −1web/static/shell.js+30 −0
diff --git a/changelog/2026-07-06-custom-apps-sideload.md b/changelog/2026-07-06-custom-apps-sideload.md| new file mode 100644 |
| index 0000000..65d4741 |
| --- /dev/null |
| +++ b/changelog/2026-07-06-custom-apps-sideload.md |
| @@ -0,0 +1,4 @@ |
| +🤖 Your space can now carry its own custom mini-apps. When the person running |
| +your server adds one, it simply appears for everyone the next time the app |
| +loads — no update to install. Custom apps work exactly like the built-in ones |
| +(To-do, Reminders, Polls…): everything they do stays end-to-end encrypted. |
diff --git a/customapps.go b/customapps.go| new file mode 100644 |
| index 0000000..1707175 |
| --- /dev/null |
| +++ b/customapps.go |
| @@ -0,0 +1,86 @@ |
| +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). |
| +// |
| +// 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 — |
| +// 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). |
| + |
| +import ( |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "regexp" |
| + "sort" |
| + "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$`) |
| + |
| +// 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". |
| +func (s *Server) customAppNames() []string { |
| + if s.appsDir == "" { |
| + return nil |
| + } |
| + entries, err := os.ReadDir(s.appsDir) |
| + if err != nil { |
| + return nil |
| + } |
| + names := []string{} |
| + for _, e := range entries { |
| + if !e.Type().IsRegular() || !customAppFile.MatchString(e.Name()) { |
| + continue |
| + } |
| + names = append(names, strings.TrimSuffix(e.Name(), ".js")) |
| + } |
| + 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 |
| +// able to treat "no custom apps" and "feature unused" identically. |
| +func (s *Server) getCustomApps(w http.ResponseWriter, r *http.Request) { |
| + apps := []map[string]string{} |
| + for _, name := range s.customAppNames() { |
| + apps = append(apps, map[string]string{ |
| + "name": name, |
| + "url": "/static/apps-custom/" + name + ".js", |
| + }) |
| + } |
| + 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. |
| +func (s *Server) serveCustomApp(w http.ResponseWriter, r *http.Request) { |
| + name := r.PathValue("name") |
| + if s.appsDir == "" || !customAppFile.MatchString(name) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + b, err := os.ReadFile(filepath.Join(s.appsDir, name)) |
| + if err != nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") |
| + w.Write(b) |
| +} |
diff --git a/handlers_test.go b/handlers_test.go| index 881e1e0..ab7b995 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -1179,3 +1179,97 @@ func TestChatPageHomeOrigin(t *testing.T) { |
| t.Fatal("chat page missing the default home origin") |
| } |
| } |
| + |
| +// ---- custom apps (customapps.go) ----------------------------------------- |
| + |
| +func TestCustomAppsManifestEmptyWhenUnset(t *testing.T) { |
| + ts, _ := newTestServer(t) |
| + |
| + resp, err := http.Get(ts.URL + "/api/apps") |
| + if err != nil { |
| + t.Fatalf("get manifest: %v", err) |
| + } |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("manifest: want 200, got %d", resp.StatusCode) |
| + } |
| + var body struct { |
| + Apps []map[string]string `json:"apps"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { |
| + t.Fatalf("decode: %v", err) |
| + } |
| + if body.Apps == nil || len(body.Apps) != 0 { |
| + t.Fatalf("want empty apps list, got %v", body.Apps) |
| + } |
| + // The module route 404s too — the feature is fully off without the env var. |
| + resp, _ = http.Get(ts.URL + "/static/apps-custom/kanban.js") |
| + if resp.StatusCode != http.StatusNotFound { |
| + t.Fatalf("module with no dir: want 404, got %d", resp.StatusCode) |
| + } |
| +} |
| + |
| +func TestCustomAppsSideload(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + src := `import { defineApp } from "../apps.js";` + "\n" + `defineApp({ name: "kanban" });` + "\n" |
| + dir := t.TempDir() |
| + if err := os.WriteFile(dir+"/kanban.js", []byte(src), 0o644); err != nil { |
| + t.Fatalf("write app: %v", err) |
| + } |
| + // Non-matching names never appear in the manifest, valid or not on disk. |
| + os.WriteFile(dir+"/README.md", []byte("not an app"), 0o644) |
| + os.WriteFile(dir+"/Bad.js", []byte("uppercase"), 0o644) |
| + s.appsDir = dir |
| + |
| + resp, _ := http.Get(ts.URL + "/api/apps") |
| + var body struct { |
| + Apps []map[string]string `json:"apps"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { |
| + t.Fatalf("decode: %v", err) |
| + } |
| + if len(body.Apps) != 1 || body.Apps[0]["name"] != "kanban" || body.Apps[0]["url"] != "/static/apps-custom/kanban.js" { |
| + t.Fatalf("manifest: got %v", body.Apps) |
| + } |
| + |
| + resp, _ = http.Get(ts.URL + "/static/apps-custom/kanban.js") |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("module: want 200, got %d", resp.StatusCode) |
| + } |
| + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") { |
| + t.Fatalf("module content type: got %q", ct) |
| + } |
| + if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" { |
| + t.Fatalf("module cache-control: got %q", cc) |
| + } |
| + got, _ := io.ReadAll(resp.Body) |
| + if string(got) != src { |
| + t.Fatalf("module body: got %q", got) |
| + } |
| +} |
| + |
| +func TestCustomAppsRejectsInvalidNames(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + dir := t.TempDir() |
| + os.WriteFile(dir+"/kanban.js", []byte("ok"), 0o644) |
| + // A secret next door that a traversal would love to read. |
| + os.WriteFile(dir+"/../secret.js", []byte("nope"), 0o644) |
| + s.appsDir = dir |
| + |
| + for _, path := range []string{ |
| + "/static/apps-custom/Kanban.js", // uppercase |
| + "/static/apps-custom/kanban.txt", // not .js |
| + "/static/apps-custom/kanban", // no extension |
| + "/static/apps-custom/no-such.js", // valid name, missing file |
| + "/static/apps-custom/..%2Fsecret.js", // encoded traversal |
| + "/static/apps-custom/%2e%2e%2fsecret.js", // fully encoded traversal |
| + "/static/apps-custom/a.b.js", // extra dot |
| + } { |
| + resp, err := http.Get(ts.URL + path) |
| + if err != nil { |
| + t.Fatalf("get %s: %v", path, err) |
| + } |
| + if resp.StatusCode != http.StatusNotFound { |
| + t.Fatalf("%s: want 404, got %d", path, resp.StatusCode) |
| + } |
| + } |
| +} |
diff --git a/main.go b/main.go| index 013fb9e..cb7f212 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -75,6 +75,9 @@ type Server struct { |
| apns apnsSender // nil unless APNs (direct p8 or relay) is configured |
| maxUpload int64 // per-upload attachment cap (LCHAT_MAX_UPLOAD_BYTES) |
| blobQuota int64 // per-instance total blob-bytes ceiling (LCHAT_BLOB_QUOTA_BYTES) |
| + // Directory of sideloaded mini-app modules served at /static/apps-custom/ |
| + // (LCHAT_APPS_DIR); empty disables the feature. See customapps.go. |
| + appsDir string |
| // inflight de-duplicates concurrent unfurls of the same URL. |
| inflight sync.Map |
| // active tracks the last time each device token reported being open + focused |
| @@ -189,7 +192,8 @@ func main() { |
| s := &Server{db: db, hub: hub, pages: pages, changelog: loadChangelog(), version: frontendVersion(), |
| appStoreURL: strings.TrimSpace(os.Getenv("LCHAT_APP_STORE_URL")), homeOrigin: homeOrigin, |
| maxUpload: envBytes("LCHAT_MAX_UPLOAD_BYTES", defaultMaxUploadBytes), |
| - blobQuota: envBytes("LCHAT_BLOB_QUOTA_BYTES", defaultBlobQuotaBytes)} |
| + blobQuota: envBytes("LCHAT_BLOB_QUOTA_BYTES", defaultBlobQuotaBytes), |
| + appsDir: strings.TrimSpace(os.Getenv("LCHAT_APPS_DIR"))} |
| // Web Push: self-generated VAPID keypair (no third-party account). |
| if pub, priv, err := db.ensureVAPID(); err != nil { |
| @@ -277,6 +281,11 @@ func (s *Server) handler() http.Handler { |
| mux.HandleFunc("GET /admin", s.servePage("admin.html")) |
| mux.HandleFunc("GET /join/{token}", s.servePage("join.html")) |
| mux.Handle("GET /static/", noCache(http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))) |
| + // Custom (sideloaded) mini-apps. Deliberately one segment below /static/ so a |
| + // custom app's relative `import "../apps.js"` resolves to /static/apps.js — |
| + // 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) |
| // Chat API (transport auth via invite token; never sees plaintext) |
| mux.HandleFunc("GET /api/messages", s.getMessages) |
diff --git a/web/static/shell.js b/web/static/shell.js| index 2c752ca..6e59f40 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -487,8 +487,35 @@ function route() { |
| boot(); |
| } |
| +// Custom apps: modules the server sideloads (LCHAT_APPS_DIR) register through |
| +// the same defineApp() the compiled-in apps use — the /api/apps manifest lists |
| +// them and each is dynamic-imported here, BEFORE the first render, so |
| +// registration (typeOwner, personalApps) is complete when folding starts. A |
| +// broken or slow custom app must never block or white-screen boot: every |
| +// import failure is contained to a console.error, and the whole phase races a |
| +// short timeout so boot proceeds regardless. |
| +const CUSTOM_APPS_TIMEOUT_MS = 4000; |
| +async function loadCustomApps() { |
| + try { |
| + const load = (async () => { |
| + const r = await fetch("/api/apps"); |
| + if (!r.ok) return; |
| + const { apps } = await r.json(); |
| + await Promise.all( |
| + (apps || []).map((a) => |
| + import(a.url).catch((e) => console.error(`custom app "${a.name}" failed to load:`, e)), |
| + ), |
| + ); |
| + })(); |
| + await Promise.race([load, new Promise((res) => setTimeout(res, CUSTOM_APPS_TIMEOUT_MS))]); |
| + } catch (e) { |
| + console.error("custom apps skipped:", e); |
| + } |
| +} |
| + |
| async function boot() { |
| try { |
| + await loadCustomApps(); |
| await ensurePubkey(); |
| await loadRoster(); |
| await Promise.all([setupInstanceKey(), loadThreads()]); |
| @@ -539,6 +566,9 @@ function hideLoading() { |
| async function bootEmbed() { |
| document.body.classList.add("native-embed"); |
| try { |
| + // The native shell can land straight in a custom app's thread, so the |
| + // embed path needs registration complete before its render too. |
| + await loadCustomApps(); |
| myUserId = embed.myUserId || ""; |
| setCurrentUser(myUserId, session.name); |
| await loadRoster(); // token-only; gives sender labels + profile photos |