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"}}