diff --git a/CHANGELOG.md b/CHANGELOG.md index e5eb969..db738c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 2aa6ed7..cf86fe7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.28.6 +0.28.7 diff --git a/docker-entrypoint-fe.sh b/docker-entrypoint-fe.sh index 8173e04..b77e408 100644 --- a/docker-entrypoint-fe.sh +++ b/docker-entrypoint-fe.sh @@ -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 ) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c0c9eb5..97b4a26 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -33,10 +33,15 @@ v0.9.x–v0.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: "}`. + 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 diff --git a/nginx.conf b/nginx.conf index cd1ed78..bc43e55 100644 --- a/nginx.conf +++ b/nginx.conf @@ -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"; } diff --git a/packages/README.md b/packages/README.md new file mode 100644 index 0000000..cfff891 --- /dev/null +++ b/packages/README.md @@ -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. diff --git a/surfaces/build.sh b/packages/build.sh similarity index 64% rename from surfaces/build.sh rename to packages/build.sh index 0a8bed3..1087006 100644 --- a/surfaces/build.sh +++ b/packages/build.sh @@ -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/.surface (zip archives, ready for POST /admin/surfaces/install) +# Output: dist/.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)" diff --git a/surfaces/hello-dashboard/css/main.css b/packages/hello-dashboard/css/main.css similarity index 100% rename from surfaces/hello-dashboard/css/main.css rename to packages/hello-dashboard/css/main.css diff --git a/surfaces/hello-dashboard/js/main.js b/packages/hello-dashboard/js/main.js similarity index 100% rename from surfaces/hello-dashboard/js/main.js rename to packages/hello-dashboard/js/main.js diff --git a/surfaces/hello-dashboard/manifest.json b/packages/hello-dashboard/manifest.json similarity index 100% rename from surfaces/hello-dashboard/manifest.json rename to packages/hello-dashboard/manifest.json diff --git a/surfaces/icd-test-runner/css/main.css b/packages/icd-test-runner/css/main.css similarity index 100% rename from surfaces/icd-test-runner/css/main.css rename to packages/icd-test-runner/css/main.css diff --git a/surfaces/icd-test-runner/js/crud/_helpers.js b/packages/icd-test-runner/js/crud/_helpers.js similarity index 100% rename from surfaces/icd-test-runner/js/crud/_helpers.js rename to packages/icd-test-runner/js/crud/_helpers.js diff --git a/surfaces/icd-test-runner/js/crud/admin.js b/packages/icd-test-runner/js/crud/admin.js similarity index 100% rename from surfaces/icd-test-runner/js/crud/admin.js rename to packages/icd-test-runner/js/crud/admin.js diff --git a/surfaces/icd-test-runner/js/crud/channels.js b/packages/icd-test-runner/js/crud/channels.js similarity index 100% rename from surfaces/icd-test-runner/js/crud/channels.js rename to packages/icd-test-runner/js/crud/channels.js diff --git a/surfaces/icd-test-runner/js/crud/extensions.js b/packages/icd-test-runner/js/crud/extensions.js similarity index 97% rename from surfaces/icd-test-runner/js/crud/extensions.js rename to packages/icd-test-runner/js/crud/extensions.js index 0650de5..4e69c6d 100644 --- a/surfaces/icd-test-runner/js/crud/extensions.js +++ b/packages/icd-test-runner/js/crud/extensions.js @@ -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'); }); diff --git a/surfaces/icd-test-runner/js/crud/knowledge.js b/packages/icd-test-runner/js/crud/knowledge.js similarity index 100% rename from surfaces/icd-test-runner/js/crud/knowledge.js rename to packages/icd-test-runner/js/crud/knowledge.js diff --git a/surfaces/icd-test-runner/js/crud/memory.js b/packages/icd-test-runner/js/crud/memory.js similarity index 100% rename from surfaces/icd-test-runner/js/crud/memory.js rename to packages/icd-test-runner/js/crud/memory.js diff --git a/surfaces/icd-test-runner/js/crud/models.js b/packages/icd-test-runner/js/crud/models.js similarity index 100% rename from surfaces/icd-test-runner/js/crud/models.js rename to packages/icd-test-runner/js/crud/models.js diff --git a/surfaces/icd-test-runner/js/crud/notes.js b/packages/icd-test-runner/js/crud/notes.js similarity index 100% rename from surfaces/icd-test-runner/js/crud/notes.js rename to packages/icd-test-runner/js/crud/notes.js diff --git a/surfaces/icd-test-runner/js/crud/notifications.js b/packages/icd-test-runner/js/crud/notifications.js similarity index 100% rename from surfaces/icd-test-runner/js/crud/notifications.js rename to packages/icd-test-runner/js/crud/notifications.js diff --git a/surfaces/icd-test-runner/js/crud/personas.js b/packages/icd-test-runner/js/crud/personas.js similarity index 100% rename from surfaces/icd-test-runner/js/crud/personas.js rename to packages/icd-test-runner/js/crud/personas.js diff --git a/surfaces/icd-test-runner/js/crud/profile.js b/packages/icd-test-runner/js/crud/profile.js similarity index 100% rename from surfaces/icd-test-runner/js/crud/profile.js rename to packages/icd-test-runner/js/crud/profile.js diff --git a/surfaces/icd-test-runner/js/crud/projects.js b/packages/icd-test-runner/js/crud/projects.js similarity index 100% rename from surfaces/icd-test-runner/js/crud/projects.js rename to packages/icd-test-runner/js/crud/projects.js diff --git a/surfaces/icd-test-runner/js/crud/surfaces.js b/packages/icd-test-runner/js/crud/surfaces.js similarity index 93% rename from surfaces/icd-test-runner/js/crud/surfaces.js rename to packages/icd-test-runner/js/crud/surfaces.js index ad5530d..779c96d 100644 --- a/surfaces/icd-test-runner/js/crud/surfaces.js +++ b/packages/icd-test-runner/js/crud/surfaces.js @@ -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'); }); diff --git a/surfaces/icd-test-runner/js/crud/tasks.js b/packages/icd-test-runner/js/crud/tasks.js similarity index 100% rename from surfaces/icd-test-runner/js/crud/tasks.js rename to packages/icd-test-runner/js/crud/tasks.js diff --git a/surfaces/icd-test-runner/js/crud/teams.js b/packages/icd-test-runner/js/crud/teams.js similarity index 100% rename from surfaces/icd-test-runner/js/crud/teams.js rename to packages/icd-test-runner/js/crud/teams.js diff --git a/surfaces/icd-test-runner/js/crud/workflows.js b/packages/icd-test-runner/js/crud/workflows.js similarity index 100% rename from surfaces/icd-test-runner/js/crud/workflows.js rename to packages/icd-test-runner/js/crud/workflows.js diff --git a/surfaces/icd-test-runner/js/crud/workspaces.js b/packages/icd-test-runner/js/crud/workspaces.js similarity index 100% rename from surfaces/icd-test-runner/js/crud/workspaces.js rename to packages/icd-test-runner/js/crud/workspaces.js diff --git a/surfaces/icd-test-runner/js/fixtures.js b/packages/icd-test-runner/js/fixtures.js similarity index 100% rename from surfaces/icd-test-runner/js/fixtures.js rename to packages/icd-test-runner/js/fixtures.js diff --git a/surfaces/icd-test-runner/js/framework.js b/packages/icd-test-runner/js/framework.js similarity index 98% rename from surfaces/icd-test-runner/js/framework.js rename to packages/icd-test-runner/js/framework.js index 4def217..7990668 100644 --- a/surfaces/icd-test-runner/js/framework.js +++ b/packages/icd-test-runner/js/framework.js @@ -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' }; diff --git a/surfaces/icd-test-runner/js/main.js b/packages/icd-test-runner/js/main.js similarity index 100% rename from surfaces/icd-test-runner/js/main.js rename to packages/icd-test-runner/js/main.js diff --git a/surfaces/icd-test-runner/js/tier-authz.js b/packages/icd-test-runner/js/tier-authz.js similarity index 100% rename from surfaces/icd-test-runner/js/tier-authz.js rename to packages/icd-test-runner/js/tier-authz.js diff --git a/surfaces/icd-test-runner/js/tier-crud.js b/packages/icd-test-runner/js/tier-crud.js similarity index 100% rename from surfaces/icd-test-runner/js/tier-crud.js rename to packages/icd-test-runner/js/tier-crud.js diff --git a/surfaces/icd-test-runner/js/tier-providers.js b/packages/icd-test-runner/js/tier-providers.js similarity index 100% rename from surfaces/icd-test-runner/js/tier-providers.js rename to packages/icd-test-runner/js/tier-providers.js diff --git a/surfaces/icd-test-runner/js/tier-sdk.js b/packages/icd-test-runner/js/tier-sdk.js similarity index 100% rename from surfaces/icd-test-runner/js/tier-sdk.js rename to packages/icd-test-runner/js/tier-sdk.js diff --git a/surfaces/icd-test-runner/js/tier-security.js b/packages/icd-test-runner/js/tier-security.js similarity index 100% rename from surfaces/icd-test-runner/js/tier-security.js rename to packages/icd-test-runner/js/tier-security.js diff --git a/surfaces/icd-test-runner/js/tier-smoke.js b/packages/icd-test-runner/js/tier-smoke.js similarity index 97% rename from surfaces/icd-test-runner/js/tier-smoke.js rename to packages/icd-test-runner/js/tier-smoke.js index 102f70a..23a29ec 100644 --- a/surfaces/icd-test-runner/js/tier-smoke.js +++ b/packages/icd-test-runner/js/tier-smoke.js @@ -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]'); } }); diff --git a/surfaces/icd-test-runner/js/ui.js b/packages/icd-test-runner/js/ui.js similarity index 100% rename from surfaces/icd-test-runner/js/ui.js rename to packages/icd-test-runner/js/ui.js diff --git a/surfaces/icd-test-runner/manifest.json b/packages/icd-test-runner/manifest.json similarity index 91% rename from surfaces/icd-test-runner/manifest.json rename to packages/icd-test-runner/manifest.json index c89dd97..8bb3fb0 100644 --- a/surfaces/icd-test-runner/manifest.json +++ b/packages/icd-test-runner/manifest.json @@ -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." } diff --git a/pages/loaders.go b/pages/loaders.go new file mode 100644 index 0000000..9244048 --- /dev/null +++ b/pages/loaders.go @@ -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 +} diff --git a/server/auth/permissions.go b/server/auth/permissions.go index 5b1c68f..cb04bbe 100644 --- a/server/auth/permissions.go +++ b/server/auth/permissions.go @@ -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 ────────────────────────────── diff --git a/server/database/migrations/012_extensions.sql b/server/database/migrations/012_extensions.sql deleted file mode 100644 index 9d07033..0000000 --- a/server/database/migrations/012_extensions.sql +++ /dev/null @@ -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) -); diff --git a/server/database/migrations/012_package_user_settings.sql b/server/database/migrations/012_package_user_settings.sql new file mode 100644 index 0000000..2f687b6 --- /dev/null +++ b/server/database/migrations/012_package_user_settings.sql @@ -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. +-- ========================================== diff --git a/server/database/migrations/016_packages.sql b/server/database/migrations/016_packages.sql new file mode 100644 index 0000000..b4d4327 --- /dev/null +++ b/server/database/migrations/016_packages.sql @@ -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) +); diff --git a/server/database/migrations/016_surfaces.sql b/server/database/migrations/016_surfaces.sql deleted file mode 100644 index b265b0f..0000000 --- a/server/database/migrations/016_surfaces.sql +++ /dev/null @@ -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'; diff --git a/server/database/migrations/sqlite/012_extensions.sql b/server/database/migrations/sqlite/012_extensions.sql deleted file mode 100644 index 28d31d3..0000000 --- a/server/database/migrations/sqlite/012_extensions.sql +++ /dev/null @@ -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) -); diff --git a/server/database/migrations/sqlite/012_package_user_settings.sql b/server/database/migrations/sqlite/012_package_user_settings.sql new file mode 100644 index 0000000..3921b0f --- /dev/null +++ b/server/database/migrations/sqlite/012_package_user_settings.sql @@ -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. diff --git a/server/database/migrations/sqlite/016_packages.sql b/server/database/migrations/sqlite/016_packages.sql new file mode 100644 index 0000000..fe27785 --- /dev/null +++ b/server/database/migrations/sqlite/016_packages.sql @@ -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) +); diff --git a/server/database/migrations/sqlite/016_surfaces.sql b/server/database/migrations/sqlite/016_surfaces.sql deleted file mode 100644 index 9bb6810..0000000 --- a/server/database/migrations/sqlite/016_surfaces.sql +++ /dev/null @@ -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')) -); diff --git a/server/database/testhelper.go b/server/database/testhelper.go index 61f0b84..2defc7f 100644 --- a/server/database/testhelper.go +++ b/server/database/testhelper.go @@ -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", } diff --git a/server/handlers/completion.go b/server/handlers/completion.go index 5b554c5..ddcda92 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -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 { diff --git a/server/handlers/extension_test.go b/server/handlers/extension_test.go index 2921f7d..151e871 100644 --- a/server/handlers/extension_test.go +++ b/server/handlers/extension_test.go @@ -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") } } diff --git a/server/handlers/extensions.go b/server/handlers/extensions.go index e39573c..ee3a0dd 100644 --- a/server/handlers/extensions.go +++ b/server/handlers/extensions.go @@ -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 +} diff --git a/server/handlers/packages.go b/server/handlers/packages.go new file mode 100644 index 0000000..c96984f --- /dev/null +++ b/server/handlers/packages.go @@ -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}) +} diff --git a/server/handlers/resolve.go b/server/handlers/resolve.go index eb8e867..3ccf704 100644 --- a/server/handlers/resolve.go +++ b/server/handlers/resolve.go @@ -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 { diff --git a/server/handlers/seed_extensions.go b/server/handlers/seed_packages.go similarity index 56% rename from server/handlers/seed_extensions.go rename to server/handlers/seed_packages.go index b3593b5..11110c8 100644 --- a/server/handlers/seed_extensions.go +++ b/server/handlers/seed_packages.go @@ -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) } diff --git a/server/handlers/surface_store_test.go b/server/handlers/surface_store_test.go deleted file mode 100644 index cb92ff8..0000000 --- a/server/handlers/surface_store_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/server/handlers/surfaces.go b/server/handlers/surfaces.go deleted file mode 100644 index 57f12c7..0000000 --- a/server/handlers/surfaces.go +++ /dev/null @@ -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}) -} diff --git a/server/handlers/surfaces_test.go b/server/handlers/surfaces_test.go deleted file mode 100644 index 5f7ecfb..0000000 --- a/server/handlers/surfaces_test.go +++ /dev/null @@ -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() -} diff --git a/server/handlers/task_test.go b/server/handlers/task_test.go index 6c76f2b..249f91b 100644 --- a/server/handlers/task_test.go +++ b/server/handlers/task_test.go @@ -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 // ═══════════════════════════════════════════════ diff --git a/server/handlers/tasks.go b/server/handlers/tasks.go index ef5cc01..4c689e4 100644 --- a/server/handlers/tasks.go +++ b/server/handlers/tasks.go @@ -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). diff --git a/server/main.go b/server/main.go index 3fae41f..da103ca 100644 --- a/server/main.go +++ b/server/main.go @@ -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 } diff --git a/server/pages/pages.go b/server/pages/pages.go index b67e19a..15ad904 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -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 diff --git a/server/pages/pages_surfaces.go b/server/pages/pages_surfaces.go index 24e64c2..747f22e 100644 --- a/server/pages/pages_surfaces.go +++ b/server/pages/pages_surfaces.go @@ -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 diff --git a/server/pages/templates/surfaces/admin.html b/server/pages/templates/surfaces/admin.html index e36f715..4ff18ca 100644 --- a/server/pages/templates/surfaces/admin.html +++ b/server/pages/templates/surfaces/admin.html @@ -219,7 +219,7 @@ - +