eleven / ocho Public

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>
James Adam c118307b04dd4b3e0562664da4c50e3a12b1d5d0 parent 19b40e6
5 files changed, +224 −1
  • changelog/2026-07-06-custom-apps-sideload.md +4 −0
  • customapps.go +86 −0
  • handlers_test.go +94 −0
  • main.go +10 −1
  • web/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