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:
@@ -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/`
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */}}
|
||||
|
||||
@@ -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 */}}
|
||||
|
||||
@@ -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}}">
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 */''}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user