Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions internal/core/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions internal/core/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type (
// RegisterUI declares the module's UI surface — agent-visible Components
// plus DefaultPages mounted under /apps/<app-slug>/<module-slug>. 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{
Expand All @@ -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) {
Expand Down
93 changes: 51 additions & 42 deletions internal/core/ui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func TestRegisterUI_PopulatesRegistry(t *testing.T) {
}},
},
DefaultPages: []UIPage{
{Slug: "", Title: "OAuth Settings", Export: "SettingsPage"},
{Route: "/", Title: "OAuth Settings", Export: "SettingsPage"},
},
})

Expand Down Expand Up @@ -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()
Expand All @@ -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"
Expand All @@ -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()
Expand All @@ -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"},
},
})
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}
78 changes: 50 additions & 28 deletions internal/registry/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<app-slug>/<module-slug>/<page-slug>. Each page is a
// under /apps/<app-slug>/<module-slug><Route>. 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;
Expand Down Expand Up @@ -55,30 +55,35 @@ type UIProp struct {
}

// UIPage is one entry in DefaultPages — the module's own React page mounted
// at /apps/<app-slug>/<module-slug>/<Slug>. Empty Slug is the index page.
// Export names the bundle's React export to mount.
// at /apps/<app-slug>/<module-slug><Route>. "/" 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.
Expand Down Expand Up @@ -117,19 +122,19 @@ 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))
}
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{}{}
}
}

Expand All @@ -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))
}
}
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions system/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
},
})

Expand Down
Loading
Loading