From 3a25c2f112e2ec6f37ec78ef1688b280e4bdaf6a Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 25 May 2026 12:08:35 +0800 Subject: [PATCH] feat(ui): UIPage.Route (was Slug) + drop UIPage.Icon + WebDir static handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-compact in-flight: brings UIPage's URL field in line with the catch-all page on the platform side (route-segment shape, "/" for index) instead of the bare slug shape, and drops the per-page Icon in favor of Config.Icon (one icon per module, set on the nav rail). - Rename UIPage.Slug → UIPage.Route. Route is a full path-from-root ("/", "/users", "/settings/audit") matching the segments the platform's [[...page]] catch-all sees. Validation moves to per- segment checks against the same regex. - Drop UIPage.Icon. Per-page icons aren't surfaced anywhere in the platform today; Config.Icon is the source of truth for the nav rail. - New system.WebHandler + Config.WebDir: SDK serves the module's React bundle directory at /__mirrorstack/web/* (CORS open) so the platform's ModuleMount can dynamic-import a stable URL. Path- traversal guarded by EvalSymlinks on both root and target. Tests updated for the rename + new web handler. Whole-repo tests clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/core/module.go | 17 +++++ internal/core/ui.go | 4 +- internal/core/ui_test.go | 93 ++++++++++++----------- internal/registry/ui.go | 78 +++++++++++++------- system/manifest_test.go | 4 +- system/web.go | 123 +++++++++++++++++++++++++++++++ system/web_test.go | 155 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 400 insertions(+), 74 deletions(-) create mode 100644 system/web.go create mode 100644 system/web_test.go diff --git a/internal/core/module.go b/internal/core/module.go index 67bfa53..82ff0ff 100644 --- a/internal/core/module.go +++ b/internal/core/module.go @@ -66,6 +66,13 @@ type Config struct { // map at lifecycle time — /lifecycle/{upgrade,downgrade} take migration // numbers only. Versions map[string]system.MigrationVersions + + // WebDir is the on-disk path to the module's React bundle output + // (typically "web/dist"). When set, the SDK serves it publicly under + // /__mirrorstack/web/* with permissive CORS so the platform's catch-all + // route can dynamically import the named exports declared in + // RegisterUI.DefaultPages. Optional — when empty, the /web route 404s. + WebDir string } // Module is the core SDK instance. @@ -426,6 +433,16 @@ func (m *Module) Close() { func (m *Module) mountSystemRoutes() { m.router.Route("/__mirrorstack", func(r chi.Router) { r.Get("/health", system.Health) // intentionally public — no auth + + // Public-scope static handler for the module's React bundle (the + // directory the author named in Config.WebDir). Browser-fetched + // from the platform's catch-all module page; CORS-permissive + // because bundles carry no credentials. When WebDir is empty, + // every request 404s. + r.Get("/web/*", system.WebHandler(m.config.WebDir)) + r.Head("/web/*", system.WebHandler(m.config.WebDir)) + r.Options("/web/*", system.WebHandler(m.config.WebDir)) + r.Route("/platform", func(r chi.Router) { r.Use(httputil.MaxBytes(64 * 1024)) // 64 KB — lifecycle bodies are tiny r.Use(m.internalAuth) diff --git a/internal/core/ui.go b/internal/core/ui.go index 1272f39..cfe1f7e 100644 --- a/internal/core/ui.go +++ b/internal/core/ui.go @@ -15,7 +15,7 @@ type ( // RegisterUI declares the module's UI surface — agent-visible Components // plus DefaultPages mounted under /apps//. See the // ModuleUI doc comment for the shape. Validates the input and panics on -// programmer errors (duplicate names, invalid slug, unknown prop type). +// programmer errors (duplicate names, invalid route, unknown prop type). // Last-write-wins; a second call replaces the prior manifest. // // ms.RegisterUI(ms.ModuleUI{ @@ -25,7 +25,7 @@ type ( // }}, // }, // DefaultPages: []ms.UIPage{ -// {Slug: "", Title: "OAuth Settings", Export: "SettingsPage"}, +// {Route: "/", Title: "OAuth Settings", Export: "SettingsPage"}, // }, // }) func (m *Module) RegisterUI(ui ModuleUI) { diff --git a/internal/core/ui_test.go b/internal/core/ui_test.go index 0cd988f..68830e7 100644 --- a/internal/core/ui_test.go +++ b/internal/core/ui_test.go @@ -18,7 +18,7 @@ func TestRegisterUI_PopulatesRegistry(t *testing.T) { }}, }, DefaultPages: []UIPage{ - {Slug: "", Title: "OAuth Settings", Export: "SettingsPage"}, + {Route: "/", Title: "OAuth Settings", Export: "SettingsPage"}, }, }) @@ -48,10 +48,10 @@ func TestRegisterUI_LastWriteWins(t *testing.T) { m, _ := New(Config{ID: "demo"}) m.RegisterUI(ModuleUI{ - DefaultPages: []UIPage{{Slug: "", Title: "First", Export: "First"}}, + DefaultPages: []UIPage{{Route: "/", Title: "First", Export: "First"}}, }) m.RegisterUI(ModuleUI{ - DefaultPages: []UIPage{{Slug: "", Title: "Second", Export: "Second"}}, + DefaultPages: []UIPage{{Route: "/", Title: "Second", Export: "Second"}}, }) got := m.registry.UI() @@ -64,7 +64,7 @@ func TestRegisterUI_StoresDeepCopy(t *testing.T) { t.Parallel() m, _ := New(Config{ID: "demo"}) - pages := []UIPage{{Slug: "", Title: "Original", Export: "P"}} + pages := []UIPage{{Route: "/", Title: "Original", Export: "P"}} m.RegisterUI(ModuleUI{DefaultPages: pages}) pages[0].Title = "Mutated" @@ -80,7 +80,7 @@ func TestRegisterUI_UIReturnsDeepCopy(t *testing.T) { m, _ := New(Config{ID: "demo"}) m.RegisterUI(ModuleUI{ - DefaultPages: []UIPage{{Slug: "", Title: "Original", Export: "P"}}, + DefaultPages: []UIPage{{Route: "/", Title: "Original", Export: "P"}}, }) first := m.registry.UI() @@ -92,75 +92,84 @@ func TestRegisterUI_UIReturnsDeepCopy(t *testing.T) { } } -// ---- RegisterUI: page slug validation ---- +// ---- RegisterUI: page route validation ---- -func TestRegisterUI_EmptySlugIsIndex(t *testing.T) { +func TestRegisterUI_RootRouteIsIndex(t *testing.T) { t.Parallel() - // Empty slug is the index page — not subject to the regex. + // "/" is the index route — not subject to the segment regex. m, _ := New(Config{ID: "demo"}) m.RegisterUI(ModuleUI{ - DefaultPages: []UIPage{{Slug: "", Title: "Index", Export: "P"}}, + DefaultPages: []UIPage{{Route: "/", Title: "Index", Export: "P"}}, }) } -func TestRegisterUI_ValidSlugVariations(t *testing.T) { +func TestRegisterUI_ValidRouteVariations(t *testing.T) { t.Parallel() - valid := []string{"a", "1", "settings", "audit-log", "v2-api", "abc123", "1-2-3", - "thirty-two-chars-exactly-1234567"} - for _, s := range valid { - t.Run("ok/"+s, func(t *testing.T) { + valid := []string{ + "/", + "/a", "/1", "/settings", "/audit-log", "/v2-api", "/abc123", "/1-2-3", + "/thirty-two-chars-exactly-1234567", + "/settings/api-keys", + "/users/active/recent", + } + for _, r := range valid { + t.Run("ok"+r, func(t *testing.T) { m, _ := New(Config{ID: "demo"}) m.RegisterUI(ModuleUI{ - DefaultPages: []UIPage{{Slug: s, Title: "T", Export: "P"}}, + DefaultPages: []UIPage{{Route: r, Title: "T", Export: "P"}}, }) }) } } -func TestRegisterUI_InvalidSlugPanics(t *testing.T) { +func TestRegisterUI_InvalidRoutePanics(t *testing.T) { t.Parallel() cases := map[string]string{ - "uppercase": "Settings", - "underscore": "settings_page", - "trailing-hyphen": "settings-", - "leading-hyphen": "-settings", - "space": "settings page", - "too-long": "thirty-three-chars-just-over-limit", - "reserved-underscore": "_internal", - "reserved-ms": "__ms", - "reserved-ms-sub": "__ms-something", + "empty": "", + "no-leading-slash": "settings", + "trailing-slash": "/settings/", + "uppercase": "/Settings", + "underscore": "/settings_page", + "trailing-hyphen": "/settings-", + "leading-hyphen": "/-settings", + "space": "/settings page", + "too-long-segment": "/thirty-three-chars-just-over-limit", + "reserved-underscore": "/_internal", + "reserved-ms": "/__ms", + "reserved-ms-sub": "/__ms-something", + "double-slash": "/settings//api-keys", } - for name, slug := range cases { + for name, route := range cases { t.Run(name, func(t *testing.T) { defer func() { if recover() == nil { - t.Errorf("expected panic for slug %q", slug) + t.Errorf("expected panic for route %q", route) } }() m, _ := New(Config{ID: "demo"}) m.RegisterUI(ModuleUI{ - DefaultPages: []UIPage{{Slug: slug, Title: "T", Export: "P"}}, + DefaultPages: []UIPage{{Route: route, Title: "T", Export: "P"}}, }) }) } } -func TestRegisterUI_DuplicatePageSlugPanics(t *testing.T) { +func TestRegisterUI_DuplicatePageRoutePanics(t *testing.T) { t.Parallel() defer func() { if recover() == nil { - t.Error("expected panic on duplicate page slug") + t.Error("expected panic on duplicate page route") } }() m, _ := New(Config{ID: "demo"}) m.RegisterUI(ModuleUI{ DefaultPages: []UIPage{ - {Slug: "audit", Title: "A", Export: "A"}, - {Slug: "audit", Title: "B", Export: "B"}, + {Route: "/audit", Title: "A", Export: "A"}, + {Route: "/audit", Title: "B", Export: "B"}, }, }) } @@ -208,8 +217,8 @@ func TestRegisterUI_EmptyPageFieldsPanic(t *testing.T) { t.Parallel() cases := map[string]UIPage{ - "empty-title": {Slug: "", Title: "", Export: "P"}, - "empty-export": {Slug: "", Title: "T", Export: ""}, + "empty-title": {Route: "/", Title: "", Export: "P"}, + "empty-export": {Route: "/", Title: "T", Export: ""}, } for name, p := range cases { t.Run(name, func(t *testing.T) { @@ -303,19 +312,19 @@ func TestRegisterUI_MultiPage(t *testing.T) { m, _ := New(Config{ID: "demo"}) m.RegisterUI(ModuleUI{ DefaultPages: []UIPage{ - {Slug: "", Icon: "settings", Title: "Settings", Export: "SettingsPage"}, - {Slug: "connections", Icon: "link", Title: "Connections", Export: "ConnectionsPage"}, - {Slug: "audit", Icon: "history", Title: "Audit log", Export: "AuditPage"}, + {Route: "/", Title: "Settings", Export: "SettingsPage"}, + {Route: "/connections", Title: "Connections", Export: "ConnectionsPage"}, + {Route: "/audit", Title: "Audit log", Export: "AuditPage"}, }, }) got := m.registry.UI().DefaultPages - wantSlugs := []string{"", "connections", "audit"} - gotSlugs := make([]string, len(got)) + wantRoutes := []string{"/", "/connections", "/audit"} + gotRoutes := make([]string, len(got)) for i, p := range got { - gotSlugs[i] = p.Slug + gotRoutes[i] = p.Route } - if !slices.Equal(gotSlugs, wantSlugs) { - t.Errorf("slugs = %+v, want %+v", gotSlugs, wantSlugs) + if !slices.Equal(gotRoutes, wantRoutes) { + t.Errorf("routes = %+v, want %+v", gotRoutes, wantRoutes) } } diff --git a/internal/registry/ui.go b/internal/registry/ui.go index accd356..9d81712 100644 --- a/internal/registry/ui.go +++ b/internal/registry/ui.go @@ -21,7 +21,7 @@ var validPropTypes = []string{"text", "secret", "textarea", "bool", "number", "t // names a React export the module's bundle ships, plus a prop schema // so the agent envelope layer (dynamic-ui v1) can compose them. // - DefaultPages: the module's own React pages, mounted by the platform -// under /apps///. Each page is a +// under /apps//. Each page is a // full React export — the module has total layout freedom. // // A module without a UI surface omits the ms.RegisterUI call entirely; @@ -55,30 +55,35 @@ type UIProp struct { } // UIPage is one entry in DefaultPages — the module's own React page mounted -// at /apps///. Empty Slug is the index page. -// Export names the bundle's React export to mount. +// at /apps//. "/" is the index page; "/users" +// is a sub-page. Export names the bundle's React export to mount; the +// platform fetches the module's web bundle and renders the matching +// named export for the requested route. +// +// The page's nav-rail icon is the module's icon (Config.Icon). Pages +// don't carry their own icon today — when distinct icons per page are +// needed, an Icon field will be added back as optional. type UIPage struct { - Slug string `json:"slug"` - Icon string `json:"icon,omitempty"` + Route string `json:"route"` Title string `json:"title"` Description string `json:"description,omitempty"` Export string `json:"export"` } -// pageSlugRe is the slug rule from the v0 plan: lowercase letters, digits, -// and hyphens, 1–32 chars, must start and end with a letter or digit (no -// leading/trailing hyphens). Empty slug (the index page) is checked -// separately and skips this pattern. -var pageSlugRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$`) +// pageSegmentRe is the route-segment rule: lowercase letters, digits, +// and hyphens, 1–32 chars, must start and end with a letter or digit +// (no leading/trailing hyphens). Each "/"-separated piece of a non-index +// route must match. +var pageSegmentRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$`) -// reservedSlugPrefixes are platform-reserved namespaces under the module -// route. The platform may mount its own surfaces under these prefixes in -// the future (e.g. /__ms/* for platform-rendered fallbacks); reserving +// reservedRouteFirstSegments are platform-reserved namespaces under the +// module route. The platform may mount its own surfaces under these prefixes +// in the future (e.g. /__ms/* for platform-rendered fallbacks); reserving // them at the SDK avoids a future collision. -var reservedSlugPrefixes = []string{"_", "__ms"} +var reservedRouteFirstSegments = []string{"_", "__ms"} // SetUI stores the module's declared UI manifest. Validates the input and -// panics on programmer errors (duplicate names, invalid slug, unknown prop +// panics on programmer errors (duplicate names, invalid route, unknown prop // type). Last-write-wins; a second call replaces the first. The stored // value is a deep copy so callers can mutate their input afterwards // without aliasing into the registry. @@ -117,7 +122,7 @@ func validateUI(ui ModuleUI) { validateProps(c.Name, c.Props) } - seenSlug := make(map[string]struct{}, len(ui.DefaultPages)) + seenRoute := make(map[string]struct{}, len(ui.DefaultPages)) for i, p := range ui.DefaultPages { if p.Title == "" { panic(fmt.Sprintf("mirrorstack: RegisterUI: DefaultPages[%d] Title is empty", i)) @@ -125,11 +130,11 @@ func validateUI(ui ModuleUI) { if p.Export == "" { panic(fmt.Sprintf("mirrorstack: RegisterUI: DefaultPages[%d] (%q) Export is empty", i, p.Title)) } - validatePageSlug(p.Slug, i) - if _, dup := seenSlug[p.Slug]; dup { - panic(fmt.Sprintf("mirrorstack: RegisterUI: duplicate DefaultPages slug %q", p.Slug)) + validatePageRoute(p.Route, i) + if _, dup := seenRoute[p.Route]; dup { + panic(fmt.Sprintf("mirrorstack: RegisterUI: duplicate DefaultPages route %q", p.Route)) } - seenSlug[p.Slug] = struct{}{} + seenRoute[p.Route] = struct{}{} } } @@ -149,17 +154,34 @@ func validateProps(componentName string, props []UIProp) { } } -func validatePageSlug(slug string, index int) { - if slug == "" { +// validatePageRoute enforces URL-shaped routes. "/" is the index page. +// Non-index routes must look like "/seg(/seg)*" where each segment matches +// pageSegmentRe and the first segment isn't reserved. +func validatePageRoute(route string, index int) { + if route == "" { + panic(fmt.Sprintf("mirrorstack: RegisterUI: DefaultPages[%d] Route is empty (use \"/\" for the index page)", index)) + } + if route == "/" { return } - for _, prefix := range reservedSlugPrefixes { - if slug == prefix || strings.HasPrefix(slug, prefix+"/") || strings.HasPrefix(slug, prefix+"-") { - panic(fmt.Sprintf("mirrorstack: RegisterUI: DefaultPages[%d] slug %q uses reserved prefix %q", index, slug, prefix)) - } + if !strings.HasPrefix(route, "/") { + panic(fmt.Sprintf("mirrorstack: RegisterUI: DefaultPages[%d] Route %q must start with \"/\"", index, route)) } - if !pageSlugRe.MatchString(slug) { - panic(fmt.Sprintf("mirrorstack: RegisterUI: DefaultPages[%d] slug %q is invalid (lowercase letters, digits, hyphens; 1-32 chars; must start with a letter or digit)", index, slug)) + if strings.HasSuffix(route, "/") { + panic(fmt.Sprintf("mirrorstack: RegisterUI: DefaultPages[%d] Route %q must not end with \"/\"", index, route)) + } + segments := strings.Split(strings.TrimPrefix(route, "/"), "/") + for j, seg := range segments { + if !pageSegmentRe.MatchString(seg) { + panic(fmt.Sprintf("mirrorstack: RegisterUI: DefaultPages[%d] Route %q segment %q is invalid (lowercase letters, digits, hyphens; 1-32 chars; must start and end with a letter or digit)", index, route, seg)) + } + if j == 0 { + for _, reserved := range reservedRouteFirstSegments { + if seg == reserved || strings.HasPrefix(seg, reserved+"-") { + panic(fmt.Sprintf("mirrorstack: RegisterUI: DefaultPages[%d] Route %q first segment uses reserved prefix %q", index, route, reserved)) + } + } + } } } diff --git a/system/manifest_test.go b/system/manifest_test.go index 793dbe9..8912de2 100644 --- a/system/manifest_test.go +++ b/system/manifest_test.go @@ -327,8 +327,8 @@ func TestManifest_UIPopulatedWhenRegistered(t *testing.T) { }}, }, DefaultPages: []registry.UIPage{ - {Slug: "", Title: "Settings", Export: "SettingsPage"}, - {Slug: "audit", Title: "Audit", Export: "AuditPage"}, + {Route: "/", Title: "Settings", Export: "SettingsPage"}, + {Route: "/audit", Title: "Audit", Export: "AuditPage"}, }, }) diff --git a/system/web.go b/system/web.go new file mode 100644 index 0000000..39943d0 --- /dev/null +++ b/system/web.go @@ -0,0 +1,123 @@ +package system + +import ( + "net/http" + "os" + "path/filepath" + "strings" +) + +// WebHandler serves the module's React bundle output (the directory the +// module author named in Config.WebDir, typically "web/dist") under +// /__mirrorstack/web/*. The platform's catch-all module page fetches the +// bundle from here and dynamically imports the named exports declared in +// ms.RegisterUI(DefaultPages). +// +// CORS is intentionally permissive: bundles are public static assets, +// browser-fetched from a different origin (web-applications on :3001 vs. +// the module on :8080 in local dev), and carry no credentials. The +// /__mirrorstack/web/* namespace is read-only — no cookie/auth flows. +// +// If rootDir is empty, every request 404s — the module declared no web +// surface. +// +// Path traversal is blocked by validating the cleaned filesystem path is +// rooted under rootDir; symlinks that escape the dir are rejected. +func WebHandler(rootDir string) http.HandlerFunc { + if rootDir == "" { + return func(w http.ResponseWriter, _ *http.Request) { + http.NotFound(w, nil) + } + } + abs, err := filepath.Abs(rootDir) + if err != nil { + // Bad config: still install a handler that 404s rather than + // panicking at module startup. The module author sees the + // problem the first time the platform tries to fetch. + return func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "web dir invalid", http.StatusInternalServerError) + } + } + // Resolve symlinks at construction so per-request EvalSymlinks + // results can be prefix-compared. Without this, macOS's tempdir + // (/var → /private/var symlink) breaks the prefix check for every + // served file. If the dir doesn't exist yet, fall back to abs — the + // handler will 404 anyway when files aren't found. + root := abs + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + root = resolved + } + return func(w http.ResponseWriter, r *http.Request) { + setCORS(w) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD, OPTIONS") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // chi captured the rest-of-path under "*"; we recover it from + // URL.Path so this handler can also be unit-tested without chi. + rel := strings.TrimPrefix(r.URL.Path, "/__mirrorstack/web/") + if rel == "" || rel == "." { + http.NotFound(w, r) + return + } + full := filepath.Join(root, rel) + // Defense-in-depth: refuse anything that escapes rootDir even + // after filepath.Clean (e.g. via symlink). EvalSymlinks resolves + // links so we compare the real on-disk path. + real, err := filepath.EvalSymlinks(full) + if err != nil { + if os.IsNotExist(err) { + http.NotFound(w, r) + return + } + http.Error(w, "io error", http.StatusInternalServerError) + return + } + if !strings.HasPrefix(real, root+string(filepath.Separator)) && real != root { + http.NotFound(w, r) // pretend it doesn't exist rather than confirm escape attempt + return + } + fi, err := os.Stat(real) + if err != nil || fi.IsDir() { + http.NotFound(w, r) + return + } + + // Material content-type for the two file kinds we ship today — + // the JS bundle and its sourcemap. http.ServeFile would also work + // but adds Last-Modified / ETag handling we don't need yet. + w.Header().Set("Content-Type", contentTypeFor(real)) + http.ServeFile(w, r, real) + } +} + +func setCORS(w http.ResponseWriter) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "*") + // Bundles are immutable per build; the platform replaces them via a + // fresh fetch when the module restarts. Short max-age keeps the dev + // loop responsive while still letting browsers reuse within a render. + w.Header().Set("Cache-Control", "no-cache") +} + +func contentTypeFor(path string) string { + switch filepath.Ext(path) { + case ".js", ".mjs": + return "application/javascript; charset=utf-8" + case ".map": + return "application/json" + case ".css": + return "text/css; charset=utf-8" + case ".html": + return "text/html; charset=utf-8" + default: + return "application/octet-stream" + } +} diff --git a/system/web_test.go b/system/web_test.go new file mode 100644 index 0000000..c362bb5 --- /dev/null +++ b/system/web_test.go @@ -0,0 +1,155 @@ +package system + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func writeFile(t *testing.T, dir, name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } +} + +func TestWebHandler_ServesFileWithCORSAndContentType(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "index.js", "export const x = 1;") + h := WebHandler(dir) + + req := httptest.NewRequest(http.MethodGet, "/__mirrorstack/web/index.js", nil) + rec := httptest.NewRecorder() + h(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body) + } + if got := rec.Body.String(); got != "export const x = 1;" { + t.Errorf("body = %q, want module body", got) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("CORS-Allow-Origin = %q, want '*'", got) + } + if got := rec.Header().Get("Content-Type"); got != "application/javascript; charset=utf-8" { + t.Errorf("Content-Type = %q, want application/javascript", got) + } +} + +func TestWebHandler_EmptyWebDir_Always404(t *testing.T) { + h := WebHandler("") + req := httptest.NewRequest(http.MethodGet, "/__mirrorstack/web/index.js", nil) + rec := httptest.NewRecorder() + h(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404 when WebDir unset", rec.Code) + } +} + +func TestWebHandler_PathTraversal_Blocked(t *testing.T) { + // Secret file lives in the parent of the served dir — a successful + // traversal would expose it through the SDK's public scope. + parent := t.TempDir() + writeFile(t, parent, "secret.txt", "should-not-be-readable") + dir := filepath.Join(parent, "dist") + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + writeFile(t, dir, "index.js", "ok") + + h := WebHandler(dir) + + cases := []string{ + "/__mirrorstack/web/../secret.txt", + "/__mirrorstack/web/..%2Fsecret.txt", + } + for _, p := range cases { + t.Run(p, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, p, nil) + rec := httptest.NewRecorder() + h(rec, req) + if rec.Code == http.StatusOK { + t.Fatalf("traversal succeeded for %q: body=%s", p, rec.Body) + } + if got := rec.Body.String(); got == "should-not-be-readable" { + t.Errorf("secret leaked for %q", p) + } + }) + } +} + +func TestWebHandler_MissingFile_Returns404(t *testing.T) { + dir := t.TempDir() + h := WebHandler(dir) + req := httptest.NewRequest(http.MethodGet, "/__mirrorstack/web/missing.js", nil) + rec := httptest.NewRecorder() + h(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", rec.Code) + } +} + +func TestWebHandler_Directory_Returns404(t *testing.T) { + // Listing the dir would expose file structure; the handler should + // only serve actual files. + parent := t.TempDir() + if err := os.Mkdir(filepath.Join(parent, "nested"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + h := WebHandler(parent) + req := httptest.NewRequest(http.MethodGet, "/__mirrorstack/web/nested", nil) + rec := httptest.NewRecorder() + h(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404 for directory request", rec.Code) + } +} + +func TestWebHandler_Options_PreflightSucceeds(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "index.js", "ok") + h := WebHandler(dir) + + req := httptest.NewRequest(http.MethodOptions, "/__mirrorstack/web/index.js", nil) + rec := httptest.NewRecorder() + h(rec, req) + if rec.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204 for OPTIONS preflight", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Methods"); got == "" { + t.Errorf("missing Allow-Methods on preflight") + } +} + +func TestWebHandler_BadMethod_405(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "index.js", "ok") + h := WebHandler(dir) + req := httptest.NewRequest(http.MethodPost, "/__mirrorstack/web/index.js", nil) + rec := httptest.NewRecorder() + h(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405 for POST", rec.Code) + } + if got := rec.Header().Get("Allow"); got == "" { + t.Errorf("missing Allow header on 405") + } +} + +func TestContentTypeFor(t *testing.T) { + cases := map[string]string{ + "x.js": "application/javascript; charset=utf-8", + "x.mjs": "application/javascript; charset=utf-8", + "x.map": "application/json", + "x.css": "text/css; charset=utf-8", + "x.html": "text/html; charset=utf-8", + "x.other": "application/octet-stream", + } + for path, want := range cases { + if got := contentTypeFor(path); got != want { + t.Errorf("contentTypeFor(%q) = %q, want %q", path, got, want) + } + } +}