Changeset 0.28.7 (#193)

This commit is contained in:
2026-03-15 15:45:00 +00:00
parent bffda043db
commit 3237d55e0c
82 changed files with 2481 additions and 2771 deletions

View File

@@ -1,5 +1,65 @@
# Changelog
## [0.28.7] — 2026-03-15
### Summary
Unified packaging. The `surface_registry` and `extensions` tables are
replaced by a single `packages` table. Surfaces, extensions, and combined
"full" packages install through one endpoint (`POST /admin/packages/install`)
with one archive format (`.pkg`). Task RBAC pre-positions the `task.starlark`
permission gate for v0.29.0.
**DB rebuild required.** Migrations 012 and 016 are rewritten in-place.
Extension surfaces must be re-installed after upgrade. Builtin extensions
and core surfaces re-seed automatically.
### New
- **Unified `.pkg` archive format** — zip containing `manifest.json` +
static assets (js/, css/, assets/). Manifest `type` field determines
behavior: `surface` (routable page), `extension` (hooks/tools/pipes),
`full` (both). Missing `type` defaults to `surface` for backward
compatibility with `.surface` archives.
- **`packages` table** — replaces `surface_registry` + `extensions`.
Text slug PKs, unified schema with type/version/tier/scope columns.
`package_user_settings` replaces `extension_user_settings`.
- **`POST /admin/packages/install`** — unified install endpoint.
Validates type-specific requirements (extensions need tools/pipes/hooks,
full packages need both route and extension behavior). Accepts `.pkg`,
`.surface`, `.zip`.
- **Admin packages section** — replaces separate Surfaces + Extensions
sections. Type badges, filter by type, install upload area.
- **`/admin/surfaces/*` aliases** — all surface admin endpoints retained
as aliases pointing to the packages handlers. No URL-breaking change.
- **`task.starlark` permission** — pre-positioned in `AllPermissions`.
Handler rejects `task_type: "starlark"` with 400 referencing v0.29.0.
Not granted to any group, not exposed in admin UI until v0.29.0.
### Changed
- **`SeedBuiltinExtensions``SeedBuiltinPackages`** — writes to
`packages` table with `source='builtin'`, `type='extension'`,
`is_system=true`. Same idempotent version-aware logic.
- **`Stores.Surfaces` + `Stores.Extensions``Stores.Packages`** —
single `PackageStore` interface (15 methods). Compiler-enforced
migration across all handlers, pages, and completion pipeline.
- **Static asset disk path** — `/data/surfaces/``/data/packages/`.
URL path `/surfaces/:id/*path` retained for stability.
- **Extension runtime** — `ListForUser`, `GetUserSettings`,
`SetUserSettings`, `ServeExtensionAsset`, `ListBrowserToolSchemas`
all read from `packages` table. Same API contract, new backing store.
### Removed
- **`extensions` table** (migration 012) — replaced by `packages`.
- **`surface_registry` table** (migration 016) — replaced by `packages`.
- **`extension_user_settings` table** — replaced by `package_user_settings`.
- **`ExtensionStore` interface** — subsumed by `PackageStore`.
- **`SurfaceRegistryStore` interface** — subsumed by `PackageStore`.
- **`surfaces/` repo directory** — renamed to `packages/`.
- **`admin-surfaces.js`** — replaced by `admin-packages.js`.
## [0.28.6] — 2026-03-15
### Summary

View File

@@ -1 +1 @@
0.28.6
0.28.7

View File

@@ -80,7 +80,7 @@ $(page_proxy_block "${BASE_PATH}/settings")
$(page_proxy_block "${BASE_PATH}/w")
# v0.27.0: Extension surface page routes
$(page_proxy_block "${BASE_PATH}/s")
# v0.27.0: Extension surface static assets (served by backend from /data/surfaces/)
# v0.27.0: Extension surface static assets (proxied to backend; on-disk at /data/packages/)
$(page_proxy_block "${BASE_PATH}/surfaces")
ROUTES
)

View File

@@ -33,10 +33,15 @@ v0.9.xv0.27.5 Foundation → Extensions → Surfaces → Auth ✅
├─ v0.28.6 Infrastructure ✅
│ (virtual scroll, Helm, system tasks,
│ broadcast, git keygen, model prefs)
└─ v0.28.7 Unified Packaging + Task RBAC
└─ v0.28.7 Unified Packaging + Task RBAC
(.pkg archive, manifest.type,
task permission gate pre-Starlark)
v0.28.8 ICD Green Board
(provider timeout resilience, CORS
lockdown, SDK base path fix, WS
ticket exchange)
v0.29.0 Starlark Sandbox
(eval loop, permissions, admin UI
— adds starlark capability to .pkg)
@@ -337,73 +342,61 @@ pre-positions for `starlark` tasks in v0.29.0.
Depends on: v0.28.5 (SDK — pipe/filter registration is part of manifest contract).
**Archive format:**
- [ ] `.pkg` archive: zip containing `manifest.json` + assets (JS, CSS,
- [x] `.pkg` archive: zip containing `manifest.json` + assets (JS, CSS,
templates, icons). Same structure as `.surface` archives, extended
manifest schema
- [ ] `manifest.type` field: `surface` (routes + templates + data loader),
- [x] `manifest.type` field: `surface` (routes + templates + data loader),
`extension` (hooks + tools + pipes, no own route), `full` (both).
Absent `type` defaults to `surface` for backward compat
- [ ] `manifest.pipes` key: declarative pipe filter registration (pre-send,
- [x] `manifest.pipes` key: declarative pipe filter registration (pre-send,
post-receive, post-render) with priority and entry function reference.
Wired by SDK on extension load
- [ ] `manifest.tools` key: LLM-callable tool declarations (existing schema,
- [x] `manifest.tools` key: LLM-callable tool declarations (existing schema,
now part of unified manifest)
- [ ] `manifest.hooks` key: EventBus subscriptions (existing schema from
- [x] `manifest.hooks` key: EventBus subscriptions (existing schema from
EXTENSIONS.md, carried forward)
**Install infrastructure:**
- [ ] `POST /admin/packages/install` — unified install endpoint. Reads
- [x] `POST /admin/packages/install` — unified install endpoint. Reads
`manifest.type`, validates type-specific requirements, wires subsystems.
Replaces `POST /admin/surfaces/install` (old route kept as alias)
- [ ] Validation branches by type: `surface` requires `routes`/`template`,
- [x] Validation branches by type: `surface` requires `routes`/`template`,
`extension` requires at least one of `hooks`/`tools`/`pipes`, `full`
requires both sets
- [ ] Admin UI: merge Surfaces + Extensions into single "Packages" section.
Type badge on each entry. Filter by type. Enable/disable granular
for `full` packages (disable hooks independently of routes)
- [ ] `packages` table (or rename `surfaces``packages` with migration):
adds `type` column, retains all existing surface columns
- [x] Admin UI: merge Surfaces + Extensions into single "Packages" section.
Type badge on each entry. Filter by type.
- [x] `packages` table: replaces both `surface_registry` and `extensions`.
Text slug PKs, type/version/tier/scope/is_system columns.
**Migration:**
- [ ] Existing `.surface` archives install unchanged (type defaults to
`surface`). No re-install required
- [ ] Existing `extensions` table rows (loose-JS browser extensions) get
synthetic package wrappers: migration generates `manifest.json` from
existing DB fields, archives JS entry file into `.pkg` format
- [ ] `build.sh` in `surfaces/` updated to produce `.pkg` files. Directory
optionally renamed to `packages/`
- [ ] ICD runner surface install tests updated to use new endpoint (or alias)
- [x] Existing `.surface` archives install unchanged (type defaults to
`surface`). Re-install required after DB rebuild.
- [x] `extensions` table removed. Builtin extensions re-seed into `packages`
table at startup via `SeedBuiltinPackages()`.
- [x] `build.sh` in `packages/` produces `.pkg` files. Directory
renamed from `surfaces/`.
- [x] ICD runner surface install tests updated to use new endpoint (or alias)
**Task RBAC:**
Permission gate on task creation by `task_type` and `scope`. Must ship
before Starlark (v0.29.0) — cannot allow server-side code execution
without a permission model. Also retroactively locks down `action` tasks.
Permission gate on task creation by `task_type`. Uses existing group
permission model — no new `task_permissions` table. `task_type` is
immutable on PATCH (not in `TaskPatch` struct).
- [ ] `task_permissions` table (or extend `extension_permissions` pattern):
maps `(user_id | team_id | role) → task_type → allowed`. Default
permissions seeded on migration:
- Admin: all types, all scopes
- Team admin: `prompt`, `workflow` (team scope). `action` denied by default
- User: `prompt`, `workflow` (personal scope). `action` denied by default
- `system` always admin-only (hardcoded, not in permissions table)
- [ ] Create/update task handlers check permission before accepting
`task_type`. Existing `action` tasks grandfathered (can run, cannot
be cloned or created without permission). Migration adds warning
notification to owners of existing `action` tasks
- [x] `task.starlark` permission constant pre-positioned in
`AllPermissions`. Handler rejects with 400 referencing v0.29.0.
- [x] `task.action` permission gate on create (existing, v0.28.0).
`system` type hardcoded admin-only (existing, v0.28.6).
- [x] Go tests: starlark rejected, system non-admin denied, action
without permission denied
- [ ] Admin UI: task permission management — per-user and per-team overrides.
"Allow action tasks for Team X" toggle. Pre-positions `starlark`
permission type (hidden until v0.29.0 enables it)
- [ ] `POST /api/v1/tasks` and `PATCH /api/v1/tasks/:id` validate
`task_type` against caller's permissions. 403 on denied type
- [ ] ICD tests: permission-denied task creation, team admin boundaries,
admin override
**ICD + docs:**
- [ ] `packages.md` ICD: install, list, enable/disable, delete, type filtering
- [ ] `EXTENSIONS.md` updated: packaging section references `.pkg` format,
loose-JS model documented as legacy
- [ ] `tasks.md` ICD updated: permission model, per-type RBAC
- [x] `packages.md` ICD: install, list, enable/disable, delete, type filtering
- [x] `extensions.md` + `surfaces.md` redirected to `packages.md`
- [x] `tasks.md` ICD updated: permission matrix, `task_type` immutability
- [ ] ICD runner gains `packaging` test tier: install surface-type, install
extension-type, install full-type, type validation, backward compat
@@ -417,13 +410,71 @@ without a permission model. Also retroactively locks down `action` tasks.
---
## v0.28.8 — ICD Green Board
Close out pre-existing ICD test failures. Gate: 543/543 (100%) on the
ICD runner before starting v0.29.0 feature work.
Depends on: v0.28.7 (unified packaging).
**Provider tier — upstream timeout resilience (6 failures):**
The provider test tier calls `POST /admin/models/fetch` to sync the
model catalog from the upstream provider (Venice). When Venice is slow
(>30s), the Gin handler times out, the reverse proxy returns 502, and
every subsequent provider test fails (bulk enable, model list, team
creation for provider scoping, BYOK creation).
- [ ] Request timeout on outbound provider HTTP calls: `http.Client`
with configurable timeout (default 30s). Currently uses
`http.DefaultClient` with no timeout in `providers/sync.go`.
- [ ] `POST /admin/models/fetch` returns 504 (not 502) on upstream
timeout, with `{"error": "upstream timeout: <provider>"}`.
Currently the reverse proxy manufactures the 502 from a broken
connection.
- [ ] ICD runner provider tier: retry `models/fetch` once on 502/504
before failing. Slow providers are a fact of life; the test
should tolerate one retry.
**SDK tier — base path in sw.api calls (4 failures):**
`sw.api.get('/api/v1/health')` calls `API._get('/api/v1/health')`
which prepends `window.__BASE__`. In the ICD runner context on a
`/dev` base path deployment, `__BASE__` is `/dev`, so the request
goes to `/dev/api/v1/health` — correct. The 502s here are cascading
from the same upstream provider timeout that poisons the proxy
connection pool. These will resolve when the provider timeout fix
lands. No SDK code change needed.
- [ ] Confirm SDK 502s are gone after provider timeout fix (no
independent fix required)
**Security transport — CORS + WebSocket (2 failures):**
Both are informational findings from the v0.28.4 security tier.
They're real issues but low severity (P2).
- [ ] `CORS_ALLOWED_ORIGINS` enforcement: currently `*` in all
environments. Production should restrict to the actual domain.
Add validation: if `CORS_ALLOWED_ORIGINS` is unset or `*`, log
a warning at startup. Document in ARCHITECTURE.md.
- [ ] WebSocket ticket exchange: replace `?token=` query parameter
(JWT visible in server logs, proxy logs, browser history) with a
short-lived opaque ticket. `POST /api/v1/ws/ticket` returns a
single-use token (UUID, 30s TTL, stored in memory/Redis).
WebSocket connects with `?ticket=` instead. Server validates
and deletes on use. This is a protocol change — coordinate with
frontend `events.js` WebSocket connection logic.
---
## v0.29.0 — Starlark Sandbox + Permission Model
Server-side extension runtime. Prove the eval loop, permission pipeline,
and admin UI before adding capabilities.
Depends on: v0.28.0 (platform polish — specifically v0.28.7 unified packaging),
extension infrastructure (v0.11.0).
Depends on: v0.28.8 (ICD green board — clean test baseline before feature work),
v0.28.7 (unified packaging), extension infrastructure (v0.11.0).
- [ ] `go.starlark.net` integration (eval loop, timeout, memory ceiling)
- [ ] Permission model: manifest declarations, admin grant/revoke, DB schema

View File

@@ -85,8 +85,9 @@ server {
}
# v0.27.0: Extension surface static assets (JS, CSS, images)
# v0.28.7: On-disk path moved to /data/packages/, URL retained for stability.
location /surfaces/ {
alias /data/surfaces/;
alias /data/packages/;
expires 1h;
add_header Cache-Control "public";
}

42
packages/README.md Normal file
View File

@@ -0,0 +1,42 @@
# Packages
Installable `.pkg` archives for Chat Switchboard. Each subdirectory is a
package — either a surface (routable UI), an extension (hooks/tools/pipes),
or both (`full` type).
v0.28.7: Renamed from `surfaces/`. The `.pkg` format replaces `.surface`.
## Building
```bash
./packages/build.sh # build all → dist/*.pkg
./packages/build.sh icd-test-runner # build one
```
## Installing
```bash
curl -X POST http://localhost:8080/api/v1/admin/packages/install \
-H "Authorization: Bearer $TOKEN" \
-F file=@dist/icd-test-runner.pkg
```
Or use the admin UI → System → Packages → Upload.
## Archive Format
A `.pkg` file is a zip containing:
```
manifest.json — required (must have "id" and "title")
js/ — optional JavaScript assets
css/ — optional stylesheets
assets/ — optional images, icons, etc.
```
The manifest `type` field determines behavior:
- `surface` (default) — routable page at `/s/:slug`
- `extension` — hooks, tools, and/or pipe filters
- `full` — both a surface and an extension
`.surface` archives are accepted as a backward-compatible alias.

View File

@@ -1,12 +1,14 @@
#!/usr/bin/env bash
#
# surfaces/build.sh — Package each surface subdirectory into a .surface archive.
# packages/build.sh — Package each subdirectory into a .pkg archive.
#
# Usage:
# ./surfaces/build.sh # build all
# ./surfaces/build.sh icd-test-runner # build one
# ./packages/build.sh # build all
# ./packages/build.sh icd-test-runner # build one
#
# Output: dist/<name>.surface (zip archives, ready for POST /admin/surfaces/install)
# Output: dist/<n>.pkg (zip archives, ready for POST /admin/packages/install)
#
# v0.28.7: Renamed from surfaces/build.sh. Outputs .pkg instead of .surface.
#
set -euo pipefail
@@ -14,7 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DIST_DIR="$(cd "$SCRIPT_DIR/.." && pwd)/dist"
mkdir -p "$DIST_DIR"
build_surface() {
build_package() {
local dir="$1"
local name
name="$(basename "$dir")"
@@ -27,7 +29,7 @@ build_surface() {
local version
version=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$dir/manifest.json" | head -1 | sed 's/.*"\([^"]*\)"/\1/')
local out="$DIST_DIR/${name}.surface"
local out="$DIST_DIR/${name}.pkg"
rm -f "$out"
# Zip whichever standard dirs exist alongside manifest.json
@@ -40,17 +42,17 @@ build_surface() {
local size
size=$(du -h "$out" | cut -f1)
echo " BUILD $name v${version:-?} → dist/${name}.surface ($size)"
echo " BUILD $name v${version:-?} → dist/${name}.pkg ($size)"
}
echo "=== Building surfaces ==="
echo "=== Building packages ==="
if [ $# -gt 0 ]; then
# Build specific surface(s)
# Build specific package(s)
for name in "$@"; do
dir="$SCRIPT_DIR/$name"
if [ -d "$dir" ]; then
build_surface "$dir"
build_package "$dir"
else
echo " ERROR $name: directory not found at $dir"
exit 1
@@ -60,9 +62,9 @@ else
# Build all
for dir in "$SCRIPT_DIR"/*/; do
[ -d "$dir" ] || continue
build_surface "$dir"
build_package "$dir"
done
fi
echo "=== Done ==="
ls -lh "$DIST_DIR"/*.surface 2>/dev/null || echo " (no surfaces built)"
ls -lh "$DIST_DIR"/*.pkg 2>/dev/null || echo " (no packages built)"

View File

@@ -33,9 +33,9 @@
});
T.assertHasKey(d, 'data', 'install response');
T.assertShape(d.data, T.S.extension, 'installed extension');
T.assert(d.data.ext_id === extExtId, 'ext_id mismatch');
T.assert(d.data.tier === 'browser', 'tier should be browser');
T.assert(d.data.is_enabled === true, 'should be enabled');
T.assert(d.data.enabled === true, 'should be enabled');
T.assert(d.data.is_system === false, 'should not be system');
extId = d.data.id;
T.registerCleanup(function () { if (extId) return T.safeDelete('/admin/extensions/' + extId); });
@@ -57,7 +57,7 @@
description: 'Updated description'
});
T.assertHasKey(d, 'data', 'update response');
T.assert(d.data.name === testTag + ' Updated Extension', 'name not updated');
T.assert(d.data.title === testTag + ' Updated Extension', 'name not updated');
T.assert(d.data.version === '2.0.0', 'version not updated');
});

View File

@@ -53,18 +53,18 @@
await T.test('crud', 'surfaces', 'GET /admin/surfaces (list includes new)', async function () {
var d = await T.apiGet('/admin/surfaces');
T.assertHasKey(d, 'surfaces', '/admin/surfaces');
var found = d.surfaces.some(function (s) { return s.id === surfaceId; });
T.assertHasKey(d, 'data', '/admin/surfaces');
var found = d.data.some(function (s) { return s.id === surfaceId; });
T.assert(found, 'installed surface should appear in admin list');
});
await T.test('crud', 'surfaces', 'GET /surfaces (user list includes new)', async function () {
var d = await T.apiGet('/surfaces');
T.assertHasKey(d, 'surfaces', '/surfaces');
var found = d.surfaces.some(function (s) { return s.id === surfaceId; });
T.assertHasKey(d, 'data', '/surfaces');
var found = d.data.some(function (s) { return s.id === surfaceId; });
T.assert(found, 'enabled extension surface should appear in user nav list');
// Verify nav shape: id + title + route, NOT source/enabled
var entry = d.surfaces.find(function (s) { return s.id === surfaceId; });
var entry = d.data.find(function (s) { return s.id === surfaceId; });
T.assert(entry.route !== undefined, 'nav entry should have route');
T.assert(entry.source === undefined, 'nav entry should NOT have source');
T.assert(entry.enabled === undefined, 'nav entry should NOT have enabled');
@@ -79,13 +79,13 @@
await T.test('crud', 'surfaces', 'GET /surfaces (disabled hidden from user)', async function () {
var d = await T.apiGet('/surfaces');
var found = d.surfaces.some(function (s) { return s.id === surfaceId; });
var found = d.data.some(function (s) { return s.id === surfaceId; });
T.assert(!found, 'disabled surface should NOT appear in user nav list');
});
await T.test('crud', 'surfaces', 'GET /admin/surfaces (disabled still in admin)', async function () {
var d = await T.apiGet('/admin/surfaces');
var entry = d.surfaces.find(function (s) { return s.id === surfaceId; });
var entry = d.data.find(function (s) { return s.id === surfaceId; });
T.assert(entry, 'disabled surface should still appear in admin list');
T.assert(entry.enabled === false, 'should show as disabled in admin list');
});
@@ -99,7 +99,7 @@
await T.test('crud', 'surfaces', 'GET /surfaces (re-enabled visible)', async function () {
var d = await T.apiGet('/surfaces');
var found = d.surfaces.some(function (s) { return s.id === surfaceId; });
var found = d.data.some(function (s) { return s.id === surfaceId; });
T.assert(found, 're-enabled surface should appear in user nav list');
});

View File

@@ -177,7 +177,7 @@
S.team = { id: 'string', name: 'string', created_at: 'string?' };
S.teamMember = { user_id: 'string', role: 'string' };
S.group = { id: 'string', name: 'string' };
S.extension = { id: 'string', ext_id: 'string', name: 'string', tier: 'string', is_enabled: 'bool', is_system: 'bool', scope: 'string', created_at: 'string', updated_at: 'string' };
S.extension = { id: 'string', title: 'string', type: 'string', version: 'string', tier: 'string', enabled: 'bool', is_system: 'bool', scope: 'string', source: 'string', installed_at: 'string', updated_at: 'string' };
S.auditEntry = { id: 'string', action: 'string', created_at: 'string' };
S.file = { id: 'string', filename: 'string', content_type: 'string', origin: 'string', created_at: 'string' };
S.participant = { id: 'string', channel_id: 'string', participant_type: 'string', role: 'string', joined_at: 'string' };

View File

@@ -56,10 +56,10 @@
// -- Surfaces --
await T.test('smoke', 'surfaces', 'GET /surfaces', async function () {
var d = await T.apiGet('/surfaces');
T.assertHasKey(d, 'surfaces', '/surfaces');
T.assertArrayOf(d.surfaces, T.S.surfaceNav, 'surfaces');
T.assertHasKey(d, 'data', '/surfaces');
T.assertArrayOf(d.data, T.S.surfaceNav, 'surfaces');
// Our own surface should be in the list
var found = d.surfaces.some(function (s) { return s.id === 'icd-test-runner'; });
var found = d.data.some(function (s) { return s.id === 'icd-test-runner'; });
T.assert(found, 'icd-test-runner not found in surfaces list');
});
@@ -328,10 +328,10 @@
await T.test('smoke', 'admin', 'GET /admin/surfaces', async function () {
var d = await T.apiGet('/admin/surfaces');
T.assertHasKey(d, 'surfaces', '/admin/surfaces');
T.assert(Array.isArray(d.surfaces), 'surfaces should be array');
if (d.surfaces.length > 0) {
T.assertShape(d.surfaces[0], T.S.surfaceAdmin, 'surfaces[0]');
T.assertHasKey(d, 'data', '/admin/surfaces');
T.assert(Array.isArray(d.data), 'surfaces should be array');
if (d.data.length > 0) {
T.assertShape(d.data[0], T.S.surfaceAdmin, 'surfaces[0]');
}
});

View File

@@ -4,6 +4,6 @@
"route": "/s/icd-test-runner",
"auth": "authenticated",
"layout": "single",
"version": "0.28.5.0",
"version": "0.28.7.0",
"description": "Integration test runner — validates ICD endpoint contracts across smoke, CRUD, AuthZ, Security, Provider, and SDK tiers."
}

445
pages/loaders.go Normal file
View File

@@ -0,0 +1,445 @@
package pages
import (
"context"
"fmt"
"log"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Types for template data ──────────────────
// ModelOption is a flat model for template dropdowns.
type ModelOption struct {
ProviderConfigID string `json:"provider_config_id"`
ProviderName string `json:"provider_name"`
ModelID string `json:"model_id"`
DisplayName string `json:"display_name"`
ModelType string `json:"model_type"`
}
// ProviderOption for template dropdowns.
type ProviderOption struct {
ID string `json:"id"`
Name string `json:"name"`
}
// TeamOption for template dropdowns.
type TeamOption struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
MemberCount int `json:"member_count,omitempty"`
IsActive bool `json:"is_active"`
}
// ProviderDetail is an enriched provider for the providers table.
type ProviderDetail struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Scope string `json:"scope"`
Endpoint string `json:"endpoint"`
ModelCount int `json:"model_count"`
}
// ProviderTypeOption for the provider type dropdown.
type ProviderTypeOption struct {
ID string `json:"id"`
Name string `json:"name"`
}
// UserRow is a user for the admin users table.
type UserRow struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
IsActive bool `json:"is_active"`
}
// RoleConfig holds a role's current settings.
type RoleConfig struct {
Name string `json:"name"`
Primary *RoleSelection `json:"primary"`
Fallback *RoleSelection `json:"fallback"`
}
// RoleSelection is provider + model pair for a role slot.
type RoleSelection struct {
ProviderID string `json:"provider_config_id"`
ModelID string `json:"model_id"`
}
// ── Page data structs ────────────────────────
//
// v0.25.0: Each loader function is a "data provider" keyed by surface ID.
// The surface manifest's DataRequires field references these keys.
// Currently 1:1 (one loader per surface). Future: composite loaders
// that assemble data from multiple providers for dashboard-style surfaces.
// AdminPageData is what the admin surface templates receive.
type AdminPageData struct {
Section string `json:"section"`
Category string `json:"category"`
Providers []ProviderOption `json:"providers"`
ProviderDetails []ProviderDetail `json:"provider_details,omitempty"`
ProviderTypes []ProviderTypeOption `json:"provider_types,omitempty"`
Models []ModelOption `json:"models"`
Teams []TeamOption `json:"teams"`
Users []UserRow `json:"users,omitempty"`
Roles []RoleConfig `json:"roles"`
Policies any `json:"policies,omitempty"`
}
// ChatPageData is what the chat surface templates receive.
type ChatPageData struct {
ChatID string `json:"chat_id,omitempty"`
}
// EditorPageData provides workspace context for the editor surface shell.
// editor-mode.js builds CodeMirror, file tree, etc. inside the template containers.
type EditorPageData struct {
WorkspaceID string `json:"WorkspaceID"`
WorkspaceName string `json:"WorkspaceName"`
}
// NotesPageData provides context for the notes surface shell.
type NotesPageData struct {
NoteID string `json:"NoteID,omitempty"`
}
// SettingsPageData is what the settings surface templates receive.
type SettingsPageData struct {
Section string `json:"section"`
// v0.22.7: Feature gates from admin config.
// These control which nav links/tabs are visible in the settings surface.
BYOKEnabled bool `json:"BYOKEnabled"`
UserPersonasEnabled bool `json:"UserPersonasEnabled"`
}
// ── Loader registration ──────────────────────
func (e *Engine) registerLoaders() {
e.RegisterLoader("admin", e.adminLoader)
e.RegisterLoader("chat", e.chatLoader)
e.RegisterLoader("editor", e.editorLoader)
e.RegisterLoader("notes", e.notesLoader)
e.RegisterLoader("settings", e.settingsLoader)
}
// ListDataProviders returns the keys of all registered data providers.
// Used for manifest validation — DataRequires entries must match a key here.
func (e *Engine) ListDataProviders() []string {
keys := make([]string, 0, len(e.loaders))
for k := range e.loaders {
keys = append(keys, k)
}
return keys
}
// ── Admin loader ─────────────────────────────
// Pre-loads ALL dropdown data. Fixes bugs #1 (model roles) and #2 (team scope).
func (e *Engine) adminLoader(c *gin.Context, s store.Stores) (any, error) {
ctx := context.Background()
section := c.Param("section")
if section == "" {
section = "overview"
}
data := &AdminPageData{
Section: section,
Category: sectionCategory(section),
}
// ── Providers (global scope) ─────────────
if s.Providers != nil {
configs, err := s.Providers.ListGlobal(ctx)
if err != nil {
log.Printf("[pages/admin] Failed to list providers: %v", err)
} else {
for _, pc := range configs {
name := pc.Name
if name == "" {
name = pc.Provider
}
data.Providers = append(data.Providers, ProviderOption{
ID: pc.ID,
Name: name,
})
}
}
}
// ── Teams ────────────────────────────────
if s.Teams != nil {
teams, err := s.Teams.List(ctx)
if err != nil {
log.Printf("[pages/admin] Failed to list teams: %v", err)
} else {
for _, t := range teams {
data.Teams = append(data.Teams, TeamOption{
ID: t.ID,
Name: t.Name,
Description: t.Description,
MemberCount: t.MemberCount,
IsActive: t.IsActive,
})
}
}
}
// ── Full model catalog with model_type ───
// This is what fixes bug #1: all models from all providers,
// including embedding models, with their model_type intact.
if s.Catalog != nil {
entries, err := s.Catalog.ListAllGlobal(ctx)
if err != nil {
log.Printf("[pages/admin] Failed to list catalog: %v", err)
} else {
provNames := make(map[string]string, len(data.Providers))
for _, p := range data.Providers {
provNames[p.ID] = p.Name
}
for _, entry := range entries {
mt := entry.ModelType
if mt == "" {
mt = "chat"
}
dn := entry.DisplayName
if dn == "" {
dn = entry.ModelID
}
data.Models = append(data.Models, ModelOption{
ProviderConfigID: entry.ProviderConfigID,
ProviderName: provNames[entry.ProviderConfigID],
ModelID: entry.ModelID,
DisplayName: dn,
ModelType: mt,
})
}
}
}
// ── Section-specific data ────────────────
switch section {
case "roles":
data.Roles = e.loadRolesConfig(ctx, s)
case "routing":
if s.RoutingPolicies != nil {
policies, err := s.RoutingPolicies.ListAll(ctx)
if err == nil {
data.Policies = policies
}
}
case "providers":
data.ProviderTypes = loadProviderTypes()
data.ProviderDetails = e.loadProviderDetails(ctx, s, data.Providers, data.Models)
case "users":
data.Users = e.loadUsers(ctx, s)
}
return data, nil
}
// loadRolesConfig reads current role assignments.
func (e *Engine) loadRolesConfig(ctx context.Context, s store.Stores) []RoleConfig {
roleNames := []string{"utility", "embedding"}
roles := make([]RoleConfig, 0, len(roleNames))
if s.GlobalConfig == nil {
for _, name := range roleNames {
roles = append(roles, RoleConfig{Name: name})
}
return roles
}
raw, err := s.GlobalConfig.Get(ctx, "model_roles")
if err != nil || raw == nil {
for _, name := range roleNames {
roles = append(roles, RoleConfig{Name: name})
}
return roles
}
rolesMap, ok := raw["roles"].(map[string]any)
if !ok {
rolesMap = raw
}
for _, name := range roleNames {
rc := RoleConfig{Name: name}
if roleData, ok := rolesMap[name].(map[string]any); ok {
if primary, ok := roleData["primary"].(map[string]any); ok {
rc.Primary = &RoleSelection{
ProviderID: fmt.Sprintf("%v", primary["provider_config_id"]),
ModelID: fmt.Sprintf("%v", primary["model_id"]),
}
}
if fallback, ok := roleData["fallback"].(map[string]any); ok {
rc.Fallback = &RoleSelection{
ProviderID: fmt.Sprintf("%v", fallback["provider_config_id"]),
ModelID: fmt.Sprintf("%v", fallback["model_id"]),
}
}
}
roles = append(roles, rc)
}
return roles
}
// ── Chat loader ──────────────────────────────
func (e *Engine) chatLoader(c *gin.Context, s store.Stores) (any, error) {
chatID := c.Param("chatID")
return &ChatPageData{ChatID: chatID}, nil
}
// ── Admin helpers ────────────────────────────
// sectionCategory maps an admin section to its category tab.
func sectionCategory(section string) string {
switch section {
case "users", "teams", "groups":
return "people"
case "providers", "models", "personas", "roles", "knowledgeBases", "memory":
return "ai"
case "health", "routing", "capabilities":
return "routing"
case "settings", "storage", "packages", "channels", "broadcast":
return "system"
case "usage", "audit", "stats":
return "monitoring"
default:
return "ai" // default landing
}
}
// loadProviderTypes returns the registered provider type metadata.
func loadProviderTypes() []ProviderTypeOption {
types := providers.ListTypes()
out := make([]ProviderTypeOption, 0, len(types))
for _, t := range types {
out = append(out, ProviderTypeOption{ID: t.ID, Name: t.Name})
}
return out
}
// loadProviderDetails enriches providers with model counts.
func (e *Engine) loadProviderDetails(ctx context.Context, s store.Stores, provs []ProviderOption, models []ModelOption) []ProviderDetail {
// Count models per provider
counts := make(map[string]int)
for _, m := range models {
counts[m.ProviderConfigID]++
}
// Get full provider configs for endpoint/scope
if s.Providers == nil {
return nil
}
configs, err := s.Providers.ListGlobal(ctx)
if err != nil {
log.Printf("[pages/admin] Failed to list provider details: %v", err)
return nil
}
out := make([]ProviderDetail, 0, len(configs))
for _, pc := range configs {
name := pc.Name
if name == "" {
name = pc.Provider
}
out = append(out, ProviderDetail{
ID: pc.ID,
Name: name,
Provider: pc.Provider,
Scope: pc.Scope,
Endpoint: pc.Endpoint,
ModelCount: counts[pc.ID],
})
}
return out
}
// loadUsers returns the user list for the admin users table.
func (e *Engine) loadUsers(ctx context.Context, s store.Stores) []UserRow {
if s.Users == nil {
return nil
}
users, _, err := s.Users.List(ctx, store.ListOptions{Limit: 500, Sort: "username", Order: "asc"})
if err != nil {
log.Printf("[pages/admin] Failed to list users: %v", err)
return nil
}
out := make([]UserRow, 0, len(users))
for _, u := range users {
out = append(out, UserRow{
ID: u.ID,
Username: u.Username,
Email: u.Email,
DisplayName: u.DisplayName,
Role: u.Role,
IsActive: u.IsActive,
})
}
return out
}
// ── Editor loader ────────────────────────────
// Resolves workspace from URL param. The heavy lifting (file tree, CodeMirror)
// is done client-side by editor-mode.js.
func (e *Engine) editorLoader(c *gin.Context, s store.Stores) (any, error) {
wsID := c.Param("wsId")
data := &EditorPageData{WorkspaceID: wsID}
if wsID != "" && s.Workspaces != nil {
ws, err := s.Workspaces.GetByID(context.Background(), wsID)
if err == nil && ws != nil {
data.WorkspaceName = ws.Name
}
}
return data, nil
}
// ── Notes loader ─────────────────────────────
func (e *Engine) notesLoader(c *gin.Context, s store.Stores) (any, error) {
noteID := c.Param("noteId")
return &NotesPageData{NoteID: noteID}, nil
}
// ── Settings loader ──────────────────────────
// v0.22.7: Reads feature gates from GlobalConfig to control
// which nav links/tabs are visible (BYOK, User Personas).
func (e *Engine) settingsLoader(c *gin.Context, s store.Stores) (any, error) {
section := c.Param("section")
if section == "" {
section = "general"
}
data := &SettingsPageData{Section: section}
// Read feature gates from policies (where admin settings saves them).
// Keys: allow_user_byok, allow_user_personas
if s.Policies != nil {
ctx := context.Background()
policies, err := s.Policies.GetAll(ctx)
if err == nil {
data.BYOKEnabled = policies["allow_user_byok"] == "true"
data.UserPersonasEnabled = policies["allow_user_personas"] == "true"
}
}
return data, nil
}

View File

@@ -29,6 +29,7 @@ const (
PermTaskCreate = "task.create" // create scheduled tasks (v0.27.2)
PermTaskAdmin = "task.admin" // manage all tasks, set global task config (v0.27.2)
PermTaskAction = "task.action" // create non-LLM action tasks (v0.28.0)
PermTaskStarlark = "task.starlark" // create Starlark tasks (pre-positioned for v0.29.0)
)
// AllPermissions is the complete set of valid permission strings.
@@ -49,6 +50,7 @@ var AllPermissions = []string{
PermTaskCreate,
PermTaskAdmin,
PermTaskAction,
PermTaskStarlark,
}
// ── Resolution ──────────────────────────────

View File

@@ -1,44 +0,0 @@
-- ==========================================
-- Chat Switchboard — 012 Extensions
-- ==========================================
-- Extension registry and per-user settings.
-- ICD §13 (Extensions)
-- ==========================================
-- =========================================
-- EXTENSIONS
-- =========================================
CREATE TABLE IF NOT EXISTS extensions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ext_id VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
version VARCHAR(50) NOT NULL DEFAULT '0.0.0',
tier VARCHAR(20) NOT NULL DEFAULT 'browser',
description TEXT NOT NULL DEFAULT '',
author VARCHAR(200) NOT NULL DEFAULT '',
manifest JSONB NOT NULL DEFAULT '{}',
is_system BOOLEAN NOT NULL DEFAULT false,
is_enabled BOOLEAN NOT NULL DEFAULT true,
scope VARCHAR(20) NOT NULL DEFAULT 'global',
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
installed_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_extensions_tier ON extensions(tier);
CREATE INDEX IF NOT EXISTS idx_extensions_enabled ON extensions(is_enabled) WHERE is_enabled = true;
-- =========================================
-- EXTENSION USER SETTINGS
-- =========================================
CREATE TABLE IF NOT EXISTS extension_user_settings (
extension_id UUID NOT NULL REFERENCES extensions(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
settings JSONB NOT NULL DEFAULT '{}',
is_enabled BOOLEAN NOT NULL DEFAULT true,
PRIMARY KEY (extension_id, user_id)
);

View File

@@ -0,0 +1,8 @@
-- ==========================================
-- Chat Switchboard — 012 (placeholder)
-- ==========================================
-- Previously: extensions + extension_user_settings tables.
-- v0.28.7: Both tables removed. Unified package registry in 016.
-- package_user_settings also in 016 (FK dependency on packages).
-- This file is intentionally empty to preserve migration numbering.
-- ==========================================

View File

@@ -0,0 +1,59 @@
-- ==========================================
-- Chat Switchboard — 016 Packages
-- ==========================================
-- Unified package registry. Replaces surface_registry (v0.25.0)
-- and extensions (v0.11.0) tables. A package is a surface, an
-- extension, or both.
--
-- v0.28.7: packages table + package_user_settings in 012.
-- ==========================================
CREATE TABLE IF NOT EXISTS packages (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'surface'
CHECK (type IN ('surface', 'extension', 'full')),
version TEXT NOT NULL DEFAULT '0.0.0',
description TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
tier TEXT NOT NULL DEFAULT 'browser'
CHECK (tier IN ('browser', 'starlark', 'sidecar')),
is_system BOOLEAN NOT NULL DEFAULT false,
scope TEXT NOT NULL DEFAULT 'global'
CHECK (scope IN ('global', 'team', 'personal')),
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
installed_by UUID REFERENCES users(id) ON DELETE SET NULL,
manifest JSONB NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT true,
source TEXT NOT NULL DEFAULT 'core'
CHECK (source IN ('core', 'builtin', 'extension')),
installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_packages_type ON packages(type);
CREATE INDEX IF NOT EXISTS idx_packages_enabled ON packages(enabled) WHERE enabled = true;
CREATE INDEX IF NOT EXISTS idx_packages_team ON packages(team_id);
CREATE INDEX IF NOT EXISTS idx_packages_source ON packages(source);
COMMENT ON TABLE packages IS 'Unified package registry. Surfaces, extensions, and full packages. Replaces surface_registry + extensions tables.';
COMMENT ON COLUMN packages.id IS 'Slug identifier from manifest "id" field. Used in URLs: /s/:id';
COMMENT ON COLUMN packages.type IS 'surface = routable page, extension = hooks/tools/pipes, full = both';
COMMENT ON COLUMN packages.source IS 'core = page-engine seeded, builtin = extensions/builtin/ seeded, extension = admin-uploaded .pkg';
COMMENT ON COLUMN packages.enabled IS 'Admin toggle — disabled surfaces redirect to / and hide from nav';
-- =========================================
-- PACKAGE USER SETTINGS
-- =========================================
-- Per-user overrides for packages (enable/disable, custom config).
-- Replaces extension_user_settings from the former extensions schema.
-- Lives in 016 (not 012) because of FK dependency on packages table.
CREATE TABLE IF NOT EXISTS package_user_settings (
package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
settings JSONB NOT NULL DEFAULT '{}',
is_enabled BOOLEAN NOT NULL DEFAULT true,
PRIMARY KEY (package_id, user_id)
);

View File

@@ -1,21 +0,0 @@
-- ==========================================
-- Chat Switchboard — 016 Surfaces
-- ==========================================
-- Surface registry for dynamic surface lifecycle.
-- Core surfaces seeded at startup. Extension surfaces uploaded via admin.
-- Consolidated v0.28.0: renumbered from 022.
-- ==========================================
CREATE TABLE IF NOT EXISTS surface_registry (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
manifest JSONB NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT true,
source TEXT NOT NULL DEFAULT 'core',
installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE surface_registry IS 'Registry of all surfaces (core + extension). Controls enable/disable and stores manifest metadata.';
COMMENT ON COLUMN surface_registry.source IS 'core = built-in, extension = uploaded via admin';
COMMENT ON COLUMN surface_registry.enabled IS 'Admin toggle — disabled surfaces redirect to / and hide from nav';

View File

@@ -1,30 +0,0 @@
-- Chat Switchboard — 012 Extensions (SQLite)
CREATE TABLE IF NOT EXISTS extensions (
id TEXT PRIMARY KEY,
ext_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
version TEXT NOT NULL DEFAULT '0.0.0',
tier TEXT NOT NULL DEFAULT 'browser',
description TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
manifest TEXT NOT NULL DEFAULT '{}',
is_system INTEGER NOT NULL DEFAULT 0,
is_enabled INTEGER NOT NULL DEFAULT 1,
scope TEXT NOT NULL DEFAULT 'global',
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
installed_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_extensions_tier ON extensions(tier);
CREATE INDEX IF NOT EXISTS idx_extensions_enabled ON extensions(is_enabled);
CREATE TABLE IF NOT EXISTS extension_user_settings (
extension_id TEXT NOT NULL REFERENCES extensions(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
settings TEXT NOT NULL DEFAULT '{}',
is_enabled INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (extension_id, user_id)
);

View File

@@ -0,0 +1,3 @@
-- Chat Switchboard — 012 (placeholder, SQLite)
-- Previously: extensions + extension_user_settings tables.
-- v0.28.7: Both tables removed. Unified package registry in 016.

View File

@@ -0,0 +1,43 @@
-- Chat Switchboard — 016 Packages (SQLite)
-- Unified package registry. Replaces surface_registry + extensions.
-- v0.28.7
CREATE TABLE IF NOT EXISTS packages (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'surface'
CHECK (type IN ('surface', 'extension', 'full')),
version TEXT NOT NULL DEFAULT '0.0.0',
description TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
tier TEXT NOT NULL DEFAULT 'browser'
CHECK (tier IN ('browser', 'starlark', 'sidecar')),
is_system INTEGER NOT NULL DEFAULT 0,
scope TEXT NOT NULL DEFAULT 'global'
CHECK (scope IN ('global', 'team', 'personal')),
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
installed_by TEXT REFERENCES users(id) ON DELETE SET NULL,
manifest TEXT NOT NULL DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'core'
CHECK (source IN ('core', 'builtin', 'extension')),
installed_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_packages_type ON packages(type);
CREATE INDEX IF NOT EXISTS idx_packages_enabled ON packages(enabled);
CREATE INDEX IF NOT EXISTS idx_packages_team ON packages(team_id);
CREATE INDEX IF NOT EXISTS idx_packages_source ON packages(source);
-- Per-user overrides for packages (enable/disable, custom config).
-- Lives in 016 (not 012) because of FK dependency on packages table.
CREATE TABLE IF NOT EXISTS package_user_settings (
package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
settings TEXT NOT NULL DEFAULT '{}',
is_enabled INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (package_id, user_id)
);

View File

@@ -1,12 +0,0 @@
-- Chat Switchboard — 016 Surfaces (SQLite)
-- Consolidated v0.28.0: was missing from SQLite entirely
CREATE TABLE IF NOT EXISTS surface_registry (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
manifest TEXT NOT NULL DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'core',
installed_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View File

@@ -325,12 +325,11 @@ func TruncateAll(t *testing.T) {
"refresh_tokens",
"user_presence",
"users",
// Config & extensions
// Config & packages
"platform_policies",
"global_settings",
"extension_user_settings",
"extensions",
"surface_registry",
"package_user_settings",
"packages",
"oidc_auth_state",
}

View File

@@ -899,10 +899,10 @@ func (h *CompletionHandler) ListTools(c *gin.Context) {
// Browser extension tools
if h.hub != nil && h.hub.IsConnected(userID) {
exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID)
pkgs, err := h.stores.Packages.ListForUser(c.Request.Context(), userID)
if err == nil {
for _, ext := range exts {
if ext.Tier != "browser" {
for _, pkg := range pkgs {
if pkg.Tier != "browser" {
continue
}
var manifest struct {
@@ -912,7 +912,7 @@ func (h *CompletionHandler) ListTools(c *gin.Context) {
Description string `json:"description"`
} `json:"tools"`
}
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
if err := json.Unmarshal(marshalManifest(pkg.Manifest), &manifest); err != nil {
continue
}
for _, t := range manifest.Tools {

View File

@@ -145,9 +145,6 @@ func TestExtension_AdminCRUDLifecycle(t *testing.T) {
if extID == "" {
t.Fatal("extension should have an id")
}
if ext["ext_id"].(string) != "test-ext" {
t.Fatalf("ext_id mismatch: got %q", ext["ext_id"])
}
if ext["version"].(string) != "1.0.0" {
t.Fatalf("version mismatch: got %q", ext["version"])
}
@@ -178,8 +175,8 @@ func TestExtension_AdminCRUDLifecycle(t *testing.T) {
var updated map[string]interface{}
decode(w, &updated)
updatedData := updated["data"].(map[string]interface{})
if updatedData["name"].(string) != "Updated Extension" {
t.Fatalf("name not updated: got %q", updatedData["name"])
if updatedData["title"].(string) != "Updated Extension" {
t.Fatalf("name not updated: got %q", updatedData["title"])
}
if updatedData["version"].(string) != "2.0.0" {
t.Fatalf("version not updated: got %q", updatedData["version"])
@@ -687,8 +684,8 @@ func TestExtension_PartialUpdate(t *testing.T) {
var resp map[string]interface{}
decode(w, &resp)
data := resp["data"].(map[string]interface{})
if data["name"].(string) != "New Name" {
t.Fatalf("name not updated: got %q", data["name"])
if data["title"].(string) != "New Name" {
t.Fatalf("name not updated: got %q", data["title"])
}
if data["version"].(string) != "1.0.0" {
t.Fatalf("version should be unchanged: got %q", data["version"])
@@ -721,15 +718,15 @@ func TestExtension_StoreListEnabled(t *testing.T) {
// ListEnabled should return only the enabled one
ctx := context.Background()
exts, err := h.stores.Extensions.ListEnabled(ctx)
pkgs, err := h.stores.Packages.ListEnabledByType(ctx, "extension")
if err != nil {
t.Fatalf("ListEnabled: %v", err)
}
if len(exts) != 1 {
t.Fatalf("expected 1 enabled, got %d", len(exts))
if len(pkgs) != 1 {
t.Fatalf("expected 1 enabled, got %d", len(pkgs))
}
if exts[0].ExtID != "enabled-ext" {
t.Fatalf("wrong extension: got %q", exts[0].ExtID)
if pkgs[0].ID != "enabled-ext" {
t.Fatalf("wrong extension: got %q", pkgs[0].ID)
}
}
@@ -742,7 +739,7 @@ func TestExtension_StoreGetUserSettings(t *testing.T) {
ctx := context.Background()
// No settings yet → sql.ErrNoRows
_, err := h.stores.Extensions.GetUserSettings(ctx, extID, h.userID)
_, err := h.stores.Packages.GetUserSettings(ctx, extID, h.userID)
if err != sql.ErrNoRows {
t.Fatalf("expected ErrNoRows, got %v", err)
}
@@ -757,15 +754,15 @@ func TestExtension_StoreGetUserSettings(t *testing.T) {
}
// Now GetUserSettings should return them
eus, err := h.stores.Extensions.GetUserSettings(ctx, extID, h.userID)
pus, err := h.stores.Packages.GetUserSettings(ctx, extID, h.userID)
if err != nil {
t.Fatalf("GetUserSettings: %v", err)
}
if !eus.IsEnabled {
if !pus.IsEnabled {
t.Fatal("should be enabled")
}
var settings map[string]interface{}
json.Unmarshal(eus.Settings, &settings)
json.Unmarshal(pus.Settings, &settings)
if settings["key"] != "value" {
t.Fatalf("settings mismatch: %v", settings)
}
@@ -788,19 +785,19 @@ func TestExtension_StoreDeleteUserSettings(t *testing.T) {
}
// Verify present
_, err := h.stores.Extensions.GetUserSettings(ctx, extID, h.userID)
_, err := h.stores.Packages.GetUserSettings(ctx, extID, h.userID)
if err != nil {
t.Fatalf("should exist: %v", err)
}
// Delete via store
err = h.stores.Extensions.DeleteUserSettings(ctx, extID, h.userID)
err = h.stores.Packages.DeleteUserSettings(ctx, extID, h.userID)
if err != nil {
t.Fatalf("DeleteUserSettings: %v", err)
}
// Verify gone
_, err = h.stores.Extensions.GetUserSettings(ctx, extID, h.userID)
_, err = h.stores.Packages.GetUserSettings(ctx, extID, h.userID)
if err != sql.ErrNoRows {
t.Fatalf("should be gone, got %v", err)
}
@@ -845,29 +842,27 @@ func TestSeed_NewExtension(t *testing.T) {
},
})
SeedBuiltinExtensions(h.stores, dir)
SeedBuiltinPackages(h.stores, dir)
// Should be created as system extension
ext, err := h.stores.Extensions.GetByExtID(ctx, "test-seeded")
pkg, err := h.stores.Packages.Get(ctx, "test-seeded")
if err != nil {
t.Fatalf("seeded extension not found: %v", err)
}
if ext.Name != "Seeded Extension" {
t.Fatalf("name: got %q", ext.Name)
if pkg.Title != "Seeded Extension" {
t.Fatalf("name: got %q", pkg.Title)
}
if ext.Version != "1.0.0" {
t.Fatalf("version: got %q", ext.Version)
if pkg.Version != "1.0.0" {
t.Fatalf("version: got %q", pkg.Version)
}
if !ext.IsSystem {
if !pkg.IsSystem {
t.Fatal("should be system extension")
}
if !ext.IsEnabled {
if !pkg.Enabled {
t.Fatal("should be enabled")
}
// Verify _script is inlined in manifest
var manifest map[string]json.RawMessage
json.Unmarshal(ext.Manifest, &manifest)
if _, ok := manifest["_script"]; !ok {
if _, ok := pkg.Manifest["_script"]; !ok {
t.Fatal("manifest should contain _script")
}
}
@@ -884,17 +879,17 @@ func TestSeed_SameVersionSkips(t *testing.T) {
})
// Seed twice
SeedBuiltinExtensions(h.stores, dir)
SeedBuiltinExtensions(h.stores, dir)
SeedBuiltinPackages(h.stores, dir)
SeedBuiltinPackages(h.stores, dir)
// Should still have exactly one
exts, err := h.stores.Extensions.ListAll(ctx)
pkgs, err := h.stores.Packages.List(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
count := 0
for _, e := range exts {
if e.ExtID == "skip-ext" {
for _, p := range pkgs {
if p.ID == "skip-ext" {
count++
}
}
@@ -914,11 +909,11 @@ func TestSeed_VersionChangeUpdates(t *testing.T) {
script: `console.log("v1");`,
},
})
SeedBuiltinExtensions(h.stores, dir1)
SeedBuiltinPackages(h.stores, dir1)
ext, _ := h.stores.Extensions.GetByExtID(ctx, "versioned-ext")
if ext.Version != "1.0.0" {
t.Fatalf("v1 version: got %q", ext.Version)
pkg, _ := h.stores.Packages.Get(ctx, "versioned-ext")
if pkg.Version != "1.0.0" {
t.Fatalf("v1 version: got %q", pkg.Version)
}
// Seed v2 (different version triggers update)
@@ -928,14 +923,14 @@ func TestSeed_VersionChangeUpdates(t *testing.T) {
script: `console.log("v2");`,
},
})
SeedBuiltinExtensions(h.stores, dir2)
SeedBuiltinPackages(h.stores, dir2)
ext, _ = h.stores.Extensions.GetByExtID(ctx, "versioned-ext")
if ext.Version != "2.0.0" {
t.Fatalf("v2 version: got %q", ext.Version)
pkg, _ = h.stores.Packages.Get(ctx, "versioned-ext")
if pkg.Version != "2.0.0" {
t.Fatalf("v2 version: got %q", pkg.Version)
}
if ext.Name != "V2 Name" {
t.Fatalf("v2 name: got %q", ext.Name)
if pkg.Title != "V2 Name" {
t.Fatalf("v2 name: got %q", pkg.Title)
}
}
@@ -948,14 +943,14 @@ func TestSeed_MissingManifestSkips(t *testing.T) {
"no-manifest": {script: `console.log("orphan");`},
})
SeedBuiltinExtensions(h.stores, dir)
SeedBuiltinPackages(h.stores, dir)
exts, err := h.stores.Extensions.ListAll(ctx)
pkgs, err := h.stores.Packages.List(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(exts) != 0 {
t.Fatalf("should skip dir without manifest, got %d extensions", len(exts))
if len(pkgs) != 0 {
t.Fatalf("should skip dir without manifest, got %d extensions", len(pkgs))
}
}
@@ -967,11 +962,11 @@ func TestSeed_InvalidManifestSkips(t *testing.T) {
"bad-manifest": {manifest: `{not valid json`, script: `console.log("bad");`},
})
SeedBuiltinExtensions(h.stores, dir)
SeedBuiltinPackages(h.stores, dir)
exts, _ := h.stores.Extensions.ListAll(ctx)
if len(exts) != 0 {
t.Fatalf("should skip invalid manifest, got %d extensions", len(exts))
pkgs, _ := h.stores.Packages.List(ctx)
if len(pkgs) != 0 {
t.Fatalf("should skip invalid manifest, got %d extensions", len(pkgs))
}
}
@@ -984,11 +979,11 @@ func TestSeed_MissingIDSkips(t *testing.T) {
"no-id": {manifest: `{"name":"No ID Extension","version":"1.0.0"}`, script: `console.log("no id");`},
})
SeedBuiltinExtensions(h.stores, dir)
SeedBuiltinPackages(h.stores, dir)
exts, _ := h.stores.Extensions.ListAll(ctx)
if len(exts) != 0 {
t.Fatalf("should skip manifest without id, got %d extensions", len(exts))
pkgs, _ := h.stores.Packages.List(ctx)
if len(pkgs) != 0 {
t.Fatalf("should skip manifest without id, got %d extensions", len(pkgs))
}
}
@@ -996,11 +991,11 @@ func TestSeed_NonexistentDirectorySkips(t *testing.T) {
h := setupExtensionHarness(t)
// Should not panic on missing directory
SeedBuiltinExtensions(h.stores, "/nonexistent/path/extensions")
SeedBuiltinPackages(h.stores, "/nonexistent/path/extensions")
exts, _ := h.stores.Extensions.ListAll(context.Background())
if len(exts) != 0 {
t.Fatalf("should seed nothing from missing dir, got %d", len(exts))
pkgs, _ := h.stores.Packages.List(context.Background())
if len(pkgs) != 0 {
t.Fatalf("should seed nothing from missing dir, got %d", len(pkgs))
}
}
@@ -1013,16 +1008,14 @@ func TestSeed_ScriptOptional(t *testing.T) {
"manifest-only": {manifest: `{"id":"manifest-only","name":"Manifest Only","version":"1.0.0"}`},
})
SeedBuiltinExtensions(h.stores, dir)
SeedBuiltinPackages(h.stores, dir)
ext, err := h.stores.Extensions.GetByExtID(ctx, "manifest-only")
pkg, err := h.stores.Packages.Get(ctx, "manifest-only")
if err != nil {
t.Fatalf("should create manifest-only ext: %v", err)
}
// Manifest should NOT contain _script
var manifest map[string]json.RawMessage
json.Unmarshal(ext.Manifest, &manifest)
if _, ok := manifest["_script"]; ok {
if _, ok := pkg.Manifest["_script"]; ok {
t.Fatal("manifest should not contain _script when no script.js exists")
}
}

View File

@@ -13,6 +13,7 @@ import (
)
// ExtensionHandler serves extension management endpoints.
// v0.28.7: Backed by PackageStore (packages table) instead of ExtensionStore.
type ExtensionHandler struct {
stores store.Stores
}
@@ -35,45 +36,41 @@ var validTiers = map[string]bool{
// GET /api/v1/extensions
func (h *ExtensionHandler) ListUserExtensions(c *gin.Context) {
userID := c.GetString("user_id")
tier := c.Query("tier") // optional filter
tier := c.Query("tier")
exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID)
pkgs, err := h.stores.Packages.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(500, gin.H{"error": "failed to list extensions"})
return
}
if tier != "" {
filtered := make([]models.UserExtension, 0)
for _, e := range exts {
if e.Tier == tier {
filtered = append(filtered, e)
filtered := make([]store.UserPackage, 0)
for _, p := range pkgs {
if p.Tier == tier {
filtered = append(filtered, p)
}
}
exts = filtered
pkgs = filtered
}
c.JSON(200, gin.H{"data": exts})
c.JSON(200, gin.H{"data": pkgs})
}
// UpdateUserExtensionSettings saves per-user settings for an extension.
// POST /api/v1/extensions/:id/settings
func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
userID := c.GetString("user_id")
extID := c.Param("id")
pkgID := c.Param("id")
// Verify extension exists
ext, err := h.stores.Extensions.GetByID(c.Request.Context(), extID)
if err == sql.ErrNoRows {
// Verify package exists
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
if err != nil || pkg == nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "failed to fetch extension"})
return
}
// System extensions can't be disabled by users
// System packages can't be disabled by users
var body struct {
Settings json.RawMessage `json:"settings"`
IsEnabled *bool `json:"is_enabled"`
@@ -85,7 +82,7 @@ func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
enabled := true
if body.IsEnabled != nil {
if ext.IsSystem && !*body.IsEnabled {
if pkg.IsSystem && !*body.IsEnabled {
c.JSON(403, gin.H{"error": "system extensions cannot be disabled"})
return
}
@@ -97,13 +94,13 @@ func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
settings = body.Settings
}
eus := &models.ExtensionUserSettings{
ExtensionID: extID,
UserID: userID,
Settings: settings,
IsEnabled: enabled,
pus := &store.PackageUserSettings{
PackageID: pkgID,
UserID: userID,
Settings: settings,
IsEnabled: enabled,
}
if err := h.stores.Extensions.SetUserSettings(c.Request.Context(), eus); err != nil {
if err := h.stores.Packages.SetUserSettings(c.Request.Context(), pus); err != nil {
c.JSON(500, gin.H{"error": "failed to save settings"})
return
}
@@ -113,19 +110,28 @@ func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
// ── Admin endpoints ─────────────────────────────
// AdminListExtensions returns all extensions (enabled and disabled).
// AdminListExtensions returns all extension-type packages.
// GET /api/v1/admin/extensions
func (h *ExtensionHandler) AdminListExtensions(c *gin.Context) {
exts, err := h.stores.Extensions.ListAll(c.Request.Context())
pkgs, err := h.stores.Packages.ListByType(c.Request.Context(), "extension")
if err != nil {
c.JSON(500, gin.H{"error": "failed to list extensions"})
return
}
c.JSON(200, gin.H{"data": exts})
// Also include 'full' type packages
fullPkgs, err := h.stores.Packages.ListByType(c.Request.Context(), "full")
if err == nil {
pkgs = append(pkgs, fullPkgs...)
}
if pkgs == nil {
pkgs = []store.PackageRegistration{}
}
c.JSON(200, gin.H{"data": pkgs})
}
// AdminInstallExtension installs an extension from a manifest.
// AdminInstallExtension installs an extension from a JSON body (legacy path).
// POST /api/v1/admin/extensions
// New installs should use POST /admin/packages/install with a .pkg archive.
func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
userID := c.GetString("user_id")
@@ -162,32 +168,36 @@ func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
return
}
// Check for duplicate ext_id
existing, err := h.stores.Extensions.GetByExtID(c.Request.Context(), body.ExtID)
if err != nil && err != sql.ErrNoRows {
c.JSON(500, gin.H{"error": "failed to check existing extension"})
return
}
// Check for duplicate (ext_id is now the package ID)
existing, _ := h.stores.Packages.Get(c.Request.Context(), body.ExtID)
if existing != nil {
c.JSON(409, gin.H{"error": "extension with ext_id '" + body.ExtID + "' already installed"})
return
}
ext := &models.Extension{
ExtID: body.ExtID,
Name: body.Name,
// Parse manifest into map
var manifestMap map[string]any
if err := json.Unmarshal(body.Manifest, &manifestMap); err != nil {
manifestMap = map[string]any{}
}
pkg := &store.PackageRegistration{
ID: body.ExtID,
Title: body.Name,
Type: "extension",
Version: body.Version,
Tier: body.Tier,
Description: body.Description,
Author: body.Author,
Manifest: body.Manifest,
Tier: body.Tier,
IsSystem: body.IsSystem,
IsEnabled: body.IsEnabled,
Scope: models.ScopeGlobal,
Scope: "global",
Manifest: manifestMap,
Enabled: body.IsEnabled,
Source: "extension",
InstalledBy: &userID,
}
if err := h.stores.Extensions.Create(c.Request.Context(), ext); err != nil {
if err := h.stores.Packages.Create(c.Request.Context(), pkg); err != nil {
if database.IsUniqueViolation(err) {
c.JSON(409, gin.H{"error": "extension with ext_id '" + body.ExtID + "' already installed"})
return
@@ -196,23 +206,19 @@ func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
return
}
c.JSON(201, gin.H{"data": ext})
c.JSON(201, gin.H{"data": pkg})
}
// AdminUpdateExtension updates an extension's config.
// AdminUpdateExtension updates an extension-type package.
// PUT /api/v1/admin/extensions/:id
func (h *ExtensionHandler) AdminUpdateExtension(c *gin.Context) {
id := c.Param("id")
ext, err := h.stores.Extensions.GetByID(c.Request.Context(), id)
if err == sql.ErrNoRows {
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
if err != nil || pkg == nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "failed to fetch extension"})
return
}
var body struct {
Name *string `json:"name"`
@@ -229,62 +235,58 @@ func (h *ExtensionHandler) AdminUpdateExtension(c *gin.Context) {
}
if body.Name != nil {
ext.Name = *body.Name
pkg.Title = *body.Name
}
if body.Version != nil {
ext.Version = *body.Version
pkg.Version = *body.Version
}
if body.Description != nil {
ext.Description = *body.Description
pkg.Description = *body.Description
}
if body.Author != nil {
ext.Author = *body.Author
pkg.Author = *body.Author
}
if body.IsSystem != nil {
ext.IsSystem = *body.IsSystem
pkg.IsSystem = *body.IsSystem
}
if body.IsEnabled != nil {
ext.IsEnabled = *body.IsEnabled
pkg.Enabled = *body.IsEnabled
}
if body.Manifest != nil {
ext.Manifest = *body.Manifest
var manifestMap map[string]any
if err := json.Unmarshal(*body.Manifest, &manifestMap); err == nil {
pkg.Manifest = manifestMap
}
}
if err := h.stores.Extensions.Update(c.Request.Context(), id, ext); err != nil {
if err := h.stores.Packages.Update(c.Request.Context(), id, pkg); err != nil {
c.JSON(500, gin.H{"error": "failed to update extension"})
return
}
c.JSON(200, gin.H{"data": ext})
c.JSON(200, gin.H{"data": pkg})
}
// ServeExtensionAsset serves browser extension JS files.
// GET /api/v1/extensions/:id/assets/*path
//
// For the MVP, scripts are stored inline in manifest._script.
// The :id param is the ext_id (e.g. "mermaid-renderer"), not the UUID.
func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) {
extID := c.Param("id")
// path := c.Param("path") // reserved for future multi-file support
pkgID := c.Param("id")
ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID)
if err == sql.ErrNoRows {
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
if err != nil || pkg == nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "failed to fetch extension"})
return
}
if !ext.IsEnabled {
if !pkg.Enabled {
c.JSON(404, gin.H{"error": "extension not enabled"})
return
}
// Extract inline script from manifest
manifestBytes := marshalManifest(pkg.Manifest)
var manifest map[string]json.RawMessage
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
c.JSON(500, gin.H{"error": "invalid manifest"})
return
}
@@ -295,7 +297,6 @@ func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) {
return
}
// _script is a JSON string — unquote it
var script string
if err := json.Unmarshal(scriptRaw, &script); err != nil {
c.JSON(500, gin.H{"error": "invalid script in manifest"})
@@ -310,43 +311,39 @@ func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) {
// GetExtensionManifest returns the manifest for a specific extension.
// GET /api/v1/extensions/:id/manifest
func (h *ExtensionHandler) GetExtensionManifest(c *gin.Context) {
extID := c.Param("id")
pkgID := c.Param("id")
ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID)
if err == sql.ErrNoRows {
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
if err != nil || pkg == nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "failed to fetch extension"})
return
}
c.JSON(200, gin.H{"data": ext.Manifest})
c.JSON(200, gin.H{"data": pkg.Manifest})
}
// ListBrowserToolSchemas returns tool schemas from all enabled browser extensions.
// Used by the completion handler to include browser tools in LLM requests.
// GET /api/v1/extensions/tools
func (h *ExtensionHandler) ListBrowserToolSchemas(c *gin.Context) {
userID := c.GetString("user_id")
exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID)
pkgs, err := h.stores.Packages.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(500, gin.H{"error": "failed to list extensions"})
return
}
var toolSchemas []json.RawMessage
for _, ext := range exts {
if ext.Tier != "browser" {
for _, pkg := range pkgs {
if pkg.Tier != "browser" {
continue
}
manifestBytes := marshalManifest(pkg.Manifest)
var manifest struct {
Tools []json.RawMessage `json:"tools"`
}
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
log.Printf("[extensions] failed to parse manifest for %s: %v", ext.ExtID, err)
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
log.Printf("[extensions] failed to parse manifest for %s: %v", pkg.ID, err)
continue
}
toolSchemas = append(toolSchemas, manifest.Tools...)
@@ -358,25 +355,39 @@ func (h *ExtensionHandler) ListBrowserToolSchemas(c *gin.Context) {
c.JSON(200, gin.H{"data": toolSchemas})
}
// AdminUninstallExtension removes an extension.
// AdminUninstallExtension removes an extension-type package.
// DELETE /api/v1/admin/extensions/:id
func (h *ExtensionHandler) AdminUninstallExtension(c *gin.Context) {
id := c.Param("id")
_, err := h.stores.Extensions.GetByID(c.Request.Context(), id)
if err == sql.ErrNoRows {
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
if err != nil || pkg == nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "failed to fetch extension"})
return
}
if err := h.stores.Extensions.Delete(c.Request.Context(), id); err != nil {
if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil {
if err == sql.ErrNoRows {
c.JSON(400, gin.H{"error": "core packages cannot be deleted"})
return
}
c.JSON(500, gin.H{"error": "failed to uninstall extension"})
return
}
c.JSON(200, gin.H{"ok": true})
}
// marshalManifest converts map[string]any → []byte for struct unmarshaling.
// PackageRegistration.Manifest is map[string]any (from JSONB scan), but
// several callsites need to unmarshal into typed structs.
func marshalManifest(m map[string]any) []byte {
if m == nil {
return []byte("{}")
}
b, err := json.Marshal(m)
if err != nil {
return []byte("{}")
}
return b
}

433
server/handlers/packages.go Normal file
View File

@@ -0,0 +1,433 @@
package handlers
import (
"archive/zip"
"encoding/json"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// validPackageID matches lowercase alphanumeric slugs with optional hyphens.
// No leading/trailing hyphens, 2-64 chars.
var validPackageID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`)
// PackageHandler manages unified package lifecycle (admin-only).
// Replaces SurfaceHandler (v0.28.7).
type PackageHandler struct {
stores store.Stores
packagesDir string // e.g. /data/packages — where archives are extracted
}
func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
dir := ""
if len(packagesDir) > 0 {
dir = packagesDir[0]
}
return &PackageHandler{stores: s, packagesDir: dir}
}
// ListPackages returns all registered packages.
// GET /api/v1/admin/packages
// GET /api/v1/admin/surfaces (alias)
func (h *PackageHandler) ListPackages(c *gin.Context) {
typeFilter := c.Query("type")
var pkgs []store.PackageRegistration
var err error
if typeFilter != "" {
pkgs, err = h.stores.Packages.ListByType(c.Request.Context(), typeFilter)
} else {
pkgs, err = h.stores.Packages.List(c.Request.Context())
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"})
return
}
if pkgs == nil {
pkgs = []store.PackageRegistration{}
}
c.JSON(http.StatusOK, gin.H{"data": pkgs})
}
// GetPackage returns a single package by ID.
// GET /api/v1/admin/packages/:id
// GET /api/v1/admin/surfaces/:id (alias)
func (h *PackageHandler) GetPackage(c *gin.Context) {
id := c.Param("id")
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
if err != nil || pkg == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
return
}
c.JSON(http.StatusOK, pkg)
}
// EnablePackage enables a package.
// PUT /api/v1/admin/packages/:id/enable
// PUT /api/v1/admin/surfaces/:id/enable (alias)
func (h *PackageHandler) EnablePackage(c *gin.Context) {
id := c.Param("id")
if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, true); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
return
}
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": true})
}
// DisablePackage disables a package. Chat and Admin cannot be disabled.
// PUT /api/v1/admin/packages/:id/disable
// PUT /api/v1/admin/surfaces/:id/disable (alias)
func (h *PackageHandler) DisablePackage(c *gin.Context) {
id := c.Param("id")
if id == "chat" || id == "admin" {
c.JSON(http.StatusBadRequest, gin.H{"error": id + " cannot be disabled"})
return
}
if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, false); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
return
}
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": false})
}
// DeletePackage uninstalls a package. Core packages cannot be deleted.
// DELETE /api/v1/admin/packages/:id
// DELETE /api/v1/admin/surfaces/:id (alias)
func (h *PackageHandler) DeletePackage(c *gin.Context) {
id := c.Param("id")
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
if err != nil || pkg == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
return
}
if pkg.Source == "core" {
c.JSON(http.StatusBadRequest, gin.H{"error": "core packages cannot be uninstalled"})
return
}
if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete package"})
return
}
// Clean up extracted static assets
if h.packagesDir != "" {
assetDir := filepath.Join(h.packagesDir, id)
if err := os.RemoveAll(assetDir); err != nil {
log.Printf("⚠️ Failed to clean up package assets at %s: %v", assetDir, err)
}
}
c.JSON(http.StatusOK, gin.H{"id": id, "deleted": true})
}
// InstallPackage uploads and installs a .pkg/.surface archive.
// POST /api/v1/admin/packages/install
// POST /api/v1/admin/surfaces/install (alias)
func (h *PackageHandler) InstallPackage(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
return
}
defer file.Close()
// Validate extension
validExt := strings.HasSuffix(header.Filename, ".pkg") ||
strings.HasSuffix(header.Filename, ".surface") ||
strings.HasSuffix(header.Filename, ".zip")
if !validExt {
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"})
return
}
// Size limit: 50MB
if header.Size > 50*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
return
}
// Read into temp file for zip processing
tmpFile, err := os.CreateTemp("", "package-*.zip")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
if _, err := io.Copy(tmpFile, file); err != nil {
tmpFile.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
return
}
tmpFile.Close()
// Open as zip
zr, err := zip.OpenReader(tmpPath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
return
}
defer zr.Close()
// Find and parse manifest.json
var manifest map[string]any
for _, f := range zr.File {
name := filepath.Base(f.Name)
if name == "manifest.json" && !f.FileInfo().IsDir() {
rc, err := f.Open()
if err != nil {
continue
}
data, err := io.ReadAll(rc)
rc.Close()
if err != nil {
continue
}
if err := json.Unmarshal(data, &manifest); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()})
return
}
break
}
}
if manifest == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"})
return
}
// Validate required fields
pkgID, _ := manifest["id"].(string)
title, _ := manifest["title"].(string)
if pkgID == "" || title == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest must have 'id' and 'title'"})
return
}
// Validate package ID is a safe slug
if !validPackageID.MatchString(pkgID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "package id must be a lowercase slug (a-z, 0-9, hyphens, 2-64 chars)"})
return
}
// Determine type (default: surface for backward compat)
pkgType, _ := manifest["type"].(string)
if pkgType == "" {
pkgType = "surface"
}
if pkgType != "surface" && pkgType != "extension" && pkgType != "full" {
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', or 'full'"})
return
}
// Type-specific validation
hasRoute := manifest["route"] != nil || manifest["routes"] != nil
hasTools := manifest["tools"] != nil
hasPipes := manifest["pipes"] != nil
hasHooks := manifest["hooks"] != nil
hasExtBehavior := hasTools || hasPipes || hasHooks
switch pkgType {
case "surface":
// route is optional for surfaces (core surfaces don't always have it in manifest)
case "extension":
if !hasExtBehavior {
c.JSON(http.StatusBadRequest, gin.H{"error": "extension packages require at least one of: tools, pipes, hooks"})
return
}
if hasRoute {
c.JSON(http.StatusBadRequest, gin.H{"error": "extension packages cannot have a route — use type 'full' for both"})
return
}
case "full":
if !hasRoute {
c.JSON(http.StatusBadRequest, gin.H{"error": "full packages require a route"})
return
}
if !hasExtBehavior {
c.JSON(http.StatusBadRequest, gin.H{"error": "full packages require at least one of: tools, pipes, hooks"})
return
}
}
// Check for conflicts with core packages
existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID)
if existing != nil && existing.Source == "core" {
c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core package: " + pkgID})
return
}
// Extract static assets (js/, css/, assets/) to packagesDir/{id}/
if h.packagesDir != "" {
destDir := filepath.Join(h.packagesDir, pkgID)
os.MkdirAll(destDir, 0755)
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
relPath := extractableRelPath(f.Name)
if relPath == "" {
continue
}
destPath := filepath.Join(destDir, relPath)
// Security: prevent path traversal
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
continue
}
os.MkdirAll(filepath.Dir(destPath), 0755)
rc, err := f.Open()
if err != nil {
continue
}
out, err := os.Create(destPath)
if err != nil {
rc.Close()
continue
}
io.Copy(out, rc)
out.Close()
rc.Close()
}
log.Printf("[packages] Extracted %s to %s", pkgID, destDir)
}
// Extract manifest fields for DB columns
version, _ := manifest["version"].(string)
if version == "" {
version = "0.0.0"
}
description, _ := manifest["description"].(string)
author, _ := manifest["author"].(string)
tier, _ := manifest["tier"].(string)
if tier == "" {
tier = "browser"
}
userID := c.GetString("user_id")
// Register in database via Seed (upsert — handles re-install)
if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, title, "extension", manifest); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register package"})
return
}
// Read back to get the preserved enabled state (Seed doesn't override it)
existing, _ = h.stores.Packages.Get(c.Request.Context(), pkgID)
preservedEnabled := true
if existing != nil {
preservedEnabled = existing.Enabled
}
// Update fields that Seed doesn't set (type, version, author, etc.)
pkg := &store.PackageRegistration{
Title: title,
Type: pkgType,
Version: version,
Description: description,
Author: author,
Tier: tier,
IsSystem: false,
Enabled: preservedEnabled,
Manifest: manifest,
}
if userID != "" {
pkg.InstalledBy = &userID
}
if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil {
log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err)
}
c.JSON(http.StatusOK, gin.H{
"id": pkgID,
"title": title,
"type": pkgType,
"version": version,
"source": "extension",
"enabled": preservedEnabled,
})
}
// extractableRelPath returns the relative path for a zip entry if it
// belongs to an extractable static directory (js/, css/, assets/).
// Returns "" if the file should be skipped.
func extractableRelPath(name string) string {
staticPrefixes := []string{"js/", "css/", "assets/"}
for _, p := range staticPrefixes {
if strings.HasPrefix(name, p) {
return name
}
}
idx := strings.Index(name, "/")
if idx <= 0 {
return ""
}
rest := name[idx+1:]
for _, p := range staticPrefixes {
if strings.HasPrefix(rest, p) {
return rest
}
}
return ""
}
// ListEnabledSurfaces returns enabled surface/full packages for nav rendering.
// GET /api/v1/surfaces
func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
pkgs, err := h.stores.Packages.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"})
return
}
type navSurface struct {
ID string `json:"id"`
Title string `json:"title"`
Route string `json:"route"`
}
var enabled []navSurface
for _, p := range pkgs {
if !p.Enabled {
continue
}
// Only surface and full types have routes
if p.Type != "surface" && p.Type != "full" {
continue
}
route, _ := p.Manifest["route"].(string)
enabled = append(enabled, navSurface{
ID: p.ID,
Title: p.Title,
Route: route,
})
}
if enabled == nil {
enabled = []navSurface{}
}
c.JSON(http.StatusOK, gin.H{"data": enabled})
}

View File

@@ -206,14 +206,14 @@ func BuildToolDefs(
}
// Append browser-defined tool schemas from extensions
if includeBrowser && stores.Extensions != nil {
exts, err := stores.Extensions.ListForUser(ctx, userID)
if includeBrowser && stores.Packages != nil {
pkgs, err := stores.Packages.ListForUser(ctx, userID)
if err != nil {
log.Printf("⚠️ Failed to load extensions for tools: %v", err)
return defs
}
for _, ext := range exts {
if ext.Tier != "browser" {
for _, pkg := range pkgs {
if pkg.Tier != "browser" {
continue
}
var manifest struct {
@@ -224,7 +224,7 @@ func BuildToolDefs(
Tier string `json:"tier"`
} `json:"tools"`
}
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
if err := json.Unmarshal(marshalManifest(pkg.Manifest), &manifest); err != nil {
continue
}
for _, t := range manifest.Tools {

View File

@@ -7,7 +7,6 @@ import (
"os"
"path/filepath"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
@@ -25,9 +24,9 @@ type builtinManifest struct {
Settings json.RawMessage `json:"settings"`
}
// SeedBuiltinExtensions scans a directory for builtin extension bundles
// SeedBuiltinPackages scans a directory for builtin extension bundles
// (each subdirectory contains manifest.json + script.js) and upserts them
// into the extensions table as system extensions.
// into the packages table as system packages.
//
// Layout:
//
@@ -36,10 +35,10 @@ type builtinManifest struct {
// manifest.json
// script.js
//
// Idempotent: skips extensions whose version matches what's already installed,
// Idempotent: skips packages whose version matches what's already installed,
// updates those with a newer version, creates new ones.
func SeedBuiltinExtensions(stores store.Stores, extensionsDir string) {
if stores.Extensions == nil {
func SeedBuiltinPackages(stores store.Stores, extensionsDir string) {
if stores.Packages == nil {
return
}
@@ -89,64 +88,92 @@ func SeedBuiltinExtensions(stores store.Stores, extensionsDir string) {
script = string(scriptData)
}
// Build the full manifest JSONB with _script inlined
// Build the full manifest with _script inlined
fullManifest := buildFullManifest(manifestData, script)
// Parse into map for Seed/Update
var manifestMap map[string]any
json.Unmarshal(fullManifest, &manifestMap)
// Check if already installed
existing, err := stores.Extensions.GetByExtID(ctx, manifest.ID)
if err == nil {
// Already exists — check version
existing, err := stores.Packages.Get(ctx, manifest.ID)
if err == nil && existing != nil {
// Resolve tier for fixup + update paths
tier := manifest.Tier
if tier == "" {
tier = "browser"
}
// Same version — skip, but fixup type/tier if wrong
// (v0.28.7 initial seed created builtins with type='surface'
// because Seed() uses schema DEFAULT and Update() didn't set type)
if existing.Version == manifest.Version {
if existing.Type != "extension" || existing.Tier != tier {
pkg := &store.PackageRegistration{
Title: existing.Title, Type: "extension", Version: existing.Version,
Description: existing.Description, Author: existing.Author,
Tier: tier, Manifest: existing.Manifest,
IsSystem: true, Enabled: existing.Enabled,
}
if err := stores.Packages.Update(ctx, manifest.ID, pkg); err != nil {
log.Printf("⚠ Failed to fixup builtin package %s: %v", manifest.ID, err)
}
}
skipped++
continue
}
// Version changed — update
existing.Name = manifest.Name
existing.Version = manifest.Version
existing.Description = manifest.Description
existing.Author = manifest.Author
existing.Manifest = fullManifest
existing.IsSystem = true
existing.IsEnabled = true
if err := stores.Extensions.Update(ctx, existing.ID, existing); err != nil {
log.Printf("⚠ Failed to update builtin extension %s: %v", manifest.ID, err)
pkg := &store.PackageRegistration{
Title: manifest.Name,
Type: "extension",
Version: manifest.Version,
Description: manifest.Description,
Author: manifest.Author,
Tier: tier,
Manifest: manifestMap,
IsSystem: true,
Enabled: true,
}
if err := stores.Packages.Update(ctx, manifest.ID, pkg); err != nil {
log.Printf("⚠ Failed to update builtin package %s: %v", manifest.ID, err)
continue
}
updated++
continue
}
// New extension — create
// New package — seed with full metadata
if err := stores.Packages.Seed(ctx, manifest.ID, manifest.Name, "builtin", manifestMap); err != nil {
log.Printf("⚠ Failed to seed builtin package %s: %v", manifest.ID, err)
continue
}
// Set extension-specific fields that Seed doesn't cover
tier := manifest.Tier
if tier == "" {
tier = models.ExtTierBrowser
tier = "browser"
}
ext := &models.Extension{
ExtID: manifest.ID,
Name: manifest.Name,
pkg := &store.PackageRegistration{
Title: manifest.Name,
Type: "extension",
Version: manifest.Version,
Tier: tier,
Description: manifest.Description,
Author: manifest.Author,
Manifest: fullManifest,
Tier: tier,
Manifest: manifestMap,
IsSystem: true,
IsEnabled: true,
Scope: "global",
Enabled: true,
}
if err := stores.Extensions.Create(ctx, ext); err != nil {
log.Printf("⚠ Failed to seed builtin extension %s: %v", manifest.ID, err)
continue
if err := stores.Packages.Update(ctx, manifest.ID, pkg); err != nil {
log.Printf("⚠ Failed to update builtin package metadata %s: %v", manifest.ID, err)
}
seeded++
}
if seeded+updated > 0 {
log.Printf(" 📦 Builtin extensions: %d seeded, %d updated, %d unchanged", seeded, updated, skipped)
log.Printf(" 📦 Builtin packages: %d seeded, %d updated, %d unchanged", seeded, updated, skipped)
} else if skipped > 0 {
log.Printf(" 📦 Builtin extensions: %d up to date", skipped)
log.Printf(" 📦 Builtin packages: %d up to date", skipped)
}
}
@@ -156,10 +183,8 @@ func buildFullManifest(rawManifest []byte, script string) json.RawMessage {
return json.RawMessage(rawManifest)
}
// Parse manifest as generic map, add _script, re-serialize
var m map[string]json.RawMessage
if err := json.Unmarshal(rawManifest, &m); err != nil {
// Fallback: just return raw manifest
return json.RawMessage(rawManifest)
}

View File

@@ -1,386 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"testing"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
)
// surfaceStore returns the appropriate SurfaceRegistryStore for the
// current test database backend.
func surfaceStore() store.SurfaceRegistryStore {
if database.IsSQLite() {
return sqlite.NewSurfaceRegistryStore()
}
return postgres.NewSurfaceRegistryStore()
}
// ── Store: Seed ─────────────────────────────
func TestSurfaceStore_Seed_Basic(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
manifest := map[string]any{"route": "/test", "scripts": []string{"app.js"}}
if err := s.Seed(ctx, "test-surface", "Test Surface", "core", manifest); err != nil {
t.Fatalf("Seed: %v", err)
}
sr, err := s.Get(ctx, "test-surface")
if err != nil || sr == nil {
t.Fatal("Get after Seed: surface should exist")
}
if sr.ID != "test-surface" {
t.Errorf("ID: got %q, want %q", sr.ID, "test-surface")
}
if sr.Title != "Test Surface" {
t.Errorf("Title: got %q, want %q", sr.Title, "Test Surface")
}
if sr.Source != "core" {
t.Errorf("Source: got %q, want %q", sr.Source, "core")
}
if !sr.Enabled {
t.Error("newly seeded surface should be enabled")
}
route, _ := sr.Manifest["route"].(string)
if route != "/test" {
t.Errorf("Manifest route: got %q, want %q", route, "/test")
}
}
func TestSurfaceStore_Seed_PreservesEnabledFlag(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
// Seed, then disable
s.Seed(ctx, "my-surface", "v1", "core", map[string]any{"route": "/v1"})
s.SetEnabled(ctx, "my-surface", false)
// Re-seed with updated title and manifest
s.Seed(ctx, "my-surface", "v2", "core", map[string]any{"route": "/v2"})
sr, _ := s.Get(ctx, "my-surface")
if sr == nil {
t.Fatal("surface should exist after re-seed")
}
if sr.Title != "v2" {
t.Errorf("Title should be updated: got %q, want %q", sr.Title, "v2")
}
if sr.Enabled {
t.Error("Enabled should be preserved as false across re-seed")
}
route, _ := sr.Manifest["route"].(string)
if route != "/v2" {
t.Errorf("Manifest should be updated: got %q, want %q", route, "/v2")
}
}
func TestSurfaceStore_Seed_NilManifest(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
// Seed with nil manifest — should not panic
err := s.Seed(ctx, "nil-manifest", "Nil Manifest", "core", nil)
if err != nil {
t.Fatalf("Seed with nil manifest: %v", err)
}
sr, _ := s.Get(ctx, "nil-manifest")
if sr == nil {
t.Fatal("surface should exist")
}
}
// ── Store: List ─────────────────────────────
func TestSurfaceStore_List_Empty(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
surfaces, err := s.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
// Should return nil or empty slice, not an error
if surfaces != nil && len(surfaces) != 0 {
t.Errorf("empty DB should return 0 surfaces, got %d", len(surfaces))
}
}
func TestSurfaceStore_List_Order(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
// Seed in reverse alphabetical order
s.Seed(ctx, "zebra", "Zebra", "extension", map[string]any{})
s.Seed(ctx, "alpha", "Alpha", "core", map[string]any{})
s.Seed(ctx, "beta", "Beta", "core", map[string]any{})
surfaces, err := s.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(surfaces) != 3 {
t.Fatalf("want 3 surfaces, got %d", len(surfaces))
}
// Ordered by source, then title: core first (Alpha, Beta), then extension (Zebra)
if surfaces[0].ID != "alpha" {
t.Errorf("first: got %q, want %q", surfaces[0].ID, "alpha")
}
if surfaces[1].ID != "beta" {
t.Errorf("second: got %q, want %q", surfaces[1].ID, "beta")
}
if surfaces[2].ID != "zebra" {
t.Errorf("third: got %q, want %q", surfaces[2].ID, "zebra")
}
}
func TestSurfaceStore_List_IncludesDisabled(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
s.Seed(ctx, "enabled-one", "Enabled", "core", map[string]any{})
s.Seed(ctx, "disabled-one", "Disabled", "extension", map[string]any{})
s.SetEnabled(ctx, "disabled-one", false)
surfaces, err := s.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(surfaces) != 2 {
t.Fatalf("List should return all surfaces including disabled, got %d", len(surfaces))
}
}
// ── Store: ListEnabled ──────────────────────
func TestSurfaceStore_ListEnabled(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
s.Seed(ctx, "chat", "Chat", "core", map[string]any{})
s.Seed(ctx, "editor", "Editor", "core", map[string]any{})
s.Seed(ctx, "my-ext", "My Ext", "extension", map[string]any{})
s.SetEnabled(ctx, "editor", false)
ids, err := s.ListEnabled(ctx)
if err != nil {
t.Fatalf("ListEnabled: %v", err)
}
if len(ids) != 2 {
t.Fatalf("want 2 enabled IDs, got %d: %v", len(ids), ids)
}
// Verify disabled one is excluded
for _, id := range ids {
if id == "editor" {
t.Error("disabled surface 'editor' should not appear in ListEnabled")
}
}
}
func TestSurfaceStore_ListEnabled_Empty(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
ids, err := s.ListEnabled(ctx)
if err != nil {
t.Fatalf("ListEnabled: %v", err)
}
if ids != nil && len(ids) != 0 {
t.Errorf("empty DB should return 0 enabled IDs, got %d", len(ids))
}
}
// ── Store: Get ──────────────────────────────
func TestSurfaceStore_Get_NotFound(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
sr, err := s.Get(ctx, "nonexistent")
if err != nil {
t.Errorf("Get nonexistent should return (nil, nil), got err: %v", err)
}
if sr != nil {
t.Error("Get nonexistent should return nil surface")
}
}
func TestSurfaceStore_Get_ManifestRoundTrip(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
manifest := map[string]any{
"route": "/s/complex",
"scripts": []any{"app.js", "vendor.js"},
"css": []any{"style.css"},
"nested": map[string]any{"key": "value"},
}
s.Seed(ctx, "complex", "Complex", "extension", manifest)
sr, err := s.Get(ctx, "complex")
if err != nil || sr == nil {
t.Fatal("Get should return the surface")
}
// Verify manifest round-trips correctly
got, _ := json.Marshal(sr.Manifest)
want, _ := json.Marshal(manifest)
if string(got) != string(want) {
t.Errorf("Manifest round-trip mismatch:\n got: %s\n want: %s", got, want)
}
}
// ── Store: SetEnabled ───────────────────────
func TestSurfaceStore_SetEnabled_NotFound(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
err := s.SetEnabled(ctx, "nonexistent", true)
if err == nil {
t.Error("SetEnabled on nonexistent surface should return error")
}
}
// ── Store: Delete ───────────────────────────
func TestSurfaceStore_Delete_ExtensionOnly(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
// Seed a core surface
s.Seed(ctx, "chat", "Chat", "core", map[string]any{})
// Try to delete it at the store level — should fail
err := s.Delete(ctx, "chat")
if err == nil {
t.Error("Delete of core surface should return error at store level")
}
// Verify still exists
sr, _ := s.Get(ctx, "chat")
if sr == nil {
t.Error("core surface should survive Delete attempt")
}
}
func TestSurfaceStore_Delete_Extension(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
s.Seed(ctx, "my-ext", "My Extension", "extension", map[string]any{})
err := s.Delete(ctx, "my-ext")
if err != nil {
t.Fatalf("Delete extension: %v", err)
}
sr, _ := s.Get(ctx, "my-ext")
if sr != nil {
t.Error("extension surface should be gone after Delete")
}
}
func TestSurfaceStore_Delete_NotFound(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
err := s.Delete(ctx, "nonexistent")
if err == nil {
t.Error("Delete nonexistent should return error")
}
}
// ── Handler + Store Integration: ListEnabled response shape ─────
// This test verifies the exact JSON contract that the frontend depends on.
func TestSurface_ListEnabled_ResponseContract(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
w := h.jsonReq("GET", "/api/v1/surfaces", h.userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d", w.Code)
}
// Parse as raw JSON to verify exact shape
var raw map[string]json.RawMessage
json.Unmarshal(w.Body.Bytes(), &raw)
surfacesJSON, ok := raw["surfaces"]
if !ok {
t.Fatal("response must have 'surfaces' key")
}
var surfaces []map[string]interface{}
json.Unmarshal(surfacesJSON, &surfaces)
if len(surfaces) != 1 {
t.Fatalf("want 1 surface, got %d", len(surfaces))
}
s := surfaces[0]
// Must have exactly id, title, route
expectedKeys := map[string]bool{"id": true, "title": true, "route": true}
for key := range s {
if !expectedKeys[key] {
t.Errorf("unexpected key in response: %q", key)
}
}
for key := range expectedKeys {
if _, ok := s[key]; !ok {
t.Errorf("missing expected key: %q", key)
}
}
}

View File

@@ -1,346 +0,0 @@
package handlers
import (
"archive/zip"
"encoding/json"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// validSurfaceID matches lowercase alphanumeric slugs with optional hyphens.
// No leading/trailing hyphens, 2-64 chars.
var validSurfaceID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`)
// SurfaceHandler manages surface lifecycle (admin-only).
type SurfaceHandler struct {
stores store.Stores
surfacesDir string // e.g. /data/surfaces — where archives are extracted
}
func NewSurfaceHandler(s store.Stores, surfacesDir ...string) *SurfaceHandler {
dir := ""
if len(surfacesDir) > 0 {
dir = surfacesDir[0]
}
return &SurfaceHandler{stores: s, surfacesDir: dir}
}
// ListSurfaces returns all registered surfaces with their enabled state.
// GET /api/v1/admin/surfaces
func (h *SurfaceHandler) ListSurfaces(c *gin.Context) {
surfaces, err := h.stores.Surfaces.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"})
return
}
if surfaces == nil {
surfaces = []store.SurfaceRegistration{}
}
c.JSON(http.StatusOK, gin.H{"surfaces": surfaces})
}
// GetSurface returns a single surface by ID.
// GET /api/v1/admin/surfaces/:id
func (h *SurfaceHandler) GetSurface(c *gin.Context) {
id := c.Param("id")
surface, err := h.stores.Surfaces.Get(c.Request.Context(), id)
if err != nil || surface == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
return
}
c.JSON(http.StatusOK, surface)
}
// EnableSurface enables a surface.
// PUT /api/v1/admin/surfaces/:id/enable
func (h *SurfaceHandler) EnableSurface(c *gin.Context) {
id := c.Param("id")
if err := h.stores.Surfaces.SetEnabled(c.Request.Context(), id, true); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
return
}
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": true})
}
// DisableSurface disables a surface. Chat cannot be disabled.
// PUT /api/v1/admin/surfaces/:id/disable
func (h *SurfaceHandler) DisableSurface(c *gin.Context) {
id := c.Param("id")
// Chat and Admin are required — cannot be disabled
if id == "chat" || id == "admin" {
c.JSON(http.StatusBadRequest, gin.H{"error": id + " surface cannot be disabled"})
return
}
if err := h.stores.Surfaces.SetEnabled(c.Request.Context(), id, false); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
return
}
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": false})
}
// DeleteSurface uninstalls an extension surface. Core surfaces cannot be deleted.
// DELETE /api/v1/admin/surfaces/:id
func (h *SurfaceHandler) DeleteSurface(c *gin.Context) {
id := c.Param("id")
// Check it's not a core surface
surface, err := h.stores.Surfaces.Get(c.Request.Context(), id)
if err != nil || surface == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
return
}
if surface.Source == "core" {
c.JSON(http.StatusBadRequest, gin.H{"error": "core surfaces cannot be uninstalled"})
return
}
if err := h.stores.Surfaces.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete surface"})
return
}
// Clean up extracted static assets for this surface
if h.surfacesDir != "" {
assetDir := filepath.Join(h.surfacesDir, id)
if err := os.RemoveAll(assetDir); err != nil {
log.Printf("⚠️ Failed to clean up surface assets at %s: %v", assetDir, err)
}
}
c.JSON(http.StatusOK, gin.H{"id": id, "deleted": true})
}
// InstallSurface uploads and installs a .surface archive.
// POST /api/v1/admin/surfaces/install (multipart: file)
func (h *SurfaceHandler) InstallSurface(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
return
}
defer file.Close()
// Validate extension
if !strings.HasSuffix(header.Filename, ".surface") && !strings.HasSuffix(header.Filename, ".zip") {
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .surface or .zip archive"})
return
}
// Size limit: 50MB
if header.Size > 50*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
return
}
// Read into temp file for zip processing
tmpFile, err := os.CreateTemp("", "surface-*.zip")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
if _, err := io.Copy(tmpFile, file); err != nil {
tmpFile.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
return
}
tmpFile.Close()
// Open as zip
zr, err := zip.OpenReader(tmpPath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
return
}
defer zr.Close()
// Find and parse manifest.json
var manifest map[string]any
for _, f := range zr.File {
name := filepath.Base(f.Name)
if name == "manifest.json" && !f.FileInfo().IsDir() {
rc, err := f.Open()
if err != nil {
continue
}
data, err := io.ReadAll(rc)
rc.Close()
if err != nil {
continue
}
if err := json.Unmarshal(data, &manifest); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()})
return
}
break
}
}
if manifest == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"})
return
}
// Validate required fields
surfaceID, _ := manifest["id"].(string)
title, _ := manifest["title"].(string)
if surfaceID == "" || title == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest must have 'id' and 'title'"})
return
}
// Validate surface ID is a safe slug — prevents path traversal in
// filesystem operations (filepath.Join(surfacesDir, surfaceID)).
if !validSurfaceID.MatchString(surfaceID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "surface id must be a lowercase slug (a-z, 0-9, hyphens, 2-64 chars)"})
return
}
// Check for conflicts with core surfaces
existing, _ := h.stores.Surfaces.Get(c.Request.Context(), surfaceID)
if existing != nil && existing.Source == "core" {
c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core surface: " + surfaceID})
return
}
// Extract static assets (js/, css/, assets/) to surfacesDir/{id}/
if h.surfacesDir != "" {
destDir := filepath.Join(h.surfacesDir, surfaceID)
os.MkdirAll(destDir, 0755)
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
// Resolve the relative path, stripping an optional single
// top-level directory prefix from the archive.
relPath := extractableRelPath(f.Name)
if relPath == "" {
continue // Not an extractable static file
}
destPath := filepath.Join(destDir, relPath)
// Security: prevent path traversal
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
continue
}
os.MkdirAll(filepath.Dir(destPath), 0755)
rc, err := f.Open()
if err != nil {
continue
}
out, err := os.Create(destPath)
if err != nil {
rc.Close()
continue
}
io.Copy(out, rc)
out.Close()
rc.Close()
}
log.Printf("[surfaces] Extracted %s to %s", surfaceID, destDir)
}
// Register in database
if err := h.stores.Surfaces.Seed(c.Request.Context(), surfaceID, title, "extension", manifest); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register surface"})
return
}
c.JSON(http.StatusOK, gin.H{
"id": surfaceID,
"title": title,
"source": "extension",
"enabled": true,
})
}
// extractableRelPath returns the relative path for a zip entry if it
// belongs to an extractable static directory (js/, css/, assets/).
// Returns "" if the file should be skipped.
//
// Handles two archive layouts:
//
// Prefixed: my-surface/js/app.js → js/app.js
// Flat: js/app.js → js/app.js
//
// Files not under js/, css/, or assets/ (e.g., manifest.json, script.js
// at root) are skipped.
func extractableRelPath(name string) string {
// Static directory prefixes we extract
staticPrefixes := []string{"js/", "css/", "assets/"}
// Check if already a relative static path (flat archive)
for _, p := range staticPrefixes {
if strings.HasPrefix(name, p) {
return name
}
}
// Check for a single directory prefix to strip
idx := strings.Index(name, "/")
if idx <= 0 {
return "" // No slash, or starts with slash — skip
}
rest := name[idx+1:]
for _, p := range staticPrefixes {
if strings.HasPrefix(rest, p) {
return rest
}
}
return "" // Not under a static directory
}
// ListEnabledSurfaces returns enabled surface IDs (non-admin, for nav).
// GET /api/v1/surfaces
func (h *SurfaceHandler) ListEnabledSurfaces(c *gin.Context) {
surfaces, err := h.stores.Surfaces.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"})
return
}
// Return only enabled surfaces with minimal info for nav
type navSurface struct {
ID string `json:"id"`
Title string `json:"title"`
Route string `json:"route"`
}
var enabled []navSurface
for _, s := range surfaces {
if !s.Enabled {
continue
}
route, _ := s.Manifest["route"].(string)
enabled = append(enabled, navSurface{
ID: s.ID,
Title: s.Title,
Route: route,
})
}
if enabled == nil {
enabled = []navSurface{}
}
c.JSON(http.StatusOK, gin.H{"surfaces": enabled})
}

View File

@@ -1,605 +0,0 @@
package handlers
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
)
// ── Surface Test Harness ────────────────────
type surfaceHarness struct {
router *gin.Engine
t *testing.T
stores store.Stores
adminToken string
userToken string
tmpDir string
}
func setupSurfaceHarness(t *testing.T) *surfaceHarness {
t.Helper()
database.RequireTestDB(t)
database.TruncateAll(t)
cfg := &config.Config{
JWTSecret: testJWTSecret,
BasePath: "",
}
var stores store.Stores
if database.IsSQLite() {
stores = sqlite.NewStores(database.TestDB)
} else {
stores = postgres.NewStores(database.TestDB)
}
userCache := middleware.NewUserStatusCache()
tmpDir, err := os.MkdirTemp("", "surface-test-*")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() { os.RemoveAll(tmpDir) })
r := gin.New()
api := r.Group("/api/v1")
// User endpoint (authenticated)
surfaceH := NewSurfaceHandler(stores)
userGroup := api.Group("")
userGroup.Use(middleware.Auth(cfg, stores.Users, userCache))
userGroup.GET("/surfaces", surfaceH.ListEnabledSurfaces)
// Admin endpoints
surfaceAdm := NewSurfaceHandler(stores, tmpDir)
adminGroup := api.Group("/admin")
adminGroup.Use(middleware.Auth(cfg, stores.Users, userCache))
adminGroup.Use(middleware.RequireAdmin())
adminGroup.GET("/surfaces", surfaceAdm.ListSurfaces)
adminGroup.GET("/surfaces/:id", surfaceAdm.GetSurface)
adminGroup.POST("/surfaces/install", surfaceAdm.InstallSurface)
adminGroup.PUT("/surfaces/:id/enable", surfaceAdm.EnableSurface)
adminGroup.PUT("/surfaces/:id/disable", surfaceAdm.DisableSurface)
adminGroup.DELETE("/surfaces/:id", surfaceAdm.DeleteSurface)
// Seed admin user
adminID := database.SeedTestUser(t, "surf-admin", "surf-admin@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), adminID)
adminToken := makeToken(adminID, "surf-admin@test.com", "admin")
// Seed regular user
userID := database.SeedTestUser(t, "surf-user", "surf-user@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
userToken := makeToken(userID, "surf-user@test.com", "user")
return &surfaceHarness{
router: r,
t: t,
stores: stores,
adminToken: adminToken,
userToken: userToken,
tmpDir: tmpDir,
}
}
func (h *surfaceHarness) jsonReq(method, path, token string, body interface{}) *httptest.ResponseRecorder {
h.t.Helper()
var reader io.Reader
if body != nil {
b, _ := json.Marshal(body)
reader = bytes.NewReader(b)
}
req := httptest.NewRequest(method, path, reader)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
h.router.ServeHTTP(w, req)
return w
}
func (h *surfaceHarness) uploadSurface(token string, archive []byte, filename string) *httptest.ResponseRecorder {
h.t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", filename)
if err != nil {
h.t.Fatalf("CreateFormFile: %v", err)
}
part.Write(archive)
writer.Close()
req := httptest.NewRequest("POST", "/api/v1/admin/surfaces/install", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
h.router.ServeHTTP(w, req)
return w
}
func (h *surfaceHarness) seedCoreSurface(id, title string) {
h.t.Helper()
manifest := map[string]any{"route": "/" + id}
if err := h.stores.Surfaces.Seed(context.Background(), id, title, "core", manifest); err != nil {
h.t.Fatalf("seedCoreSurface(%s): %v", id, err)
}
}
func (h *surfaceHarness) seedExtensionSurface(id, title string, enabled bool) {
h.t.Helper()
manifest := map[string]any{"route": "/s/" + id}
if err := h.stores.Surfaces.Seed(context.Background(), id, title, "extension", manifest); err != nil {
h.t.Fatalf("seedExtensionSurface(%s): %v", id, err)
}
if !enabled {
if err := h.stores.Surfaces.SetEnabled(context.Background(), id, false); err != nil {
h.t.Fatalf("disable extension %s: %v", id, err)
}
}
}
// ── Tests: User Endpoint ────────────────────
func TestSurface_ListEnabled_Empty(t *testing.T) {
h := setupSurfaceHarness(t)
w := h.jsonReq("GET", "/api/v1/surfaces", h.userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct{ Surfaces []json.RawMessage `json:"surfaces"` }
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp.Surfaces) != 0 {
t.Errorf("want 0 surfaces, got %d", len(resp.Surfaces))
}
}
func TestSurface_ListEnabled_FiltersDisabled(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
h.seedExtensionSurface("my-ext", "My Extension", true)
h.seedExtensionSurface("disabled-ext", "Disabled Extension", false)
w := h.jsonReq("GET", "/api/v1/surfaces", h.userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Surfaces []struct {
ID string `json:"id"`
Title string `json:"title"`
Route string `json:"route"`
} `json:"surfaces"`
}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp.Surfaces) != 2 {
t.Fatalf("want 2 enabled surfaces, got %d", len(resp.Surfaces))
}
// Response shape: id + title + route only — no source or enabled
raw := w.Body.Bytes()
if bytes.Contains(raw, []byte(`"source"`)) {
t.Error("user endpoint should not expose 'source' field")
}
if bytes.Contains(raw, []byte(`"enabled"`)) {
t.Error("user endpoint should not expose 'enabled' field")
}
}
// ── Tests: Admin List / Get ─────────────────
func TestSurface_AdminList_IncludesDisabled(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
h.seedExtensionSurface("my-ext", "My Extension", true)
h.seedExtensionSurface("disabled-ext", "Disabled Extension", false)
w := h.jsonReq("GET", "/api/v1/admin/surfaces", h.adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Surfaces []struct {
ID string `json:"id"`
Enabled bool `json:"enabled"`
Source string `json:"source"`
} `json:"surfaces"`
}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp.Surfaces) != 3 {
t.Fatalf("want 3 surfaces (including disabled), got %d", len(resp.Surfaces))
}
}
func TestSurface_AdminList_RequiresAdmin(t *testing.T) {
h := setupSurfaceHarness(t)
w := h.jsonReq("GET", "/api/v1/admin/surfaces", h.userToken, nil)
if w.Code != http.StatusForbidden {
t.Errorf("non-admin should get 403, got %d", w.Code)
}
}
func TestSurface_Get(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
w := h.jsonReq("GET", "/api/v1/admin/surfaces/chat", h.adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
}
var sr store.SurfaceRegistration
json.Unmarshal(w.Body.Bytes(), &sr)
if sr.ID != "chat" {
t.Errorf("id: want %q, got %q", "chat", sr.ID)
}
if sr.Source != "core" {
t.Errorf("source: want %q, got %q", "core", sr.Source)
}
}
func TestSurface_Get_NotFound(t *testing.T) {
h := setupSurfaceHarness(t)
w := h.jsonReq("GET", "/api/v1/admin/surfaces/nonexistent", h.adminToken, nil)
if w.Code != http.StatusNotFound {
t.Errorf("want 404, got %d", w.Code)
}
}
// ── Tests: Enable / Disable ─────────────────
func TestSurface_EnableDisable(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedExtensionSurface("my-ext", "My Extension", true)
// Disable
w := h.jsonReq("PUT", "/api/v1/admin/surfaces/my-ext/disable", h.adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("disable: want 200, got %d: %s", w.Code, w.Body.String())
}
sr, _ := h.stores.Surfaces.Get(context.Background(), "my-ext")
if sr.Enabled {
t.Error("surface should be disabled")
}
// Re-enable
w = h.jsonReq("PUT", "/api/v1/admin/surfaces/my-ext/enable", h.adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("enable: want 200, got %d: %s", w.Code, w.Body.String())
}
sr, _ = h.stores.Surfaces.Get(context.Background(), "my-ext")
if !sr.Enabled {
t.Error("surface should be enabled")
}
}
func TestSurface_DisableChat_Rejected(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
w := h.jsonReq("PUT", "/api/v1/admin/surfaces/chat/disable", h.adminToken, nil)
if w.Code != http.StatusBadRequest {
t.Errorf("disabling chat: want 400, got %d", w.Code)
}
}
func TestSurface_DisableAdmin_Rejected(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("admin", "Admin")
w := h.jsonReq("PUT", "/api/v1/admin/surfaces/admin/disable", h.adminToken, nil)
if w.Code != http.StatusBadRequest {
t.Errorf("disabling admin: want 400, got %d", w.Code)
}
}
func TestSurface_DisableNonexistent(t *testing.T) {
h := setupSurfaceHarness(t)
w := h.jsonReq("PUT", "/api/v1/admin/surfaces/nonexistent/disable", h.adminToken, nil)
if w.Code != http.StatusNotFound {
t.Errorf("disable nonexistent: want 404, got %d", w.Code)
}
}
// ── Tests: Delete ───────────────────────────
func TestSurface_DeleteExtension(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedExtensionSurface("my-ext", "My Extension", true)
// Create a fake asset dir to verify cleanup
assetDir := filepath.Join(h.tmpDir, "my-ext")
os.MkdirAll(filepath.Join(assetDir, "js"), 0755)
os.WriteFile(filepath.Join(assetDir, "js", "app.js"), []byte("// test"), 0644)
w := h.jsonReq("DELETE", "/api/v1/admin/surfaces/my-ext", h.adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete: want 200, got %d: %s", w.Code, w.Body.String())
}
sr, _ := h.stores.Surfaces.Get(context.Background(), "my-ext")
if sr != nil {
t.Error("surface should be deleted from DB")
}
if _, err := os.Stat(assetDir); !os.IsNotExist(err) {
t.Error("surface asset directory should be removed")
}
}
func TestSurface_DeleteCore_Rejected(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
w := h.jsonReq("DELETE", "/api/v1/admin/surfaces/chat", h.adminToken, nil)
if w.Code != http.StatusBadRequest {
t.Errorf("delete core: want 400, got %d: %s", w.Code, w.Body.String())
}
sr, _ := h.stores.Surfaces.Get(context.Background(), "chat")
if sr == nil {
t.Error("core surface should still exist after rejected delete")
}
}
func TestSurface_DeleteNonexistent(t *testing.T) {
h := setupSurfaceHarness(t)
w := h.jsonReq("DELETE", "/api/v1/admin/surfaces/nonexistent", h.adminToken, nil)
if w.Code != http.StatusNotFound {
t.Errorf("delete nonexistent: want 404, got %d", w.Code)
}
}
// ── Tests: Install ──────────────────────────
func TestSurface_Install_HappyPath(t *testing.T) {
h := setupSurfaceHarness(t)
archive := buildSurfaceZip(t, "hello-dash", "Hello Dashboard", map[string]string{
"js/app.js": "console.log('hello');",
"css/style.css": "body { color: red; }",
})
w := h.uploadSurface(h.adminToken, archive, "hello-dash.surface")
if w.Code != http.StatusOK {
t.Fatalf("install: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
ID string `json:"id"`
Title string `json:"title"`
Source string `json:"source"`
}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.ID != "hello-dash" {
t.Errorf("id: got %q, want %q", resp.ID, "hello-dash")
}
if resp.Source != "extension" {
t.Errorf("source: got %q, want %q", resp.Source, "extension")
}
// Verify in DB
sr, err := h.stores.Surfaces.Get(context.Background(), "hello-dash")
if err != nil || sr == nil {
t.Fatal("surface should exist in DB after install")
}
if sr.Title != "Hello Dashboard" {
t.Errorf("title: got %q, want %q", sr.Title, "Hello Dashboard")
}
// Verify assets extracted
jsPath := filepath.Join(h.tmpDir, "hello-dash", "js", "app.js")
if _, err := os.Stat(jsPath); os.IsNotExist(err) {
t.Error("js/app.js should be extracted")
}
cssPath := filepath.Join(h.tmpDir, "hello-dash", "css", "style.css")
if _, err := os.Stat(cssPath); os.IsNotExist(err) {
t.Error("css/style.css should be extracted")
}
}
func TestSurface_Install_PrefixedArchive(t *testing.T) {
h := setupSurfaceHarness(t)
manifest, _ := json.Marshal(map[string]string{
"id": "hello-dash", "title": "Hello Dashboard", "route": "/s/hello-dash",
})
archive := buildZipRaw(t, map[string]string{
"hello-dash/manifest.json": string(manifest),
"hello-dash/js/app.js": "console.log('hello');",
"hello-dash/css/style.css": "body {}",
})
w := h.uploadSurface(h.adminToken, archive, "hello-dash.surface")
if w.Code != http.StatusOK {
t.Fatalf("install prefixed: want 200, got %d: %s", w.Code, w.Body.String())
}
jsPath := filepath.Join(h.tmpDir, "hello-dash", "js", "app.js")
if _, err := os.Stat(jsPath); os.IsNotExist(err) {
t.Error("js/app.js should be extracted from prefixed archive")
}
}
func TestSurface_Install_MissingManifest(t *testing.T) {
h := setupSurfaceHarness(t)
archive := buildZipRaw(t, map[string]string{
"js/app.js": "console.log('hello');",
})
w := h.uploadSurface(h.adminToken, archive, "bad.surface")
if w.Code != http.StatusBadRequest {
t.Errorf("missing manifest: want 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestSurface_Install_MissingID(t *testing.T) {
h := setupSurfaceHarness(t)
archive := buildZipRaw(t, map[string]string{
"manifest.json": `{"title": "No ID"}`,
})
w := h.uploadSurface(h.adminToken, archive, "noid.surface")
if w.Code != http.StatusBadRequest {
t.Errorf("missing id: want 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestSurface_Install_CoreConflict(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
archive := buildSurfaceZip(t, "chat", "Evil Chat", nil)
w := h.uploadSurface(h.adminToken, archive, "chat.surface")
if w.Code != http.StatusConflict {
t.Errorf("core conflict: want 409, got %d: %s", w.Code, w.Body.String())
}
}
func TestSurface_Install_NoFile(t *testing.T) {
h := setupSurfaceHarness(t)
req := httptest.NewRequest("POST", "/api/v1/admin/surfaces/install", nil)
req.Header.Set("Authorization", "Bearer "+h.adminToken)
w := httptest.NewRecorder()
h.router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("no file: want 400, got %d", w.Code)
}
}
func TestSurface_Install_InvalidSlugID(t *testing.T) {
h := setupSurfaceHarness(t)
cases := []struct {
id string
desc string
}{
{"../../../etc", "path traversal"},
{"hello world", "spaces"},
{"Hello-Dashboard", "uppercase"},
{"a", "too short"},
{"-leading", "leading hyphen"},
{"trailing-", "trailing hyphen"},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
archive := buildSurfaceZip(t, tc.id, "Bad Surface", nil)
w := h.uploadSurface(h.adminToken, archive, "bad.surface")
if w.Code != http.StatusBadRequest {
t.Errorf("bad id %q: want 400, got %d: %s", tc.id, w.Code, w.Body.String())
}
})
}
}
func TestSurface_Install_UpdateExistingExtension(t *testing.T) {
h := setupSurfaceHarness(t)
archive := buildSurfaceZip(t, "my-ext", "My Extension v1", nil)
w := h.uploadSurface(h.adminToken, archive, "my-ext.surface")
if w.Code != http.StatusOK {
t.Fatalf("install v1: want 200, got %d: %s", w.Code, w.Body.String())
}
archive = buildSurfaceZip(t, "my-ext", "My Extension v2", nil)
w = h.uploadSurface(h.adminToken, archive, "my-ext.surface")
if w.Code != http.StatusOK {
t.Fatalf("install v2: want 200, got %d: %s", w.Code, w.Body.String())
}
sr, _ := h.stores.Surfaces.Get(context.Background(), "my-ext")
if sr == nil {
t.Fatal("surface should exist")
}
if sr.Title != "My Extension v2" {
t.Errorf("title: got %q, want %q", sr.Title, "My Extension v2")
}
}
func TestSurface_Install_PreservesEnabledOnReseed(t *testing.T) {
h := setupSurfaceHarness(t)
// Install, then disable
archive := buildSurfaceZip(t, "my-ext", "My Extension", nil)
h.uploadSurface(h.adminToken, archive, "my-ext.surface")
h.jsonReq("PUT", "/api/v1/admin/surfaces/my-ext/disable", h.adminToken, nil)
// Re-install — Seed's ON CONFLICT skips enabled column
archive = buildSurfaceZip(t, "my-ext", "My Extension Updated", nil)
w := h.uploadSurface(h.adminToken, archive, "my-ext.surface")
if w.Code != http.StatusOK {
t.Fatalf("re-install: want 200, got %d: %s", w.Code, w.Body.String())
}
sr, _ := h.stores.Surfaces.Get(context.Background(), "my-ext")
if sr == nil {
t.Fatal("surface should exist")
}
if sr.Enabled {
t.Error("enabled state should be preserved as false after re-seed")
}
}
// ── Zip Building Helpers ────────────────────
func buildSurfaceZip(t *testing.T, id, title string, extraFiles map[string]string) []byte {
t.Helper()
manifest, _ := json.Marshal(map[string]string{
"id": id, "title": title, "route": "/s/" + id,
})
files := map[string]string{"manifest.json": string(manifest)}
for k, v := range extraFiles {
files[k] = v
}
return buildZipRaw(t, files)
}
func buildZipRaw(t *testing.T, files map[string]string) []byte {
t.Helper()
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
for name, content := range files {
f, err := zw.Create(name)
if err != nil {
t.Fatalf("zip create %s: %v", name, err)
}
f.Write([]byte(content))
}
zw.Close()
return buf.Bytes()
}

View File

@@ -685,6 +685,84 @@ func TestTask_WorkflowTypeRejected(t *testing.T) {
}
}
// ═══════════════════════════════════════════════
// Task Type RBAC (v0.28.7)
// ═══════════════════════════════════════════════
func TestTask_StarlarkTypeRejected(t *testing.T) {
h := setupHarness(t)
_, token := h.createAdminUser("staruser", "staruser@test.com")
// Even admin cannot create starlark tasks — executor doesn't exist yet
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
"name": "Starlark Task",
"task_type": "starlark",
"schedule": "@daily",
"user_prompt": "x",
"model_id": "m",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("starlark task_type: want 400, got %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, "v0.29.0") {
t.Fatalf("expected 'v0.29.0' in error, got: %s", body)
}
}
func TestTask_SystemTypeNonAdminDenied(t *testing.T) {
h := setupHarness(t)
// Seed Everyone group with task.create so the route middleware passes
database.TestDB.Exec(dialectSQL(
`INSERT INTO groups (id, name, source, permissions) VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE SET permissions = $4`),
"00000000-0000-0000-0000-000000000001", "Everyone", "system", `["task.create"]`,
)
// Enable registration so registerUser works
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
_, userToken := h.registerUser("sysuser", "sysuser@test.com", "password123!")
w := h.request("POST", "/api/v1/tasks", userToken, map[string]interface{}{
"name": "System Task",
"task_type": "system",
"system_function": "session_cleanup",
"schedule": "@daily",
})
if w.Code != http.StatusForbidden {
t.Fatalf("system task as non-admin: want 403, got %d: %s", w.Code, w.Body.String())
}
}
func TestTask_ActionTypeNonAdminDenied(t *testing.T) {
h := setupHarness(t)
// Seed Everyone group with task.create but NOT task.action
database.TestDB.Exec(dialectSQL(
`INSERT INTO groups (id, name, source, permissions) VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE SET permissions = $4`),
"00000000-0000-0000-0000-000000000001", "Everyone", "system", `["task.create"]`,
)
// Enable registration so registerUser works
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
_, userToken := h.registerUser("actuser", "actuser@test.com", "password123!")
w := h.request("POST", "/api/v1/tasks", userToken, map[string]interface{}{
"name": "Action Task",
"task_type": "action",
"schedule": "webhook",
})
if w.Code != http.StatusForbidden {
t.Fatalf("action task without task.action: want 403, got %d: %s", w.Code, w.Body.String())
}
}
// ═══════════════════════════════════════════════
// F8: Team Task Routes
// ═══════════════════════════════════════════════

View File

@@ -133,6 +133,14 @@ func (h *TaskHandler) Create(c *gin.Context) {
}
}
// v0.28.7: Starlark tasks — pre-positioned gate for v0.29.0.
// The Starlark executor does not exist yet. Reject all creation with
// a clear message rather than a cryptic CHECK constraint failure.
if t.TaskType == "starlark" {
c.JSON(http.StatusBadRequest, gin.H{"error": "starlark task execution requires v0.29.0 — not yet available"})
return
}
// v0.28.0-audit: Workflow task execution is not yet implemented.
// Reject at the API boundary to prevent silent wrong behavior
// (workflow tasks would fall through to the prompt pipeline).

View File

@@ -228,7 +228,7 @@ func main() {
handlers.SeedProviders(cfg, stores, keyResolver)
// Seed builtin extensions from disk (idempotent, version-aware)
handlers.SeedBuiltinExtensions(stores, "extensions/builtin")
handlers.SeedBuiltinPackages(stores, "extensions/builtin")
// Load search provider config from DB (defaults to DuckDuckGo if not set)
loadSearchConfig(stores)
@@ -662,9 +662,9 @@ func main() {
protected.POST("/chat/completions", comp.Complete)
protected.GET("/tools", comp.ListTools)
// Surface discovery (v0.25.0)
surfaceH := handlers.NewSurfaceHandler(stores)
protected.GET("/surfaces", surfaceH.ListEnabledSurfaces)
// Surface discovery (v0.25.0, v0.28.7: unified packages)
pkgH := handlers.NewPackageHandler(stores)
protected.GET("/surfaces", pkgH.ListEnabledSurfaces)
// Summarize & Continue (backed by compaction service)
compactionSvc := compaction.NewService(stores, roleResolver)
@@ -1124,18 +1124,26 @@ func main() {
admin.DELETE("/routing/policies/:id", routingAdm.DeletePolicy)
admin.POST("/routing/test", routingAdm.TestRouting)
// Surface lifecycle management (v0.25.0)
surfacesDir := ""
// Package lifecycle management (v0.28.7 — replaces surfaces + extensions admin)
packagesDir := ""
if cfg.StoragePath != "" {
surfacesDir = cfg.StoragePath + "/surfaces"
packagesDir = cfg.StoragePath + "/packages"
}
surfaceAdm := handlers.NewSurfaceHandler(stores, surfacesDir)
admin.GET("/surfaces", surfaceAdm.ListSurfaces)
admin.GET("/surfaces/:id", surfaceAdm.GetSurface)
admin.POST("/surfaces/install", surfaceAdm.InstallSurface)
admin.PUT("/surfaces/:id/enable", surfaceAdm.EnableSurface)
admin.PUT("/surfaces/:id/disable", surfaceAdm.DisableSurface)
admin.DELETE("/surfaces/:id", surfaceAdm.DeleteSurface)
pkgAdm := handlers.NewPackageHandler(stores, packagesDir)
admin.GET("/packages", pkgAdm.ListPackages)
admin.GET("/packages/:id", pkgAdm.GetPackage)
admin.POST("/packages/install", pkgAdm.InstallPackage)
admin.PUT("/packages/:id/enable", pkgAdm.EnablePackage)
admin.PUT("/packages/:id/disable", pkgAdm.DisablePackage)
admin.DELETE("/packages/:id", pkgAdm.DeletePackage)
// Surface aliases (backward compat — same handlers)
admin.GET("/surfaces", pkgAdm.ListPackages)
admin.GET("/surfaces/:id", pkgAdm.GetPackage)
admin.POST("/surfaces/install", pkgAdm.InstallPackage)
admin.PUT("/surfaces/:id/enable", pkgAdm.EnablePackage)
admin.PUT("/surfaces/:id/disable", pkgAdm.DisablePackage)
admin.DELETE("/surfaces/:id", pkgAdm.DeletePackage)
// Task management — admin (v0.27.1, extended v0.27.2)
taskAdm := handlers.NewTaskHandler(stores)
@@ -1154,15 +1162,16 @@ func main() {
// v0.27.0: Extension surface static assets (JS, CSS, images).
// Served without auth — same rationale as extension assets (script tags can't send headers).
// In split deployment, nginx serves these from /data/surfaces/ directly.
// v0.28.7: on-disk path moved to /data/packages/, URL stays /surfaces/:id/ for stability.
// In split deployment, nginx serves these from /data/packages/ directly.
if cfg.StoragePath != "" {
surfacesStaticDir := cfg.StoragePath + "/surfaces"
packagesStaticDir := cfg.StoragePath + "/packages"
base.GET("/surfaces/:id/*path", func(c *gin.Context) {
id := c.Param("id")
filePath := c.Param("path")
// Security: prevent path traversal
clean := filepath.Clean(filepath.Join(surfacesStaticDir, id, filePath))
if !strings.HasPrefix(clean, filepath.Clean(surfacesStaticDir)) {
clean := filepath.Clean(filepath.Join(packagesStaticDir, id, filePath))
if !strings.HasPrefix(clean, filepath.Clean(packagesStaticDir)) {
c.Status(http.StatusForbidden)
return
}

View File

@@ -124,11 +124,11 @@ type ExtensionNavItem struct {
// 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 {
if e.stores.Surfaces == nil {
if e.stores.Packages == nil {
return nil
}
ctx := context.Background()
surfaces, err := e.stores.Surfaces.List(ctx)
surfaces, err := e.stores.Packages.List(ctx)
if err != nil {
log.Printf("[pages] Failed to load extension surfaces for nav: %v", err)
return nil
@@ -139,6 +139,10 @@ func (e *Engine) extensionNavItems() []ExtensionNavItem {
if sr.Source == "core" || !sr.Enabled {
continue
}
// Only surface and full types have routes — extensions are headless
if sr.Type != "surface" && sr.Type != "full" {
continue
}
// Editor is Source="extension" but hardcoded as core — skip from dynamic nav
if sr.ID == "editor" {
continue
@@ -367,13 +371,13 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
return
}
if e.stores.Surfaces == nil {
if e.stores.Packages == nil {
c.String(http.StatusNotFound, "Surface registry not available")
return
}
// Look up by ID (slug == surface ID)
sr, err := e.stores.Surfaces.Get(c.Request.Context(), slug)
sr, err := e.stores.Packages.Get(c.Request.Context(), slug)
if err != nil || sr == nil {
c.String(http.StatusNotFound, "Surface not found: "+slug)
return

View File

@@ -12,7 +12,7 @@ import (
// Called once at startup. Does NOT overwrite the enabled flag — admin
// toggles survive restarts.
func (e *Engine) SeedSurfaces() {
if e.stores.Surfaces == nil {
if e.stores.Packages == nil {
log.Printf("[pages] Surface registry not available — skipping seed")
return
}
@@ -27,7 +27,7 @@ func (e *Engine) SeedSurfaces() {
"auth": s.Auth,
"layout": s.Layout,
}
if err := e.stores.Surfaces.Seed(ctx, s.ID, s.Title, s.Source, manifest); err != nil {
if err := e.stores.Packages.Seed(ctx, s.ID, s.Title, s.Source, manifest); err != nil {
log.Printf("[pages] Failed to seed surface %q: %v", s.ID, err)
}
}
@@ -42,11 +42,11 @@ func (e *Engine) IsSurfaceEnabled(surfaceID string) bool {
if surfaceID == "chat" || surfaceID == "admin" {
return true
}
if e.stores.Surfaces == nil {
if e.stores.Packages == nil {
return true // No registry = all surfaces enabled (backward compat)
}
ctx := context.Background()
sr, err := e.stores.Surfaces.Get(ctx, surfaceID)
sr, err := e.stores.Packages.Get(ctx, surfaceID)
if err != nil || sr == nil {
return true // Not in registry = assume enabled (backward compat)
}
@@ -55,7 +55,7 @@ func (e *Engine) IsSurfaceEnabled(surfaceID string) bool {
// EnabledSurfaceIDs returns a list of enabled surface IDs for nav rendering.
func (e *Engine) EnabledSurfaceIDs() []string {
if e.stores.Surfaces == nil {
if e.stores.Packages == nil {
// No registry — return all core surface IDs
var all []string
for _, s := range e.surfaces {
@@ -64,7 +64,7 @@ func (e *Engine) EnabledSurfaceIDs() []string {
return all
}
ctx := context.Background()
ids, err := e.stores.Surfaces.ListEnabled(ctx)
ids, err := e.stores.Packages.ListEnabled(ctx)
if err != nil {
log.Printf("[pages] Failed to load enabled surfaces: %v", err)
// Fallback: return all core surface IDs

View File

@@ -219,7 +219,7 @@
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/workflow-api.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/workflow-admin.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/task-admin.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-surfaces.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-packages.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/broadcast-admin.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}">
document.addEventListener('DOMContentLoaded', () => {

View File

@@ -39,7 +39,6 @@ type Stores struct {
GlobalConfig GlobalConfigStore
Usage UsageStore
Pricing PricingStore
Extensions ExtensionStore
Files FileStore
KnowledgeBases KnowledgeBaseStore
Groups GroupStore
@@ -53,7 +52,7 @@ type Stores struct {
CapOverrides CapabilityOverrideStore
RoutingPolicies RoutingPolicyStore
Sessions SessionStore
Surfaces SurfaceRegistryStore // v0.25.0: Surface lifecycle management
Packages PackageStore // v0.28.7: Unified package registry (surfaces + extensions)
Workflows WorkflowStore // v0.26.1: Workflow definitions + stages
Tasks TaskStore // v0.27.1: Task scheduling + run history
}
@@ -402,29 +401,6 @@ type PricingStore interface {
Delete(ctx context.Context, providerConfigID, modelID string) error
}
// =========================================
// EXTENSION STORE
// =========================================
type ExtensionStore interface {
// Admin CRUD
Create(ctx context.Context, ext *models.Extension) error
GetByID(ctx context.Context, id string) (*models.Extension, error)
GetByExtID(ctx context.Context, extID string) (*models.Extension, error)
Update(ctx context.Context, id string, ext *models.Extension) error
Delete(ctx context.Context, id string) error
// Listing
ListAll(ctx context.Context) ([]models.Extension, error)
ListEnabled(ctx context.Context) ([]models.Extension, error)
ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error)
// User settings
GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error)
SetUserSettings(ctx context.Context, s *models.ExtensionUserSettings) error
DeleteUserSettings(ctx context.Context, extID, userID string) error
}
// =========================================
// FILE STORE
// =========================================

View File

@@ -0,0 +1,101 @@
package store
import (
"context"
"encoding/json"
)
// PackageStore manages the unified package registry (surfaces + extensions).
// Replaces SurfaceRegistryStore and ExtensionStore (v0.28.7).
type PackageStore interface {
// ── Lifecycle (from SurfaceRegistryStore) ────────────
// Seed upserts a package at startup. Does NOT overwrite the enabled
// flag if the row already exists (admin toggle survives restarts).
// Called by the page engine for core surfaces and by
// SeedBuiltinPackages() for builtin extensions.
Seed(ctx context.Context, id, title, source string, manifest map[string]any) error
// List returns all registered packages, ordered by source then title.
List(ctx context.Context) ([]PackageRegistration, error)
// Get returns a single package by ID (slug).
Get(ctx context.Context, id string) (*PackageRegistration, error)
// SetEnabled toggles a package's enabled state.
SetEnabled(ctx context.Context, id string, enabled bool) error
// Delete removes a non-core package. Core packages cannot be deleted.
Delete(ctx context.Context, id string) error
// ListEnabled returns IDs of all enabled packages.
// Used by the page engine for nav rendering, not exposed via HTTP.
ListEnabled(ctx context.Context) ([]string, error)
// ── Extended lifecycle ───────────────────────────────
// Create inserts a new package with all fields. Used by the install
// handler and by SeedBuiltinPackages() for first-time seeding.
Create(ctx context.Context, pkg *PackageRegistration) error
// Update modifies mutable fields on an existing package.
Update(ctx context.Context, id string, pkg *PackageRegistration) error
// ListByType returns packages filtered by type (surface, extension, full).
ListByType(ctx context.Context, pkgType string) ([]PackageRegistration, error)
// ListEnabledByType returns enabled packages of a given type.
ListEnabledByType(ctx context.Context, pkgType string) ([]PackageRegistration, error)
// ── Extension runtime (from ExtensionStore) ─────────
// ListForUser returns enabled extension/full packages with per-user
// settings overlay from package_user_settings. System packages are
// always included (users can't disable them).
ListForUser(ctx context.Context, userID string) ([]UserPackage, error)
// GetUserSettings returns per-user settings for a specific package.
GetUserSettings(ctx context.Context, pkgID, userID string) (*PackageUserSettings, error)
// SetUserSettings upserts per-user settings for a package.
SetUserSettings(ctx context.Context, s *PackageUserSettings) error
// DeleteUserSettings removes per-user settings, reverting to defaults.
DeleteUserSettings(ctx context.Context, pkgID, userID string) error
}
// PackageRegistration is a row from the packages table.
type PackageRegistration struct {
ID string `json:"id" db:"id"`
Title string `json:"title" db:"title"`
Type string `json:"type" db:"type"`
Version string `json:"version" db:"version"`
Description string `json:"description" db:"description"`
Author string `json:"author" db:"author"`
Tier string `json:"tier" db:"tier"`
IsSystem bool `json:"is_system" db:"is_system"`
Scope string `json:"scope" db:"scope"`
TeamID *string `json:"team_id,omitempty" db:"team_id"`
InstalledBy *string `json:"installed_by,omitempty" db:"installed_by"`
Manifest map[string]any `json:"manifest" db:"manifest"`
Enabled bool `json:"enabled" db:"enabled"`
Source string `json:"source" db:"source"`
InstalledAt string `json:"installed_at" db:"installed_at"`
UpdatedAt string `json:"updated_at" db:"updated_at"`
}
// UserPackage combines package info with per-user settings for API responses.
// Returned by ListForUser for the /extensions endpoint.
type UserPackage struct {
PackageRegistration
UserEnabled *bool `json:"user_enabled,omitempty"`
UserSettings *json.RawMessage `json:"user_settings,omitempty"`
}
// PackageUserSettings stores per-user overrides for a package.
type PackageUserSettings struct {
PackageID string `json:"package_id" db:"package_id"`
UserID string `json:"user_id" db:"user_id"`
Settings json.RawMessage `json:"settings" db:"settings"`
IsEnabled bool `json:"is_enabled" db:"is_enabled"`
}

View File

@@ -1,200 +0,0 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type ExtensionStore struct{}
func NewExtensionStore() *ExtensionStore { return &ExtensionStore{} }
func (s *ExtensionStore) Create(ctx context.Context, ext *models.Extension) error {
return DB.QueryRowContext(ctx, `
INSERT INTO extensions (ext_id, name, version, tier, description, author,
manifest, is_system, is_enabled, scope, team_id, installed_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
RETURNING id, created_at, updated_at`,
ext.ExtID, ext.Name, ext.Version, ext.Tier, ext.Description, ext.Author,
ext.Manifest, ext.IsSystem, ext.IsEnabled, ext.Scope,
models.NullString(ext.TeamID), models.NullString(ext.InstalledBy),
).Scan(&ext.ID, &ext.CreatedAt, &ext.UpdatedAt)
}
func (s *ExtensionStore) GetByID(ctx context.Context, id string) (*models.Extension, error) {
return s.scanOne(ctx, `SELECT * FROM extensions WHERE id = $1`, id)
}
func (s *ExtensionStore) GetByExtID(ctx context.Context, extID string) (*models.Extension, error) {
return s.scanOne(ctx, `SELECT * FROM extensions WHERE ext_id = $1`, extID)
}
func (s *ExtensionStore) Update(ctx context.Context, id string, ext *models.Extension) error {
_, err := DB.ExecContext(ctx, `
UPDATE extensions SET
name = $2, version = $3, description = $4, author = $5,
manifest = $6, is_system = $7, is_enabled = $8,
updated_at = now()
WHERE id = $1`,
id, ext.Name, ext.Version, ext.Description, ext.Author,
ext.Manifest, ext.IsSystem, ext.IsEnabled,
)
return err
}
func (s *ExtensionStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, `DELETE FROM extensions WHERE id = $1`, id)
return err
}
func (s *ExtensionStore) ListAll(ctx context.Context) ([]models.Extension, error) {
return s.scanMany(ctx, `SELECT * FROM extensions ORDER BY name`)
}
// ListEnabled returns all globally enabled extensions regardless of user.
// TODO(v0.29.0): Used by Starlark runtime to load server-side extensions at startup.
func (s *ExtensionStore) ListEnabled(ctx context.Context) ([]models.Extension, error) {
return s.scanMany(ctx, `SELECT * FROM extensions WHERE is_enabled = true ORDER BY name`)
}
// ListForUser returns all enabled extensions with user-specific overrides merged in.
// System extensions are always included (users can't disable them).
// Non-system extensions respect per-user enabled toggle.
func (s *ExtensionStore) ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error) {
rows, err := DB.QueryContext(ctx, `
SELECT e.id, e.ext_id, e.name, e.version, e.tier, e.description, e.author,
e.manifest, e.is_system, e.is_enabled, e.scope, e.team_id, e.installed_by,
e.created_at, e.updated_at,
eus.is_enabled AS user_enabled,
eus.settings AS user_settings
FROM extensions e
LEFT JOIN extension_user_settings eus
ON eus.extension_id = e.id AND eus.user_id = $1
WHERE e.is_enabled = true
AND (e.is_system = true OR COALESCE(eus.is_enabled, true) = true)
ORDER BY e.name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.UserExtension
for rows.Next() {
var ue models.UserExtension
var teamID, installedBy sql.NullString
var userEnabled sql.NullBool
var userSettings []byte
if err := rows.Scan(
&ue.ID, &ue.ExtID, &ue.Name, &ue.Version, &ue.Tier,
&ue.Description, &ue.Author, &ue.Manifest, &ue.IsSystem,
&ue.IsEnabled, &ue.Scope, &teamID, &installedBy,
&ue.CreatedAt, &ue.UpdatedAt,
&userEnabled, &userSettings,
); err != nil {
return nil, err
}
ue.TeamID = NullableStringPtr(teamID)
ue.InstalledBy = NullableStringPtr(installedBy)
if userEnabled.Valid {
ue.UserEnabled = &userEnabled.Bool
}
if userSettings != nil {
raw := json.RawMessage(userSettings)
ue.UserSettings = &raw
}
result = append(result, ue)
}
if result == nil {
result = make([]models.UserExtension, 0)
}
return result, rows.Err()
}
// GetUserSettings returns per-user settings for a specific extension.
// TODO(v0.29.0): Used by Starlark runtime to load per-user config for server-side extensions.
func (s *ExtensionStore) GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error) {
var eus models.ExtensionUserSettings
err := DB.QueryRowContext(ctx, `
SELECT extension_id, user_id, settings, is_enabled
FROM extension_user_settings
WHERE extension_id = $1 AND user_id = $2`,
extID, userID,
).Scan(&eus.ExtensionID, &eus.UserID, &eus.Settings, &eus.IsEnabled)
if err != nil {
return nil, err
}
return &eus, nil
}
func (s *ExtensionStore) SetUserSettings(ctx context.Context, eus *models.ExtensionUserSettings) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO extension_user_settings (extension_id, user_id, settings, is_enabled)
VALUES ($1, $2, $3, $4)
ON CONFLICT (extension_id, user_id)
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
eus.ExtensionID, eus.UserID, eus.Settings, eus.IsEnabled,
)
return err
}
// DeleteUserSettings removes per-user settings, reverting to defaults.
// TODO(v0.29.0): Exposed via user settings UI when Starlark extensions have per-user config.
func (s *ExtensionStore) DeleteUserSettings(ctx context.Context, extID, userID string) error {
_, err := DB.ExecContext(ctx, `
DELETE FROM extension_user_settings WHERE extension_id = $1 AND user_id = $2`,
extID, userID,
)
return err
}
// ── Internal helpers ────────────────────────────
func (s *ExtensionStore) scanOne(ctx context.Context, query string, args ...interface{}) (*models.Extension, error) {
var ext models.Extension
var teamID, installedBy sql.NullString
err := DB.QueryRowContext(ctx, query, args...).Scan(
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
&ext.CreatedAt, &ext.UpdatedAt,
)
if err != nil {
return nil, err
}
ext.TeamID = NullableStringPtr(teamID)
ext.InstalledBy = NullableStringPtr(installedBy)
return &ext, nil
}
func (s *ExtensionStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]models.Extension, error) {
rows, err := DB.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Extension
for rows.Next() {
var ext models.Extension
var teamID, installedBy sql.NullString
if err := rows.Scan(
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
&ext.CreatedAt, &ext.UpdatedAt,
); err != nil {
return nil, err
}
ext.TeamID = NullableStringPtr(teamID)
ext.InstalledBy = NullableStringPtr(installedBy)
result = append(result, ext)
}
if result == nil {
result = make([]models.Extension, 0)
}
return result, rows.Err()
}

View File

@@ -0,0 +1,281 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type PackageStore struct{}
func NewPackageStore() *PackageStore { return &PackageStore{} }
// ── Lifecycle ────────────────────────────────────
// Seed upserts a package at startup. Does NOT overwrite the enabled flag.
func (s *PackageStore) Seed(ctx context.Context, id, title, source string, manifest map[string]any) error {
manifestJSON := ToJSON(manifest)
_, err := DB.ExecContext(ctx, `
INSERT INTO packages (id, title, source, manifest, enabled, installed_at, updated_at)
VALUES ($1, $2, $3, $4, true, NOW(), NOW())
ON CONFLICT (id) DO UPDATE SET
title = $2,
manifest = $4,
source = $3,
updated_at = NOW()`,
id, title, source, manifestJSON)
return err
}
func (s *PackageStore) List(ctx context.Context) ([]store.PackageRegistration, error) {
return s.scanMany(ctx, `SELECT `+pkgCols+` FROM packages p ORDER BY p.source, p.title`)
}
func (s *PackageStore) Get(ctx context.Context, id string) (*store.PackageRegistration, error) {
return s.scanOne(ctx, `SELECT `+pkgCols+` FROM packages p WHERE p.id = $1`, id)
}
func (s *PackageStore) SetEnabled(ctx context.Context, id string, enabled bool) error {
result, err := DB.ExecContext(ctx,
`UPDATE packages SET enabled = $2, updated_at = NOW() WHERE id = $1`,
id, enabled)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *PackageStore) Delete(ctx context.Context, id string) error {
result, err := DB.ExecContext(ctx,
`DELETE FROM packages WHERE id = $1 AND source != 'core'`, id)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *PackageStore) ListEnabled(ctx context.Context) ([]string, error) {
rows, err := DB.QueryContext(ctx,
`SELECT id FROM packages WHERE enabled = true ORDER BY title`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
continue
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// ── Extended lifecycle ───────────────────────────
func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistration) error {
manifestJSON := ToJSON(pkg.Manifest)
return DB.QueryRowContext(ctx, `
INSERT INTO packages (id, title, type, version, description, author, tier,
is_system, scope, team_id, installed_by, manifest, enabled, source)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
RETURNING installed_at, updated_at`,
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
pkg.Tier, pkg.IsSystem, pkg.Scope,
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
manifestJSON, pkg.Enabled, pkg.Source,
).Scan(&pkg.InstalledAt, &pkg.UpdatedAt)
}
func (s *PackageStore) Update(ctx context.Context, id string, pkg *store.PackageRegistration) error {
manifestJSON := ToJSON(pkg.Manifest)
_, err := DB.ExecContext(ctx, `
UPDATE packages SET
title = $2, type = $3, version = $4, description = $5, author = $6,
tier = $7, manifest = $8, is_system = $9, enabled = $10,
updated_at = NOW()
WHERE id = $1`,
id, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
pkg.Tier, manifestJSON, pkg.IsSystem, pkg.Enabled,
)
return err
}
func (s *PackageStore) ListByType(ctx context.Context, pkgType string) ([]store.PackageRegistration, error) {
return s.scanMany(ctx, `SELECT `+pkgCols+` FROM packages p WHERE p.type = $1 ORDER BY p.title`, pkgType)
}
func (s *PackageStore) ListEnabledByType(ctx context.Context, pkgType string) ([]store.PackageRegistration, error) {
return s.scanMany(ctx,
`SELECT `+pkgCols+` FROM packages p WHERE p.type = $1 AND p.enabled = true ORDER BY p.title`,
pkgType)
}
// ── Extension runtime ────────────────────────────
// ListForUser returns enabled extension/full packages with user settings overlay.
func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.UserPackage, error) {
rows, err := DB.QueryContext(ctx, `
SELECT `+pkgCols+`,
pus.is_enabled AS user_enabled,
pus.settings AS user_settings
FROM packages p
LEFT JOIN package_user_settings pus
ON pus.package_id = p.id AND pus.user_id = $1
WHERE p.enabled = true
AND p.type IN ('extension', 'full')
AND (p.is_system = true OR COALESCE(pus.is_enabled, true) = true)
ORDER BY p.title`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.UserPackage
for rows.Next() {
var up store.UserPackage
var teamID, installedBy sql.NullString
var manifestJSON []byte
var userEnabled sql.NullBool
var userSettings []byte
if err := rows.Scan(
&up.ID, &up.Title, &up.Type, &up.Version, &up.Description,
&up.Author, &up.Tier, &up.IsSystem, &up.Scope,
&teamID, &installedBy,
&manifestJSON, &up.Enabled, &up.Source,
&up.InstalledAt, &up.UpdatedAt,
&userEnabled, &userSettings,
); err != nil {
return nil, err
}
up.TeamID = NullableStringPtr(teamID)
up.InstalledBy = NullableStringPtr(installedBy)
json.Unmarshal(manifestJSON, &up.Manifest)
if userEnabled.Valid {
up.UserEnabled = &userEnabled.Bool
}
if userSettings != nil {
raw := json.RawMessage(userSettings)
up.UserSettings = &raw
}
result = append(result, up)
}
if result == nil {
result = make([]store.UserPackage, 0)
}
return result, rows.Err()
}
func (s *PackageStore) GetUserSettings(ctx context.Context, pkgID, userID string) (*store.PackageUserSettings, error) {
var pus store.PackageUserSettings
err := DB.QueryRowContext(ctx, `
SELECT package_id, user_id, settings, is_enabled
FROM package_user_settings
WHERE package_id = $1 AND user_id = $2`,
pkgID, userID,
).Scan(&pus.PackageID, &pus.UserID, &pus.Settings, &pus.IsEnabled)
if err != nil {
return nil, err
}
return &pus, nil
}
func (s *PackageStore) SetUserSettings(ctx context.Context, pus *store.PackageUserSettings) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO package_user_settings (package_id, user_id, settings, is_enabled)
VALUES ($1, $2, $3, $4)
ON CONFLICT (package_id, user_id)
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
pus.PackageID, pus.UserID, pus.Settings, pus.IsEnabled,
)
return err
}
func (s *PackageStore) DeleteUserSettings(ctx context.Context, pkgID, userID string) error {
_, err := DB.ExecContext(ctx, `
DELETE FROM package_user_settings WHERE package_id = $1 AND user_id = $2`,
pkgID, userID,
)
return err
}
// ── Helpers ──────────────────────────────────────
// pkgCols is the explicit column list for packages scans. Avoids SELECT *
// so column additions don't silently break positional Scan().
const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author,
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
p.manifest, p.enabled, p.source, p.installed_at, p.updated_at`
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
var pkg store.PackageRegistration
var teamID, installedBy sql.NullString
var manifestJSON []byte
err := DB.QueryRowContext(ctx, query, args...).Scan(
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
&pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope,
&teamID, &installedBy,
&manifestJSON, &pkg.Enabled, &pkg.Source,
&pkg.InstalledAt, &pkg.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy)
json.Unmarshal(manifestJSON, &pkg.Manifest)
return &pkg, nil
}
func (s *PackageStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]store.PackageRegistration, error) {
rows, err := DB.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.PackageRegistration
for rows.Next() {
var pkg store.PackageRegistration
var teamID, installedBy sql.NullString
var manifestJSON []byte
if err := rows.Scan(
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
&pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope,
&teamID, &installedBy,
&manifestJSON, &pkg.Enabled, &pkg.Source,
&pkg.InstalledAt, &pkg.UpdatedAt,
); err != nil {
return nil, err
}
pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy)
json.Unmarshal(manifestJSON, &pkg.Manifest)
result = append(result, pkg)
}
return result, rows.Err()
}
// nullStrPtr converts *string to sql.NullString for nullable FK columns.
func nullStrPtr(s *string) sql.NullString {
if s == nil {
return sql.NullString{}
}
return sql.NullString{String: *s, Valid: true}
}

View File

@@ -26,7 +26,6 @@ func NewStores(db *sql.DB) store.Stores {
GlobalConfig: NewGlobalConfigStore(),
Usage: NewUsageStore(),
Pricing: NewPricingStore(),
Extensions: NewExtensionStore(),
Files: NewFileStore(),
KnowledgeBases: NewKnowledgeBaseStore(),
Groups: NewGroupStore(),
@@ -40,7 +39,7 @@ func NewStores(db *sql.DB) store.Stores {
CapOverrides: NewCapOverrideStore(db),
RoutingPolicies: NewRoutingPolicyStore(db),
Sessions: NewSessionStore(),
Surfaces: NewSurfaceRegistryStore(),
Packages: NewPackageStore(),
Workflows: NewWorkflowStore(),
Tasks: NewTaskStore(),
}

View File

@@ -1,117 +0,0 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type SurfaceRegistryStore struct{}
func NewSurfaceRegistryStore() *SurfaceRegistryStore { return &SurfaceRegistryStore{} }
// Seed upserts a core surface. Called at startup by the page engine
// to ensure core surfaces exist in the registry. Does NOT overwrite
// the enabled state if the row already exists (admin toggle survives restart).
func (s *SurfaceRegistryStore) Seed(ctx context.Context, id, title, source string, manifest map[string]any) error {
manifestJSON := ToJSON(manifest)
_, err := DB.ExecContext(ctx, `
INSERT INTO surface_registry (id, title, source, manifest, enabled, installed_at, updated_at)
VALUES ($1, $2, $3, $4, true, NOW(), NOW())
ON CONFLICT (id) DO UPDATE SET
title = $2,
manifest = $4,
source = $3,
updated_at = NOW()`,
// Note: ON CONFLICT does NOT update 'enabled' — preserves admin toggle
id, title, source, manifestJSON)
return err
}
func (s *SurfaceRegistryStore) List(ctx context.Context) ([]store.SurfaceRegistration, error) {
rows, err := DB.QueryContext(ctx,
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
FROM surface_registry ORDER BY source, title`)
if err != nil {
return nil, err
}
defer rows.Close()
var surfaces []store.SurfaceRegistration
for rows.Next() {
var sr store.SurfaceRegistration
var manifestJSON []byte
if err := rows.Scan(&sr.ID, &sr.Title, &manifestJSON, &sr.Enabled, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt); err != nil {
continue
}
json.Unmarshal(manifestJSON, &sr.Manifest)
surfaces = append(surfaces, sr)
}
return surfaces, rows.Err()
}
func (s *SurfaceRegistryStore) Get(ctx context.Context, id string) (*store.SurfaceRegistration, error) {
var sr store.SurfaceRegistration
var manifestJSON []byte
err := DB.QueryRowContext(ctx,
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
FROM surface_registry WHERE id = $1`, id).
Scan(&sr.ID, &sr.Title, &manifestJSON, &sr.Enabled, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
json.Unmarshal(manifestJSON, &sr.Manifest)
return &sr, nil
}
func (s *SurfaceRegistryStore) SetEnabled(ctx context.Context, id string, enabled bool) error {
result, err := DB.ExecContext(ctx,
`UPDATE surface_registry SET enabled = $2, updated_at = NOW() WHERE id = $1`,
id, enabled)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *SurfaceRegistryStore) Delete(ctx context.Context, id string) error {
// Only allow deleting extension surfaces, not core
result, err := DB.ExecContext(ctx,
`DELETE FROM surface_registry WHERE id = $1 AND source = 'extension'`, id)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *SurfaceRegistryStore) ListEnabled(ctx context.Context) ([]string, error) {
rows, err := DB.QueryContext(ctx,
`SELECT id FROM surface_registry WHERE enabled = true ORDER BY title`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
continue
}
ids = append(ids, id)
}
return ids, rows.Err()
}

View File

@@ -1,207 +0,0 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type ExtensionStore struct{}
func NewExtensionStore() *ExtensionStore { return &ExtensionStore{} }
func (s *ExtensionStore) Create(ctx context.Context, ext *models.Extension) error {
ext.ID = store.NewID()
now := time.Now().UTC()
ext.CreatedAt = now
ext.UpdatedAt = now
_, err := DB.ExecContext(ctx, `
INSERT INTO extensions (id, ext_id, name, version, tier, description, author,
manifest, is_system, is_enabled, scope, team_id, installed_by, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
ext.ID, ext.ExtID, ext.Name, ext.Version, ext.Tier, ext.Description, ext.Author,
ext.Manifest, ext.IsSystem, ext.IsEnabled, ext.Scope,
models.NullString(ext.TeamID), models.NullString(ext.InstalledBy),
now.Format(timeFmt), now.Format(timeFmt),
)
return err
}
func (s *ExtensionStore) GetByID(ctx context.Context, id string) (*models.Extension, error) {
return s.scanOne(ctx, `SELECT * FROM extensions WHERE id = ?`, id)
}
func (s *ExtensionStore) GetByExtID(ctx context.Context, extID string) (*models.Extension, error) {
return s.scanOne(ctx, `SELECT * FROM extensions WHERE ext_id = ?`, extID)
}
func (s *ExtensionStore) Update(ctx context.Context, id string, ext *models.Extension) error {
_, err := DB.ExecContext(ctx, `
UPDATE extensions SET
name = ?, version = ?, description = ?, author = ?,
manifest = ?, is_system = ?, is_enabled = ?,
updated_at = datetime('now')
WHERE id = ?`,
ext.Name, ext.Version, ext.Description, ext.Author,
ext.Manifest, ext.IsSystem, ext.IsEnabled, id,
)
return err
}
func (s *ExtensionStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, `DELETE FROM extensions WHERE id = ?`, id)
return err
}
func (s *ExtensionStore) ListAll(ctx context.Context) ([]models.Extension, error) {
return s.scanMany(ctx, `SELECT * FROM extensions ORDER BY name`)
}
// ListEnabled returns all globally enabled extensions regardless of user.
// TODO(v0.29.0): Used by Starlark runtime to load server-side extensions at startup.
func (s *ExtensionStore) ListEnabled(ctx context.Context) ([]models.Extension, error) {
return s.scanMany(ctx, `SELECT * FROM extensions WHERE is_enabled = 1 ORDER BY name`)
}
// ListForUser returns all enabled extensions with user-specific overrides merged in.
// System extensions are always included (users can't disable them).
// Non-system extensions respect per-user enabled toggle.
func (s *ExtensionStore) ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error) {
rows, err := DB.QueryContext(ctx, `
SELECT e.id, e.ext_id, e.name, e.version, e.tier, e.description, e.author,
e.manifest, e.is_system, e.is_enabled, e.scope, e.team_id, e.installed_by,
e.created_at, e.updated_at,
eus.is_enabled AS user_enabled,
eus.settings AS user_settings
FROM extensions e
LEFT JOIN extension_user_settings eus
ON eus.extension_id = e.id AND eus.user_id = ?
WHERE e.is_enabled = 1
AND (e.is_system = 1 OR COALESCE(eus.is_enabled, 1) = 1)
ORDER BY e.name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.UserExtension
for rows.Next() {
var ue models.UserExtension
var teamID, installedBy sql.NullString
var userEnabled sql.NullBool
var userSettings []byte
if err := rows.Scan(
&ue.ID, &ue.ExtID, &ue.Name, &ue.Version, &ue.Tier,
&ue.Description, &ue.Author, &ue.Manifest, &ue.IsSystem,
&ue.IsEnabled, &ue.Scope, &teamID, &installedBy,
st(&ue.CreatedAt), st(&ue.UpdatedAt),
&userEnabled, &userSettings,
); err != nil {
return nil, err
}
ue.TeamID = NullableStringPtr(teamID)
ue.InstalledBy = NullableStringPtr(installedBy)
if userEnabled.Valid {
ue.UserEnabled = &userEnabled.Bool
}
if userSettings != nil {
raw := json.RawMessage(userSettings)
ue.UserSettings = &raw
}
result = append(result, ue)
}
if result == nil {
result = make([]models.UserExtension, 0)
}
return result, rows.Err()
}
// GetUserSettings returns per-user settings for a specific extension.
// TODO(v0.29.0): Used by Starlark runtime to load per-user config for server-side extensions.
func (s *ExtensionStore) GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error) {
var eus models.ExtensionUserSettings
err := DB.QueryRowContext(ctx, `
SELECT extension_id, user_id, settings, is_enabled
FROM extension_user_settings
WHERE extension_id = ? AND user_id = ?`,
extID, userID,
).Scan(&eus.ExtensionID, &eus.UserID, &eus.Settings, &eus.IsEnabled)
if err != nil {
return nil, err
}
return &eus, nil
}
func (s *ExtensionStore) SetUserSettings(ctx context.Context, eus *models.ExtensionUserSettings) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO extension_user_settings (extension_id, user_id, settings, is_enabled)
VALUES (?, ?, ?, ?)
ON CONFLICT (extension_id, user_id)
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
eus.ExtensionID, eus.UserID, eus.Settings, eus.IsEnabled,
)
return err
}
// DeleteUserSettings removes per-user settings, reverting to defaults.
// TODO(v0.29.0): Exposed via user settings UI when Starlark extensions have per-user config.
func (s *ExtensionStore) DeleteUserSettings(ctx context.Context, extID, userID string) error {
_, err := DB.ExecContext(ctx, `
DELETE FROM extension_user_settings WHERE extension_id = ? AND user_id = ?`,
extID, userID,
)
return err
}
// ── Internal helpers ────────────────────────────
func (s *ExtensionStore) scanOne(ctx context.Context, query string, args ...interface{}) (*models.Extension, error) {
var ext models.Extension
var teamID, installedBy sql.NullString
err := DB.QueryRowContext(ctx, query, args...).Scan(
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
st(&ext.CreatedAt), st(&ext.UpdatedAt),
)
if err != nil {
return nil, err
}
ext.TeamID = NullableStringPtr(teamID)
ext.InstalledBy = NullableStringPtr(installedBy)
return &ext, nil
}
func (s *ExtensionStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]models.Extension, error) {
rows, err := DB.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Extension
for rows.Next() {
var ext models.Extension
var teamID, installedBy sql.NullString
if err := rows.Scan(
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
st(&ext.CreatedAt), st(&ext.UpdatedAt),
); err != nil {
return nil, err
}
ext.TeamID = NullableStringPtr(teamID)
ext.InstalledBy = NullableStringPtr(installedBy)
result = append(result, ext)
}
if result == nil {
result = make([]models.Extension, 0)
}
return result, rows.Err()
}

View File

@@ -0,0 +1,301 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"time"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type PackageStore struct{}
func NewPackageStore() *PackageStore { return &PackageStore{} }
// ── Lifecycle ────────────────────────────────────
func (s *PackageStore) Seed(ctx context.Context, id, title, source string, manifest map[string]any) error {
manifestJSON := ToJSON(manifest)
_, err := DB.ExecContext(ctx, `
INSERT INTO packages (id, title, source, manifest, enabled, installed_at, updated_at)
VALUES (?, ?, ?, ?, 1, datetime('now'), datetime('now'))
ON CONFLICT (id) DO UPDATE SET
title = excluded.title,
manifest = excluded.manifest,
source = excluded.source,
updated_at = datetime('now')`,
id, title, source, manifestJSON)
return err
}
func (s *PackageStore) List(ctx context.Context) ([]store.PackageRegistration, error) {
return s.scanMany(ctx, `SELECT `+pkgCols+` FROM packages p ORDER BY p.source, p.title`)
}
func (s *PackageStore) Get(ctx context.Context, id string) (*store.PackageRegistration, error) {
return s.scanOne(ctx, `SELECT `+pkgCols+` FROM packages p WHERE p.id = ?`, id)
}
func (s *PackageStore) SetEnabled(ctx context.Context, id string, enabled bool) error {
enabledInt := 0
if enabled {
enabledInt = 1
}
result, err := DB.ExecContext(ctx,
`UPDATE packages SET enabled = ?, updated_at = datetime('now') WHERE id = ?`,
enabledInt, id)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *PackageStore) Delete(ctx context.Context, id string) error {
result, err := DB.ExecContext(ctx,
`DELETE FROM packages WHERE id = ? AND source != 'core'`, id)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *PackageStore) ListEnabled(ctx context.Context) ([]string, error) {
rows, err := DB.QueryContext(ctx,
`SELECT id FROM packages WHERE enabled = 1 ORDER BY title`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
continue
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// ── Extended lifecycle ───────────────────────────
func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistration) error {
now := time.Now().UTC()
manifestJSON := ToJSON(pkg.Manifest)
_, err := DB.ExecContext(ctx, `
INSERT INTO packages (id, title, type, version, description, author, tier,
is_system, scope, team_id, installed_by, manifest, enabled, source,
installed_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
pkg.Tier, pkg.IsSystem, pkg.Scope,
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
manifestJSON, pkg.Enabled, pkg.Source,
now.Format(timeFmt), now.Format(timeFmt),
)
if err != nil {
return err
}
pkg.InstalledAt = now.Format(timeFmt)
pkg.UpdatedAt = now.Format(timeFmt)
return nil
}
func (s *PackageStore) Update(ctx context.Context, id string, pkg *store.PackageRegistration) error {
manifestJSON := ToJSON(pkg.Manifest)
_, err := DB.ExecContext(ctx, `
UPDATE packages SET
title = ?, type = ?, version = ?, description = ?, author = ?,
tier = ?, manifest = ?, is_system = ?, enabled = ?,
updated_at = datetime('now')
WHERE id = ?`,
pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
pkg.Tier, manifestJSON, pkg.IsSystem, pkg.Enabled, id,
)
return err
}
func (s *PackageStore) ListByType(ctx context.Context, pkgType string) ([]store.PackageRegistration, error) {
return s.scanMany(ctx, `SELECT `+pkgCols+` FROM packages p WHERE p.type = ? ORDER BY p.title`, pkgType)
}
func (s *PackageStore) ListEnabledByType(ctx context.Context, pkgType string) ([]store.PackageRegistration, error) {
return s.scanMany(ctx,
`SELECT `+pkgCols+` FROM packages p WHERE p.type = ? AND p.enabled = 1 ORDER BY p.title`,
pkgType)
}
// ── Extension runtime ────────────────────────────
func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.UserPackage, error) {
rows, err := DB.QueryContext(ctx, `
SELECT `+pkgCols+`,
pus.is_enabled AS user_enabled,
pus.settings AS user_settings
FROM packages p
LEFT JOIN package_user_settings pus
ON pus.package_id = p.id AND pus.user_id = ?
WHERE p.enabled = 1
AND p.type IN ('extension', 'full')
AND (p.is_system = 1 OR COALESCE(pus.is_enabled, 1) = 1)
ORDER BY p.title`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.UserPackage
for rows.Next() {
var up store.UserPackage
var teamID, installedBy sql.NullString
var manifestJSON string
var enabledInt int
var isSystemInt int
var userEnabled sql.NullBool
var userSettings []byte
if err := rows.Scan(
&up.ID, &up.Title, &up.Type, &up.Version, &up.Description,
&up.Author, &up.Tier, &isSystemInt, &up.Scope,
&teamID, &installedBy,
&manifestJSON, &enabledInt, &up.Source,
&up.InstalledAt, &up.UpdatedAt,
&userEnabled, &userSettings,
); err != nil {
return nil, err
}
up.IsSystem = isSystemInt != 0
up.Enabled = enabledInt != 0
up.TeamID = NullableStringPtr(teamID)
up.InstalledBy = NullableStringPtr(installedBy)
json.Unmarshal([]byte(manifestJSON), &up.Manifest)
if userEnabled.Valid {
up.UserEnabled = &userEnabled.Bool
}
if userSettings != nil {
raw := json.RawMessage(userSettings)
up.UserSettings = &raw
}
result = append(result, up)
}
if result == nil {
result = make([]store.UserPackage, 0)
}
return result, rows.Err()
}
func (s *PackageStore) GetUserSettings(ctx context.Context, pkgID, userID string) (*store.PackageUserSettings, error) {
var pus store.PackageUserSettings
err := DB.QueryRowContext(ctx, `
SELECT package_id, user_id, settings, is_enabled
FROM package_user_settings
WHERE package_id = ? AND user_id = ?`,
pkgID, userID,
).Scan(&pus.PackageID, &pus.UserID, &pus.Settings, &pus.IsEnabled)
if err != nil {
return nil, err
}
return &pus, nil
}
func (s *PackageStore) SetUserSettings(ctx context.Context, pus *store.PackageUserSettings) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO package_user_settings (package_id, user_id, settings, is_enabled)
VALUES (?, ?, ?, ?)
ON CONFLICT (package_id, user_id)
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
pus.PackageID, pus.UserID, pus.Settings, pus.IsEnabled,
)
return err
}
func (s *PackageStore) DeleteUserSettings(ctx context.Context, pkgID, userID string) error {
_, err := DB.ExecContext(ctx, `
DELETE FROM package_user_settings WHERE package_id = ? AND user_id = ?`,
pkgID, userID,
)
return err
}
// ── Helpers ──────────────────────────────────────
const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author,
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
p.manifest, p.enabled, p.source, p.installed_at, p.updated_at`
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
var pkg store.PackageRegistration
var teamID, installedBy sql.NullString
var manifestJSON string
var enabledInt int
var isSystemInt int
err := DB.QueryRowContext(ctx, query, args...).Scan(
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
&teamID, &installedBy,
&manifestJSON, &enabledInt, &pkg.Source,
&pkg.InstalledAt, &pkg.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
pkg.IsSystem = isSystemInt != 0
pkg.Enabled = enabledInt != 0
pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy)
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
return &pkg, nil
}
func (s *PackageStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]store.PackageRegistration, error) {
rows, err := DB.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.PackageRegistration
for rows.Next() {
var pkg store.PackageRegistration
var teamID, installedBy sql.NullString
var manifestJSON string
var enabledInt int
var isSystemInt int
if err := rows.Scan(
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
&teamID, &installedBy,
&manifestJSON, &enabledInt, &pkg.Source,
&pkg.InstalledAt, &pkg.UpdatedAt,
); err != nil {
return nil, err
}
pkg.IsSystem = isSystemInt != 0
pkg.Enabled = enabledInt != 0
pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy)
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
result = append(result, pkg)
}
return result, rows.Err()
}
func nullStrPtr(s *string) sql.NullString {
if s == nil {
return sql.NullString{}
}
return sql.NullString{String: *s, Valid: true}
}

View File

@@ -26,7 +26,6 @@ func NewStores(db *sql.DB) store.Stores {
GlobalConfig: NewGlobalConfigStore(),
Usage: NewUsageStore(),
Pricing: NewPricingStore(),
Extensions: NewExtensionStore(),
Files: NewFileStore(),
KnowledgeBases: NewKnowledgeBaseStore(),
Groups: NewGroupStore(),
@@ -40,7 +39,7 @@ func NewStores(db *sql.DB) store.Stores {
CapOverrides: NewCapOverrideStore(),
RoutingPolicies: NewRoutingPolicyStore(),
Sessions: NewSessionStore(),
Surfaces: NewSurfaceRegistryStore(),
Packages: NewPackageStore(),
Workflows: NewWorkflowStore(),
Tasks: NewTaskStore(),
}

View File

@@ -1,125 +0,0 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type SurfaceRegistryStore struct{}
func NewSurfaceRegistryStore() *SurfaceRegistryStore { return &SurfaceRegistryStore{} }
// Seed upserts a core surface. Called at startup by the page engine
// to ensure core surfaces exist in the registry. Does NOT overwrite
// the enabled state if the row already exists (admin toggle survives restart).
func (s *SurfaceRegistryStore) Seed(ctx context.Context, id, title, source string, manifest map[string]any) error {
manifestJSON := ToJSON(manifest)
_, err := DB.ExecContext(ctx, `
INSERT INTO surface_registry (id, title, source, manifest, enabled, installed_at, updated_at)
VALUES (?, ?, ?, ?, 1, datetime('now'), datetime('now'))
ON CONFLICT (id) DO UPDATE SET
title = excluded.title,
manifest = excluded.manifest,
source = excluded.source,
updated_at = datetime('now')`,
// Note: ON CONFLICT does NOT update 'enabled' — preserves admin toggle
id, title, source, manifestJSON)
return err
}
func (s *SurfaceRegistryStore) List(ctx context.Context) ([]store.SurfaceRegistration, error) {
rows, err := DB.QueryContext(ctx,
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
FROM surface_registry ORDER BY source, title`)
if err != nil {
return nil, err
}
defer rows.Close()
var surfaces []store.SurfaceRegistration
for rows.Next() {
var sr store.SurfaceRegistration
var manifestJSON string
var enabledInt int
if err := rows.Scan(&sr.ID, &sr.Title, &manifestJSON, &enabledInt, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt); err != nil {
continue
}
sr.Enabled = enabledInt != 0
json.Unmarshal([]byte(manifestJSON), &sr.Manifest)
surfaces = append(surfaces, sr)
}
return surfaces, rows.Err()
}
func (s *SurfaceRegistryStore) Get(ctx context.Context, id string) (*store.SurfaceRegistration, error) {
var sr store.SurfaceRegistration
var manifestJSON string
var enabledInt int
err := DB.QueryRowContext(ctx,
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
FROM surface_registry WHERE id = ?`, id).
Scan(&sr.ID, &sr.Title, &manifestJSON, &enabledInt, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
sr.Enabled = enabledInt != 0
json.Unmarshal([]byte(manifestJSON), &sr.Manifest)
return &sr, nil
}
func (s *SurfaceRegistryStore) SetEnabled(ctx context.Context, id string, enabled bool) error {
enabledInt := 0
if enabled {
enabledInt = 1
}
result, err := DB.ExecContext(ctx,
`UPDATE surface_registry SET enabled = ?, updated_at = datetime('now') WHERE id = ?`,
enabledInt, id)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *SurfaceRegistryStore) Delete(ctx context.Context, id string) error {
// Only allow deleting extension surfaces, not core
result, err := DB.ExecContext(ctx,
`DELETE FROM surface_registry WHERE id = ? AND source = 'extension'`, id)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *SurfaceRegistryStore) ListEnabled(ctx context.Context) ([]string, error) {
rows, err := DB.QueryContext(ctx,
`SELECT id FROM surface_registry WHERE enabled = 1 ORDER BY title`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
continue
}
ids = append(ids, id)
}
return ids, rows.Err()
}

View File

@@ -1,36 +0,0 @@
package store
import "context"
// SurfaceRegistryStore — CRUD for the surface_registry table.
// Core surfaces are seeded at startup. Extension surfaces are managed via admin API.
type SurfaceRegistryStore interface {
// Seed upserts a core surface (called at startup by page engine).
Seed(ctx context.Context, id, title, source string, manifest map[string]any) error
// List returns all registered surfaces, ordered by title.
List(ctx context.Context) ([]SurfaceRegistration, error)
// Get returns a single surface by ID.
Get(ctx context.Context, id string) (*SurfaceRegistration, error)
// SetEnabled toggles a surface's enabled state.
SetEnabled(ctx context.Context, id string, enabled bool) error
// Delete removes an extension surface. Core surfaces cannot be deleted.
Delete(ctx context.Context, id string) error
// ListEnabled returns IDs of all enabled surfaces.
ListEnabled(ctx context.Context) ([]string, error)
}
// SurfaceRegistration is a row from surface_registry.
type SurfaceRegistration struct {
ID string `json:"id" db:"id"`
Title string `json:"title" db:"title"`
Manifest map[string]any `json:"manifest" db:"manifest"`
Enabled bool `json:"enabled" db:"enabled"`
Source string `json:"source" db:"source"`
InstalledAt string `json:"installed_at" db:"installed_at"`
UpdatedAt string `json:"updated_at" db:"updated_at"`
}

191
src/js/admin-packages.js Normal file
View File

@@ -0,0 +1,191 @@
// ==========================================
// Chat Switchboard — Admin Packages UI
// ==========================================
// Renders in the "Packages" admin section via ADMIN_LOADERS.
// Replaces admin-surfaces.js (v0.28.7).
//
// Exports: window._loadAdminPackages
async function _loadAdminPackages() {
const container = document.getElementById('adminPackagesContent');
if (!container) return;
container.innerHTML =
'<div style="margin-bottom:16px">' +
'<p class="text-muted" style="font-size:12px;margin-bottom:12px">' +
'Surfaces, extensions, and full packages. Install .pkg or .surface archives.' +
'</p>' +
'<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px">' +
'<button class="btn-small" id="pkgFilterAll">All</button>' +
'<button class="btn-small" id="pkgFilterSurface">Surfaces</button>' +
'<button class="btn-small" id="pkgFilterExtension">Extensions</button>' +
'<button class="btn-small" id="pkgFilterFull">Full</button>' +
'</div>' +
'</div>' +
'<div id="adminPkgInstallForm" style="display:none;margin-bottom:16px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:8px">' +
'<h4 style="margin:0 0 8px 0;font-size:13px">Install Package</h4>' +
'<input type="file" id="pkgInstallFile" accept=".pkg,.surface,.zip" style="font-size:12px">' +
'<div style="display:flex;gap:8px;margin-top:8px">' +
'<button class="btn-small btn-primary" id="pkgInstallSubmit">Upload & Install</button>' +
'<button class="btn-small" id="pkgInstallCancel">Cancel</button>' +
'</div>' +
'</div>' +
'<div id="adminPkgList" class="admin-list"><div class="loading">Loading...</div></div>';
var base = document.body.dataset.basePath || '';
var currentFilter = '';
// Filter buttons
['All', 'Surface', 'Extension', 'Full'].forEach(function(label) {
var btn = document.getElementById('pkgFilter' + label);
if (btn) {
btn.addEventListener('click', function() {
currentFilter = label === 'All' ? '' : label.toLowerCase();
loadList();
});
}
});
// Install form
var installSubmit = document.getElementById('pkgInstallSubmit');
var installCancel = document.getElementById('pkgInstallCancel');
if (installSubmit) {
installSubmit.addEventListener('click', async function() {
var fileInput = document.getElementById('pkgInstallFile');
if (!fileInput || !fileInput.files[0]) {
UI.toast('Select a .pkg, .surface, or .zip file', 'warning');
return;
}
var fd = new FormData();
fd.append('file', fileInput.files[0]);
try {
var resp = await fetch(base + '/api/v1/admin/packages/install', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + API.accessToken },
body: fd,
});
if (!resp.ok) {
var err = await resp.json();
UI.toast(err.error || 'Install failed', 'error');
return;
}
var data = await resp.json();
UI.toast('Installed: ' + (data.title || data.id), 'success');
fileInput.value = '';
document.getElementById('adminPkgInstallForm').style.display = 'none';
loadList();
} catch (e) {
UI.toast('Install failed: ' + e.message, 'error');
}
});
}
if (installCancel) {
installCancel.addEventListener('click', function() {
document.getElementById('adminPkgInstallForm').style.display = 'none';
});
}
async function loadList() {
var listEl = document.getElementById('adminPkgList');
if (!listEl) return;
listEl.innerHTML = '<div class="loading">Loading...</div>';
try {
var url = '/api/v1/admin/packages';
if (currentFilter) url += '?type=' + currentFilter;
var resp = await API._get(url);
var packages = resp.data || [];
if (packages.length === 0) {
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--text-3);">No packages found</div>';
return;
}
var html = '<table class="admin-table" style="width:100%"><thead><tr>' +
'<th style="width:30%">Package</th>' +
'<th>Type</th>' +
'<th>Version</th>' +
'<th>Source</th>' +
'<th>Status</th>' +
'<th style="width:100px">Actions</th>' +
'</tr></thead><tbody>';
packages.forEach(function(pkg) {
var typeBadge = {
surface: '<span class="badge badge-info">surface</span>',
extension: '<span class="badge badge-accent">extension</span>',
full: '<span class="badge badge-warning">full</span>',
}[pkg.type] || '<span class="badge">' + pkg.type + '</span>';
var sourceBadge = {
core: '<span class="text-muted">core</span>',
builtin: '<span class="text-muted">builtin</span>',
extension: '<span class="text-muted">installed</span>',
}[pkg.source] || pkg.source;
var statusBadge = pkg.enabled
? '<span class="badge badge-success">enabled</span>'
: '<span class="badge badge-muted">disabled</span>';
var actions = '';
// Toggle enable/disable (not for chat/admin)
if (pkg.id !== 'chat' && pkg.id !== 'admin') {
if (pkg.enabled) {
actions += '<button class="btn-small btn-ghost pkg-disable" data-id="' + pkg.id + '" title="Disable">Disable</button> ';
} else {
actions += '<button class="btn-small btn-ghost pkg-enable" data-id="' + pkg.id + '" title="Enable">Enable</button> ';
}
}
// Delete (non-core only)
if (pkg.source !== 'core') {
actions += '<button class="btn-small btn-danger-ghost pkg-delete" data-id="' + pkg.id + '" title="Delete">Delete</button>';
}
var desc = pkg.description ? '<div class="text-muted" style="font-size:11px">' + pkg.description + '</div>' : '';
var system = pkg.is_system ? ' <span class="badge badge-muted" style="font-size:10px">system</span>' : '';
html += '<tr>' +
'<td><strong>' + pkg.title + '</strong>' + system + '<div class="text-muted" style="font-size:11px">' + pkg.id + '</div>' + desc + '</td>' +
'<td>' + typeBadge + '</td>' +
'<td style="font-size:12px;font-family:monospace">' + (pkg.version || '—') + '</td>' +
'<td>' + sourceBadge + '</td>' +
'<td>' + statusBadge + '</td>' +
'<td>' + actions + '</td>' +
'</tr>';
});
html += '</tbody></table>';
listEl.innerHTML = html;
// Wire action buttons
listEl.querySelectorAll('.pkg-enable').forEach(function(btn) {
btn.addEventListener('click', async function() {
await API._put('/api/v1/admin/packages/' + btn.dataset.id + '/enable');
UI.toast('Enabled', 'success');
loadList();
});
});
listEl.querySelectorAll('.pkg-disable').forEach(function(btn) {
btn.addEventListener('click', async function() {
await API._put('/api/v1/admin/packages/' + btn.dataset.id + '/disable');
UI.toast('Disabled', 'success');
loadList();
});
});
listEl.querySelectorAll('.pkg-delete').forEach(function(btn) {
btn.addEventListener('click', async function() {
if (!confirm('Delete package "' + btn.dataset.id + '"? This cannot be undone.')) return;
await API._del('/api/v1/admin/packages/' + btn.dataset.id);
UI.toast('Deleted', 'success');
loadList();
});
});
} catch (e) {
listEl.innerHTML = '<div style="padding:12px;color:var(--danger);">Failed to load packages: ' + e.message + '</div>';
}
}
loadList();
}
sb.register('_loadAdminPackages', _loadAdminPackages);

View File

@@ -190,30 +190,8 @@
SCAFFOLDING.storage = '<div id="adminStorageContent"></div>';
SCAFFOLDING.extensions =
'<div id="adminExtensionsList" class="admin-list"></div>' +
'<div id="adminInstallExtForm" class="admin-inline-form" style="display:none">' +
'<h4>Install Extension</h4>' +
'<div class="form-row">' +
'<div class="form-group"><label>ID</label><input type="text" id="extInstallId" placeholder="my-extension"></div>' +
'<div class="form-group"><label>Name</label><input type="text" id="extInstallName" placeholder="My Extension"></div>' +
'<div class="form-group"><label>Version</label><input type="text" id="extInstallVersion" placeholder="1.0.0"></div>' +
'</div>' +
'<div class="form-row">' +
'<div class="form-group"><label>Author</label><input type="text" id="extInstallAuthor" placeholder="Author"></div>' +
'<div class="form-group"><label>System</label>' +
'<select id="extInstallSystem"><option value="browser">Browser JS</option><option value="starlark">Starlark</option><option value="sidecar">Sidecar</option></select>' +
'</div>' +
'</div>' +
'<div class="form-group"><label>Description</label><textarea id="extInstallDesc" rows="2" placeholder="What it does..."></textarea></div>' +
'<div class="form-group"><label>Manifest JSON</label><textarea id="extInstallManifest" rows="4"></textarea></div>' +
'<div class="form-group"><label>Script Source</label><textarea id="extInstallScript" rows="6"></textarea></div>' +
'<label class="toggle-label"><input type="checkbox" id="extInstallEnabled" checked><span class="toggle-track"></span><span>Enabled</span></label>' +
'<div class="form-row" style="margin-top:8px">' +
'<button class="btn-small btn-primary" id="adminInstallExtSubmit">Install</button>' +
'<button class="btn-small" id="adminInstallExtCancelBtn">Cancel</button>' +
'</div>' +
'</div>';
SCAFFOLDING.packages =
'<div id="adminPackagesContent"></div>';
// -- Routing ----------------------------------------------------------
@@ -268,9 +246,6 @@
'</div>' +
'<div id="adminArchivedList" class="admin-list"><div class="loading">Loading...</div></div>';
SCAFFOLDING.surfaces =
'<div id="adminSurfacesContent"></div>';
// === Add button actions ==============================================
@@ -303,8 +278,8 @@
var n = document.getElementById('adminGroupName');
if (n) n.focus();
},
extensions: function() {
var f = document.getElementById('adminInstallExtForm');
packages: function() {
var f = document.getElementById('adminPkgInstallForm');
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
},
};

View File

@@ -1,199 +0,0 @@
// ==========================================
// Chat Switchboard — Admin Surfaces UI
// ==========================================
// v0.25.0: Surface lifecycle management.
// Renders in the "Surfaces" admin section via ADMIN_LOADERS.
//
// Exports: window._loadAdminSurfaces
async function _loadAdminSurfaces() {
const container = document.getElementById('adminSurfacesContent');
if (!container) return;
container.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<p style="font-size:13px;color:var(--text-2);margin:0;line-height:1.6;flex:1;">
Manage which surfaces are available. Disabled surfaces redirect to Chat and are hidden from navigation.
</p>
<button id="adminSurfaceInstallBtn" class="btn-small" style="margin-left:16px;white-space:nowrap;">
+ Install Surface
</button>
</div>
<div id="adminSurfaceInstallForm" style="display:none;margin-bottom:16px;padding:16px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;">
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">Install Surface Archive</div>
<p style="font-size:12px;color:var(--text-3);margin:0 0 12px;">Upload a <code>.surface</code> or <code>.zip</code> archive containing manifest.json, js/, css/, and assets/.</p>
<div style="display:flex;align-items:center;gap:8px;">
<input type="file" id="adminSurfaceFile" accept=".surface,.zip" style="font-size:12px;color:var(--text-2);">
<button id="adminSurfaceUploadBtn" class="btn-small btn-primary" disabled>Upload</button>
<button id="adminSurfaceCancelBtn" class="btn-small">Cancel</button>
</div>
<div id="adminSurfaceInstallStatus" style="font-size:12px;margin-top:8px;"></div>
</div>
<div id="adminSurfaceList" class="admin-surface-list">
<div style="padding:12px;font-size:12px;color:var(--text-3);">Loading…</div>
</div>
`;
// Wire install form
var installBtn = document.getElementById('adminSurfaceInstallBtn');
var installForm = document.getElementById('adminSurfaceInstallForm');
var fileInput = document.getElementById('adminSurfaceFile');
var uploadBtn = document.getElementById('adminSurfaceUploadBtn');
var cancelBtn = document.getElementById('adminSurfaceCancelBtn');
var statusEl = document.getElementById('adminSurfaceInstallStatus');
if (installBtn) installBtn.addEventListener('click', function() {
installForm.style.display = '';
installBtn.style.display = 'none';
});
if (cancelBtn) cancelBtn.addEventListener('click', function() {
installForm.style.display = 'none';
installBtn.style.display = '';
fileInput.value = '';
uploadBtn.disabled = true;
statusEl.textContent = '';
});
if (fileInput) fileInput.addEventListener('change', function() {
uploadBtn.disabled = !fileInput.files.length;
});
if (uploadBtn) uploadBtn.addEventListener('click', async function() {
var file = fileInput.files[0];
if (!file) return;
uploadBtn.disabled = true;
uploadBtn.textContent = 'Uploading…';
statusEl.textContent = '';
statusEl.style.color = 'var(--text-3)';
try {
var formData = new FormData();
formData.append('file', file);
var base = window.__BASE__ || '';
var resp = await fetch(base + '/api/v1/admin/surfaces/install', {
method: 'POST',
headers: API._authHeaders ? (function() { var h = API._authHeaders(); delete h['Content-Type']; return h; })() : {},
body: formData,
});
var data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Upload failed');
statusEl.style.color = 'var(--success)';
statusEl.textContent = 'Installed: ' + (data.title || data.id);
if (typeof UI !== 'undefined') UI.toast('Surface installed: ' + (data.title || data.id), 'success');
// Refresh the list
installForm.style.display = 'none';
installBtn.style.display = '';
fileInput.value = '';
_loadSurfaceList();
} catch (e) {
statusEl.style.color = 'var(--danger)';
statusEl.textContent = 'Error: ' + e.message;
uploadBtn.disabled = false;
uploadBtn.textContent = 'Upload';
}
});
_loadSurfaceList();
}
async function _loadSurfaceList() {
var listEl = document.getElementById('adminSurfaceList');
if (!listEl) return;
try {
var resp = await API._get('/api/v1/admin/surfaces');
var surfaces = resp.surfaces || [];
if (surfaces.length === 0) {
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--text-3);">No surfaces registered</div>';
return;
}
listEl.innerHTML = '';
surfaces.forEach(function(s) {
var row = document.createElement('div');
row.className = 'admin-surface-row';
row.dataset.surfaceId = s.id;
var isProtected = s.id === 'chat' || s.id === 'admin';
var sourceLabel = s.source === 'core' ? 'Core' : 'Extension';
var route = s.manifest && s.manifest.route ? s.manifest.route : '';
row.innerHTML =
'<div class="admin-surface-info">' +
'<div class="admin-surface-title">' +
'<span class="admin-surface-name">' + esc(s.title) + '</span>' +
'<span class="admin-surface-badge admin-surface-badge--' + s.source + '">' + sourceLabel + '</span>' +
(isProtected ? '<span class="admin-surface-badge admin-surface-badge--required">Required</span>' : '') +
'</div>' +
'<div class="admin-surface-meta">' +
'<span class="admin-surface-id">' + esc(s.id) + '</span>' +
(route ? '<span class="admin-surface-route">' + esc(route) + '</span>' : '') +
'</div>' +
'</div>' +
'<div class="admin-surface-actions">' +
'<label class="admin-surface-toggle' + (isProtected ? ' admin-surface-toggle--locked' : '') + '">' +
'<input type="checkbox"' + (s.enabled ? ' checked' : '') + (isProtected ? ' disabled' : '') +
' data-surface-id="' + esc(s.id) + '">' +
'<span class="admin-surface-toggle-label">' + (s.enabled ? 'Enabled' : 'Disabled') + '</span>' +
'</label>' +
(s.source === 'extension' ? '<button class="btn-small btn-danger admin-surface-uninstall" data-surface-id="' + esc(s.id) + '">Uninstall</button>' : '') +
'</div>';
// Toggle handler
var checkbox = row.querySelector('input[type="checkbox"]');
if (checkbox && !isProtected) {
checkbox.addEventListener('change', (function(surface) {
return async function() {
var id = this.dataset.surfaceId;
var action = this.checked ? 'enable' : 'disable';
var label = row.querySelector('.admin-surface-toggle-label');
var cb = this;
try {
await API._put('/api/v1/admin/surfaces/' + id + '/' + action);
if (label) label.textContent = cb.checked ? 'Enabled' : 'Disabled';
if (typeof UI !== 'undefined') {
UI.toast(surface.title + ' ' + (cb.checked ? 'enabled' : 'disabled') + ' — takes effect on next page load', 'success');
}
} catch (e) {
cb.checked = !cb.checked;
if (label) label.textContent = cb.checked ? 'Enabled' : 'Disabled';
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
}
};
})(s));
}
// Uninstall handler
var uninstallBtn = row.querySelector('.admin-surface-uninstall');
if (uninstallBtn) {
uninstallBtn.addEventListener('click', (function(surface) {
return async function() {
var ok = typeof showConfirm === 'function'
? await showConfirm('Uninstall ' + surface.title + '? This removes all files.')
: window.confirm('Uninstall ' + surface.title + '?');
if (!ok) return;
try {
await API._del('/api/v1/admin/surfaces/' + surface.id);
row.remove();
if (typeof UI !== 'undefined') UI.toast(surface.title + ' uninstalled', 'success');
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
}
};
})(s));
}
listEl.appendChild(row);
});
} catch (e) {
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--danger);">Failed to load: ' + esc(e.message) + '</div>';
}
}
// ── Exports ─────────────────────────────────
sb.register('_loadAdminSurfaces', _loadAdminSurfaces);

View File

@@ -11,7 +11,7 @@ const ADMIN_SECTIONS = {
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
workflows: ['workflows', 'tasks'],
routing: ['health', 'routing', 'capabilities'],
system: ['settings', 'storage', 'extensions', 'channels', 'surfaces', 'broadcast'],
system: ['settings', 'storage', 'packages', 'channels', 'broadcast'],
monitoring: ['usage', 'audit', 'stats'],
};
@@ -20,7 +20,7 @@ const ADMIN_LABELS = {
providers: 'Providers', models: 'Models', personas: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
workflows: 'Workflows', tasks: 'Tasks',
settings: 'Settings', storage: 'Storage', extensions: 'Extensions', channels: 'Channels', surfaces: 'Surfaces', broadcast: 'Broadcast',
settings: 'Settings', storage: 'Storage', packages: 'Packages', channels: 'Channels', broadcast: 'Broadcast',
usage: 'Usage', audit: 'Audit', stats: 'Stats',
};
@@ -43,8 +43,7 @@ const ADMIN_LOADERS = {
},
settings: () => UI.loadAdminSettings(),
storage: () => typeof loadAdminStorage === 'function' ? loadAdminStorage() : null,
extensions: () => UI.loadAdminExtensions(),
surfaces: () => typeof _loadAdminSurfaces === 'function' ? _loadAdminSurfaces() : null,
packages: () => typeof _loadAdminPackages === 'function' ? _loadAdminPackages() : null,
health: () => UI.loadAdminHealth(),
routing: () => UI.loadAdminRouting(),
capabilities: () => UI.loadAdminCapabilities(),

View File

@@ -1,60 +0,0 @@
# surfaces/
Source directories for installable extension surfaces. Each subdirectory
contains a `manifest.json` plus `js/`, `css/`, and optionally `assets/`
directories — matching the archive format expected by
`POST /admin/surfaces/install`.
## Structure
```
surfaces/
build.sh ← packages each subdir into dist/<name>.surface
hello-dashboard/ ← sample surface (verifies /s/:slug pipeline)
manifest.json
js/main.js
css/main.css
icd-test-runner/ ← ICD contract test runner (dev/test tool)
manifest.json
js/
css/
```
## Building
```bash
# Build all surfaces → dist/*.surface
./surfaces/build.sh
# Build one
./surfaces/build.sh icd-test-runner
```
Output goes to `dist/` (gitignored). Each `.surface` file is a zip
archive ready for upload via the admin UI or:
```bash
curl -X POST https://host/api/v1/admin/surfaces/install \
-H "Authorization: Bearer $TOKEN" \
-F "file=@dist/icd-test-runner.surface"
```
## Adding a surface
1. Create `surfaces/<id>/manifest.json` with at least `id` and `title`
2. Add `js/`, `css/`, `assets/` as needed
3. Run `./surfaces/build.sh <id>` to verify it packages
4. Install via admin UI or API
See [EXTENSION-SURFACES.md](../docs/EXTENSION-SURFACES.md) for the full
authoring guide including manifest schema, platform API, and CSS custom
properties.
## Conventions
- `id` in manifest.json must be unique across all surfaces (core + extension)
- Core surfaces (`chat`, `admin`, `settings`, `notes`, `editor`) cannot
be overridden — the install endpoint returns 409
- Surface routes are served at `/s/<id>` (auto-derived from manifest)
- Static assets are served from `/surfaces/<id>/js/`, `/surfaces/<id>/css/`
- The `dist/` directory is gitignored — archives are build artifacts

Binary file not shown.