diff --git a/docs/ICD/extensions.md b/docs/ICD/extensions.md index 3be8038..14d6a77 100644 --- a/docs/ICD/extensions.md +++ b/docs/ICD/extensions.md @@ -458,6 +458,58 @@ returns them as a frozen struct. Results are cached per invocation. --- +### Config Sections (v0.38.3) + +Packages can declare a `config_section` in their manifest to inject a +Preact configuration component into the Settings, Admin, or Team Admin +surfaces. This enables headless extensions and libraries to own their +configuration UX without requiring a full surface. + +**Manifest schema:** + +```json +{ + "config_section": { + "label": "My Extension", + "icon": "M12 2L2 7...", + "component": "js/config.js", + "surfaces": ["admin", "settings", "team-admin"], + "category": "system" + } +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `label` | string | Yes | Nav link text. Falls back to package title if empty. | +| `icon` | string | No | SVG path data in compact format (admin CatIcon). | +| `component` | string | No | Relative path within the package archive. Default: `js/config.js`. | +| `surfaces` | string[] | Yes | Target surfaces: `admin`, `settings`, `team-admin`. | +| `category` | string | No | Admin-only: category tab to appear under. Default: `system`. | + +**Component contract:** + +The config section component is a standard ES module exporting a +default Preact component. It is lazy-loaded via dynamic `import()` from +the existing asset-serving endpoint (`GET /surfaces/:id/:component`). + +The component uses `sw.sdk` to read/write its own package settings: +- `sw.api.admin.packages.settings(packageId)` — read +- `sw.api.admin.packages.updateSettings(packageId, data)` — write + +Settings are stored in the `package_settings` JSONB column (admin scope) +and `package_user_settings` table (user scope). + +**Discovery:** At page load, the backend queries enabled packages for +`config_section` entries targeting the current surface and passes them +as `__CONFIG_SECTIONS__` to the frontend. The surface merges them into +its nav and section module map. + +**No new tables or endpoints.** Config sections are purely manifest-driven, +using existing package storage and settings infrastructure. + +--- + ### Builtin Seeding On startup, `SeedBuiltinExtensions()` scans `extensions/builtin/` diff --git a/packages/sdk-test-runner/js/domains/packages.js b/packages/sdk-test-runner/js/domains/packages.js index 57107fd..b9d5d41 100644 --- a/packages/sdk-test-runner/js/domains/packages.js +++ b/packages/sdk-test-runner/js/domains/packages.js @@ -272,6 +272,99 @@ } }); + // ── v0.38.3: Config section manifest ── + + await T.test('packages', 'config-section-install', 'install pkg with config_section → manifest stored', async function () { + var manifest = makeManifest({ + tier: 'browser', + type: 'extension', + config_section: { + label: 'SDK Test Config', + icon: 'M12 2L2 7', + component: 'js/config.js', + surfaces: ['admin', 'settings'], + category: 'system' + } + }); + // Extension needs at least one of tools/pipes/hooks + var file = makePkgFile(manifest, [ + { name: 'js/config.js', content: 'export default function() { return "config"; }' } + ]); + var r = await sw.api.admin.packages.install(file); + T.assert(r && r.id === manifest.id, 'expected install success'); + + // Verify manifest roundtrip + var pkg = await sw.api.admin.packages.get(manifest.id); + T.assert(pkg && pkg.manifest, 'expected manifest in response'); + var cs = pkg.manifest.config_section; + T.assert(cs && cs.label === 'SDK Test Config', 'expected config_section.label preserved'); + T.assert(cs.surfaces && cs.surfaces.length === 2, 'expected 2 surfaces'); + T.assert(cs.component === 'js/config.js', 'expected component preserved'); + + // Cleanup + T.cleanup.push(async function () { + try { await sw.api.admin.packages.del(manifest.id); } catch (_) {} + }); + }); + + await T.test('packages', 'config-section-settings', 'config section settings round-trip', async function () { + var manifest = makeManifest({ + tier: 'browser', + type: 'extension', + config_section: { + label: 'SDK Config RT', + component: 'js/config.js', + surfaces: ['settings'] + } + }); + var file = makePkgFile(manifest, [ + { name: 'js/config.js', content: 'export default function() { return "config"; }' } + ]); + var r = await sw.api.admin.packages.install(file); + T.assert(r && r.id, 'install success'); + + // Write settings + var settings = { api_key_label: 'My Service', timeout: 30 }; + await sw.api.admin.packages.updateSettings(manifest.id, settings); + + // Read back + var stored = await sw.api.admin.packages.settings(manifest.id); + T.assert(stored && stored.api_key_label === 'My Service', 'expected settings round-trip'); + T.assert(stored.timeout === 30, 'expected numeric setting preserved'); + + // Cleanup + T.cleanup.push(async function () { + try { await sw.api.admin.packages.del(manifest.id); } catch (_) {} + }); + }); + + await T.test('packages', 'config-section-list', 'list packages → config_section visible', async function () { + var manifest = makeManifest({ + tier: 'browser', + type: 'extension', + config_section: { + label: 'SDK List Test', + component: 'js/config.js', + surfaces: ['admin'] + } + }); + var file = makePkgFile(manifest, [ + { name: 'js/config.js', content: 'export default function() {}' } + ]); + await sw.api.admin.packages.install(file); + + var list = await sw.api.admin.packages.list(); + var found = (list || []).find(function (p) { return p.id === manifest.id; }); + T.assert(found, 'expected package in list'); + T.assert(found.manifest && found.manifest.config_section, 'expected config_section in list response'); + T.assert(found.manifest.config_section.label === 'SDK List Test', 'expected label match'); + + // Cleanup + T.cleanup.push(async function () { + try { await sw.api.admin.packages.del(manifest.id); } catch (_) {} + }); + }); + await T.test('packages', 'consumer-bad-dep', 'consumer with non-existent dependency → 400', async function () { var manifest = makeManifest({ dependencies: { 'nonexistent-lib-xyz': '>=1.0.0' } }); var file = makePkgFile(manifest, [ diff --git a/server/pages/loaders.go b/server/pages/loaders.go index d389e12..4144f5d 100644 --- a/server/pages/loaders.go +++ b/server/pages/loaders.go @@ -95,6 +95,7 @@ type AdminPageData struct { Users []UserRow `json:"users,omitempty"` Roles []RoleConfig `json:"roles"` Policies any `json:"policies,omitempty"` + ConfigSections []ConfigSectionEntry `json:"config_sections,omitempty"` // v0.38.3 } // ChatPageData is what the chat surface templates receive. @@ -110,14 +111,16 @@ type NotesPageData struct { // SettingsPageData is what the settings surface templates receive. // Feature gates (BYOK, personas) moved to sw.auth.policies in v0.37.19. type SettingsPageData struct { - Section string `json:"section"` + Section string `json:"section"` + ConfigSections []ConfigSectionEntry `json:"config_sections,omitempty"` // v0.38.3 } // ── Loader registration ────────────────────── // TeamAdminPageData is what the team-admin surface receives. type TeamAdminPageData struct { - Section string `json:"section"` + Section string `json:"section"` + ConfigSections []ConfigSectionEntry `json:"config_sections,omitempty"` // v0.38.3 } // ProjectsPageData is what the projects surface receives. @@ -246,6 +249,9 @@ func (e *Engine) adminLoader(c *gin.Context, s store.Stores) (any, error) { data.Users = e.loadUsers(ctx, s) } + // v0.38.3: Discover extension config sections for admin surface + data.ConfigSections = configSectionsForSurface(ctx, s, "admin") + return data, nil } @@ -409,7 +415,10 @@ func (e *Engine) teamAdminLoader(c *gin.Context, s store.Stores) (any, error) { if section == "" { section = "members" } - return &TeamAdminPageData{Section: section}, nil + return &TeamAdminPageData{ + Section: section, + ConfigSections: configSectionsForSurface(context.Background(), s, "team-admin"), + }, nil } func (e *Engine) settingsLoader(c *gin.Context, s store.Stores) (any, error) { @@ -418,10 +427,84 @@ func (e *Engine) settingsLoader(c *gin.Context, s store.Stores) (any, error) { section = "general" } - return &SettingsPageData{Section: section}, nil + return &SettingsPageData{ + Section: section, + ConfigSections: configSectionsForSurface(context.Background(), s, "settings"), + }, nil } func (e *Engine) projectsLoader(c *gin.Context, s store.Stores) (any, error) { projectID := c.Param("id") return &ProjectsPageData{ProjectID: projectID}, nil } + +// ── Config section discovery (v0.38.3) ─────── +// +// Packages declare a config_section in their manifest. This helper scans +// enabled packages and returns entries whose surfaces array includes the +// target surface. No new tables or endpoints — purely manifest-driven. + +func configSectionsForSurface(ctx context.Context, s store.Stores, surfaceID string) []ConfigSectionEntry { + if s.Packages == nil { + return nil + } + pkgs, err := s.Packages.List(ctx) + if err != nil { + log.Printf("[pages] config sections: failed to list packages: %v", err) + return nil + } + + var sections []ConfigSectionEntry + for _, pkg := range pkgs { + if !pkg.Enabled || pkg.Manifest == nil { + continue + } + csRaw, ok := pkg.Manifest["config_section"] + if !ok { + continue + } + cs, ok := csRaw.(map[string]any) + if !ok { + continue + } + + // Check if this section targets the requested surface + surfacesRaw, _ := cs["surfaces"].([]any) + if len(surfacesRaw) == 0 { + continue + } + found := false + for _, v := range surfacesRaw { + if s, ok := v.(string); ok && s == surfaceID { + found = true + break + } + } + if !found { + continue + } + + label, _ := cs["label"].(string) + if label == "" { + label = pkg.Title + } + icon, _ := cs["icon"].(string) + component, _ := cs["component"].(string) + if component == "" { + component = "js/config.js" + } + category, _ := cs["category"].(string) + if category == "" { + category = "system" + } + + sections = append(sections, ConfigSectionEntry{ + PackageID: pkg.ID, + Label: label, + Icon: icon, + Component: component, + Category: category, + }) + } + return sections +} diff --git a/server/pages/pages.go b/server/pages/pages.go index c5f4170..036be4e 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -135,6 +135,17 @@ type ExtensionNavItem struct { Route string } +// ConfigSectionEntry describes a package-declared config section for +// lazy-loading in Settings, Admin, or Team Admin surfaces. +// v0.38.3: manifest-driven discovery — no new tables or endpoints. +type ConfigSectionEntry struct { + PackageID string `json:"package_id"` // section key & asset path prefix + Label string `json:"label"` // nav link text + Icon string `json:"icon"` // optional SVG path data (admin CatIcon format) + Component string `json:"component"` // relative asset path, e.g. "js/config.js" + Category string `json:"category"` // admin only: category tab (default "system") +} + // extensionNavItems returns enabled extension surfaces for sidebar rendering. // Queries the DB at render time so newly installed surfaces appear immediately. func (e *Engine) extensionNavItems() []ExtensionNavItem { diff --git a/server/pages/templates/surfaces/admin.html b/server/pages/templates/surfaces/admin.html index 6cc286f..02d3454 100644 --- a/server/pages/templates/surfaces/admin.html +++ b/server/pages/templates/surfaces/admin.html @@ -21,6 +21,7 @@ {{/* Inject section name for Preact routing */}} {{/* Vendor: Preact + htm → globals, then SDK boot, then admin surface */}} diff --git a/server/pages/templates/surfaces/settings.html b/server/pages/templates/surfaces/settings.html index 4589a25..b500731 100644 --- a/server/pages/templates/surfaces/settings.html +++ b/server/pages/templates/surfaces/settings.html @@ -18,6 +18,7 @@ {{/* Inject section name for Preact routing */}} {{/* Bridge section scripts removed in v0.37.7 — all sections are native Preact */}} diff --git a/server/pages/templates/surfaces/team-admin.html b/server/pages/templates/surfaces/team-admin.html index 03ba960..5543da9 100644 --- a/server/pages/templates/surfaces/team-admin.html +++ b/server/pages/templates/surfaces/team-admin.html @@ -15,6 +15,7 @@ {{define "scripts-team-admin"}}