V0.38.3 extension config sections (#236)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-25 16:38:38 +00:00
committed by xcaliber
parent 2136535176
commit fe5894b6e2
11 changed files with 338 additions and 12 deletions

View File

@@ -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/`

View File

@@ -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, [

View File

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

View File

@@ -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 {

View File

@@ -21,6 +21,7 @@
{{/* Inject section name for Preact routing */}}
<script nonce="{{.CSPNonce}}">
window.__SECTION__ = '{{.Section}}';
window.__CONFIG_SECTIONS__ = {{.Data.ConfigSections | toJSON}};
</script>
{{/* Vendor: Preact + htm → globals, then SDK boot, then admin surface */}}

View File

@@ -18,6 +18,7 @@
{{/* Inject section name for Preact routing */}}
<script nonce="{{.CSPNonce}}">
window.__SECTION__ = '{{.Section}}';
window.__CONFIG_SECTIONS__ = {{.Data.ConfigSections | toJSON}};
</script>
{{/* Bridge section scripts removed in v0.37.7 — all sections are native Preact */}}

View File

@@ -15,6 +15,7 @@
{{define "scripts-team-admin"}}
<script nonce="{{.CSPNonce}}">
window.__SECTION__ = '{{.Section}}';
window.__CONFIG_SECTIONS__ = {{.Data.ConfigSections | toJSON}};
</script>
<script type="module" nonce="{{.CSPNonce}}">

View File

@@ -12350,6 +12350,28 @@ components:
resolved_ver:
type: string
description: Actual library version at dependency creation time
ConfigSection:
type: object
description: "v0.38.3: Manifest-declared config section for Settings/Admin/Team Admin surfaces"
properties:
label:
type: string
description: Nav link text
icon:
type: string
description: Optional SVG path data (compact format)
component:
type: string
description: Relative asset path within package archive (default js/config.js)
surfaces:
type: array
items:
type: string
enum: [admin, settings, team-admin]
description: Which surfaces show this section
category:
type: string
description: Admin-only category tab (default system)
ExtConnection:
type: object
description: "v0.38.1: Extension connection credential (secrets masked in list responses)"

View File

@@ -2,11 +2,12 @@
* AdminSurface — root admin surface
*
* Reads globals:
* __SECTION__ — active section name (string)
* __BASE__ — base path
* __SECTION__ — active section name (string)
* __BASE__ — base path
* __CONFIG_SECTIONS__ — v0.38.3: extension config sections (array|null)
*
* Layout: topbar (back + category tabs) + body (sidebar nav + content area).
* All 24 sections are native Preact components loaded lazily.
* All 24+ sections are native Preact components loaded lazily.
*/
const { html } = window;
const { useState, useEffect, useCallback, useMemo } = hooks;
@@ -37,6 +38,20 @@ const ADMIN_LABELS = {
dashboard: 'Dashboard', usage: 'Usage', audit: 'Audit', stats: 'Stats',
};
// ── v0.38.3: Extension config sections ──────
// Packages declare config_section in their manifest targeting "admin".
// We merge them into the appropriate category and section module map.
const _configSections = window.__CONFIG_SECTIONS__ || [];
const _base = window.__BASE__ || '';
for (const cs of _configSections) {
const pkgId = cs.package_id;
const cat = cs.category || 'system';
// Add to category (create if new — unlikely but safe)
if (!ADMIN_SECTIONS[cat]) ADMIN_SECTIONS[cat] = [];
if (!ADMIN_SECTIONS[cat].includes(pkgId)) ADMIN_SECTIONS[cat].push(pkgId);
ADMIN_LABELS[pkgId] = cs.label;
}
const CATEGORY_META = {
people: { label: 'People', first: 'users', icon: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2|C9 7 4' },
ai: { label: 'AI', first: 'providers', icon: 'R4 4 16 16 2|L9 9 9.01 9|L15 9 15.01 9|M8 14s1.5 2 4 2 4-2 4-2' },
@@ -76,6 +91,13 @@ const sectionModules = {
stats: () => import(`./stats.js${_v}`),
};
// v0.38.3: Register dynamic section loaders for extension config sections
for (const cs of _configSections) {
const pkgId = cs.package_id;
const component = cs.component || 'js/config.js';
sectionModules[pkgId] = () => import(`${_base}/surfaces/${pkgId}/${component}`);
}
// ── Derive category from section ────────────
function catForSection(section) {
for (const [cat, sections] of Object.entries(ADMIN_SECTIONS)) {

View File

@@ -2,8 +2,9 @@
* SettingsSurface — root settings surface
*
* Reads globals:
* __SECTION__ — active section name (string)
* __BASE__ — base path
* __SECTION__ — active section name (string)
* __BASE__ — base path
* __CONFIG_SECTIONS__ — v0.38.3: extension config sections (array|null)
*
* Policy flags (BYOK, personas) come from sw.auth.policies,
* populated at SDK boot via GET /api/v1/profile/permissions.
@@ -75,6 +76,20 @@ const SECTION_TITLES = {
notifications: 'Notifications',
};
// ── v0.38.3: Extension config sections ──────
// Packages declare config_section in their manifest. The backend passes
// matching entries via __CONFIG_SECTIONS__. We merge them into the nav
// and section module map for lazy loading.
const _configSections = window.__CONFIG_SECTIONS__ || [];
const EXT_NAV_ITEMS = _configSections.map(cs => ({ key: cs.package_id, label: cs.label }));
const _base = window.__BASE__ || '';
for (const cs of _configSections) {
const pkgId = cs.package_id;
const component = cs.component || 'js/config.js';
sectionModules[pkgId] = () => import(`${_base}/surfaces/${pkgId}/${component}`);
SECTION_TITLES[pkgId] = cs.label;
}
function SettingsSurface() {
const BASE = window.__BASE__ || '';
const section = window.__SECTION__ || 'general';
@@ -195,6 +210,20 @@ function SettingsSurface() {
</p>
</div>
`}
${EXT_NAV_ITEMS.length > 0 && html`
<div>
<div class="settings-nav-sep"></div>
<div class="settings-nav-label" style="font-size:10px;font-weight:600;color:var(--text-3);padding:8px 12px 4px;text-transform:uppercase;letter-spacing:0.5px;">Extensions</div>
${EXT_NAV_ITEMS.map(item => html`
<a href="${BASE}/settings/${item.key}"
class="settings-nav-link ${section === item.key ? 'active' : ''}"
onClick=${onNavClick}>
${item.label}
</a>
`)}
</div>
`}
</div>
${/* Content */''}

View File

@@ -2,11 +2,12 @@
* TeamAdminSurface — root team admin surface
*
* Reads globals:
* __SECTION__ — active section name (string)
* __BASE__ — base path
* __SECTION__ — active section name (string)
* __BASE__ — base path
* __CONFIG_SECTIONS__ — v0.38.3: extension config sections (array|null)
*
* Layout: topbar (back + team name) + sidebar nav + content area.
* All 10 sections are native Preact components loaded lazily.
* All 10+ sections are native Preact components loaded lazily.
*/
const { html } = window;
const { useState, useEffect, useCallback, useMemo } = hooks;
@@ -45,6 +46,16 @@ const sectionModules = {
activity: () => import('./activity.js'),
};
// ── v0.38.3: Extension config sections ──────
const _configSections = window.__CONFIG_SECTIONS__ || [];
const _base = window.__BASE__ || '';
for (const cs of _configSections) {
const pkgId = cs.package_id;
const component = cs.component || 'js/config.js';
SECTIONS.push({ key: pkgId, label: cs.label });
sectionModules[pkgId] = () => import(`${_base}/surfaces/${pkgId}/${component}`);
}
function TeamAdminSurface() {
const BASE = window.__BASE__ || '';
const section = window.__SECTION__ || 'members';