Changeset 0.28.7 (#193)
This commit is contained in:
60
CHANGELOG.md
60
CHANGELOG.md
@@ -1,5 +1,65 @@
|
|||||||
# Changelog
|
# 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
|
## [0.28.6] — 2026-03-15
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ $(page_proxy_block "${BASE_PATH}/settings")
|
|||||||
$(page_proxy_block "${BASE_PATH}/w")
|
$(page_proxy_block "${BASE_PATH}/w")
|
||||||
# v0.27.0: Extension surface page routes
|
# v0.27.0: Extension surface page routes
|
||||||
$(page_proxy_block "${BASE_PATH}/s")
|
$(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")
|
$(page_proxy_block "${BASE_PATH}/surfaces")
|
||||||
ROUTES
|
ROUTES
|
||||||
)
|
)
|
||||||
|
|||||||
141
docs/ROADMAP.md
141
docs/ROADMAP.md
@@ -33,10 +33,15 @@ v0.9.x–v0.27.5 Foundation → Extensions → Surfaces → Auth ✅
|
|||||||
├─ v0.28.6 Infrastructure ✅
|
├─ v0.28.6 Infrastructure ✅
|
||||||
│ (virtual scroll, Helm, system tasks,
|
│ (virtual scroll, Helm, system tasks,
|
||||||
│ broadcast, git keygen, model prefs)
|
│ broadcast, git keygen, model prefs)
|
||||||
└─ v0.28.7 Unified Packaging + Task RBAC
|
└─ v0.28.7 Unified Packaging + Task RBAC ✅
|
||||||
(.pkg archive, manifest.type,
|
(.pkg archive, manifest.type,
|
||||||
task permission gate pre-Starlark)
|
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
|
v0.29.0 Starlark Sandbox
|
||||||
(eval loop, permissions, admin UI
|
(eval loop, permissions, admin UI
|
||||||
— adds starlark capability to .pkg)
|
— 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).
|
Depends on: v0.28.5 (SDK — pipe/filter registration is part of manifest contract).
|
||||||
|
|
||||||
**Archive format:**
|
**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
|
templates, icons). Same structure as `.surface` archives, extended
|
||||||
manifest schema
|
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).
|
`extension` (hooks + tools + pipes, no own route), `full` (both).
|
||||||
Absent `type` defaults to `surface` for backward compat
|
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.
|
post-receive, post-render) with priority and entry function reference.
|
||||||
Wired by SDK on extension load
|
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)
|
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)
|
EXTENSIONS.md, carried forward)
|
||||||
|
|
||||||
**Install infrastructure:**
|
**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.
|
`manifest.type`, validates type-specific requirements, wires subsystems.
|
||||||
Replaces `POST /admin/surfaces/install` (old route kept as alias)
|
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`
|
`extension` requires at least one of `hooks`/`tools`/`pipes`, `full`
|
||||||
requires both sets
|
requires both sets
|
||||||
- [ ] Admin UI: merge Surfaces + Extensions into single "Packages" section.
|
- [x] Admin UI: merge Surfaces + Extensions into single "Packages" section.
|
||||||
Type badge on each entry. Filter by type. Enable/disable granular
|
Type badge on each entry. Filter by type.
|
||||||
for `full` packages (disable hooks independently of routes)
|
- [x] `packages` table: replaces both `surface_registry` and `extensions`.
|
||||||
- [ ] `packages` table (or rename `surfaces` → `packages` with migration):
|
Text slug PKs, type/version/tier/scope/is_system columns.
|
||||||
adds `type` column, retains all existing surface columns
|
|
||||||
|
|
||||||
**Migration:**
|
**Migration:**
|
||||||
- [ ] Existing `.surface` archives install unchanged (type defaults to
|
- [x] Existing `.surface` archives install unchanged (type defaults to
|
||||||
`surface`). No re-install required
|
`surface`). Re-install required after DB rebuild.
|
||||||
- [ ] Existing `extensions` table rows (loose-JS browser extensions) get
|
- [x] `extensions` table removed. Builtin extensions re-seed into `packages`
|
||||||
synthetic package wrappers: migration generates `manifest.json` from
|
table at startup via `SeedBuiltinPackages()`.
|
||||||
existing DB fields, archives JS entry file into `.pkg` format
|
- [x] `build.sh` in `packages/` produces `.pkg` files. Directory
|
||||||
- [ ] `build.sh` in `surfaces/` updated to produce `.pkg` files. Directory
|
renamed from `surfaces/`.
|
||||||
optionally renamed to `packages/`
|
- [x] ICD runner surface install tests updated to use new endpoint (or alias)
|
||||||
- [ ] ICD runner surface install tests updated to use new endpoint (or alias)
|
|
||||||
|
|
||||||
**Task RBAC:**
|
**Task RBAC:**
|
||||||
|
|
||||||
Permission gate on task creation by `task_type` and `scope`. Must ship
|
Permission gate on task creation by `task_type`. Uses existing group
|
||||||
before Starlark (v0.29.0) — cannot allow server-side code execution
|
permission model — no new `task_permissions` table. `task_type` is
|
||||||
without a permission model. Also retroactively locks down `action` tasks.
|
immutable on PATCH (not in `TaskPatch` struct).
|
||||||
|
|
||||||
- [ ] `task_permissions` table (or extend `extension_permissions` pattern):
|
- [x] `task.starlark` permission constant pre-positioned in
|
||||||
maps `(user_id | team_id | role) → task_type → allowed`. Default
|
`AllPermissions`. Handler rejects with 400 referencing v0.29.0.
|
||||||
permissions seeded on migration:
|
- [x] `task.action` permission gate on create (existing, v0.28.0).
|
||||||
- Admin: all types, all scopes
|
`system` type hardcoded admin-only (existing, v0.28.6).
|
||||||
- Team admin: `prompt`, `workflow` (team scope). `action` denied by default
|
- [x] Go tests: starlark rejected, system non-admin denied, action
|
||||||
- User: `prompt`, `workflow` (personal scope). `action` denied by default
|
without permission denied
|
||||||
- `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
|
|
||||||
- [ ] Admin UI: task permission management — per-user and per-team overrides.
|
- [ ] Admin UI: task permission management — per-user and per-team overrides.
|
||||||
"Allow action tasks for Team X" toggle. Pre-positions `starlark`
|
"Allow action tasks for Team X" toggle. Pre-positions `starlark`
|
||||||
permission type (hidden until v0.29.0 enables it)
|
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:**
|
**ICD + docs:**
|
||||||
- [ ] `packages.md` ICD: install, list, enable/disable, delete, type filtering
|
- [x] `packages.md` ICD: install, list, enable/disable, delete, type filtering
|
||||||
- [ ] `EXTENSIONS.md` updated: packaging section references `.pkg` format,
|
- [x] `extensions.md` + `surfaces.md` redirected to `packages.md`
|
||||||
loose-JS model documented as legacy
|
- [x] `tasks.md` ICD updated: permission matrix, `task_type` immutability
|
||||||
- [ ] `tasks.md` ICD updated: permission model, per-type RBAC
|
|
||||||
- [ ] ICD runner gains `packaging` test tier: install surface-type, install
|
- [ ] ICD runner gains `packaging` test tier: install surface-type, install
|
||||||
extension-type, install full-type, type validation, backward compat
|
extension-type, install full-type, type validation, backward compat
|
||||||
|
|
||||||
@@ -417,13 +410,71 @@ without a permission model. Also retroactively locks down `action` tasks.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v0.28.8 — ICD Green Board
|
||||||
|
|
||||||
|
Close out pre-existing ICD test failures. Gate: 543/543 (100%) on the
|
||||||
|
ICD runner before starting v0.29.0 feature work.
|
||||||
|
|
||||||
|
Depends on: v0.28.7 (unified packaging).
|
||||||
|
|
||||||
|
**Provider tier — upstream timeout resilience (6 failures):**
|
||||||
|
|
||||||
|
The provider test tier calls `POST /admin/models/fetch` to sync the
|
||||||
|
model catalog from the upstream provider (Venice). When Venice is slow
|
||||||
|
(>30s), the Gin handler times out, the reverse proxy returns 502, and
|
||||||
|
every subsequent provider test fails (bulk enable, model list, team
|
||||||
|
creation for provider scoping, BYOK creation).
|
||||||
|
|
||||||
|
- [ ] Request timeout on outbound provider HTTP calls: `http.Client`
|
||||||
|
with configurable timeout (default 30s). Currently uses
|
||||||
|
`http.DefaultClient` with no timeout in `providers/sync.go`.
|
||||||
|
- [ ] `POST /admin/models/fetch` returns 504 (not 502) on upstream
|
||||||
|
timeout, with `{"error": "upstream timeout: <provider>"}`.
|
||||||
|
Currently the reverse proxy manufactures the 502 from a broken
|
||||||
|
connection.
|
||||||
|
- [ ] ICD runner provider tier: retry `models/fetch` once on 502/504
|
||||||
|
before failing. Slow providers are a fact of life; the test
|
||||||
|
should tolerate one retry.
|
||||||
|
|
||||||
|
**SDK tier — base path in sw.api calls (4 failures):**
|
||||||
|
|
||||||
|
`sw.api.get('/api/v1/health')` calls `API._get('/api/v1/health')`
|
||||||
|
which prepends `window.__BASE__`. In the ICD runner context on a
|
||||||
|
`/dev` base path deployment, `__BASE__` is `/dev`, so the request
|
||||||
|
goes to `/dev/api/v1/health` — correct. The 502s here are cascading
|
||||||
|
from the same upstream provider timeout that poisons the proxy
|
||||||
|
connection pool. These will resolve when the provider timeout fix
|
||||||
|
lands. No SDK code change needed.
|
||||||
|
|
||||||
|
- [ ] Confirm SDK 502s are gone after provider timeout fix (no
|
||||||
|
independent fix required)
|
||||||
|
|
||||||
|
**Security transport — CORS + WebSocket (2 failures):**
|
||||||
|
|
||||||
|
Both are informational findings from the v0.28.4 security tier.
|
||||||
|
They're real issues but low severity (P2).
|
||||||
|
|
||||||
|
- [ ] `CORS_ALLOWED_ORIGINS` enforcement: currently `*` in all
|
||||||
|
environments. Production should restrict to the actual domain.
|
||||||
|
Add validation: if `CORS_ALLOWED_ORIGINS` is unset or `*`, log
|
||||||
|
a warning at startup. Document in ARCHITECTURE.md.
|
||||||
|
- [ ] WebSocket ticket exchange: replace `?token=` query parameter
|
||||||
|
(JWT visible in server logs, proxy logs, browser history) with a
|
||||||
|
short-lived opaque ticket. `POST /api/v1/ws/ticket` returns a
|
||||||
|
single-use token (UUID, 30s TTL, stored in memory/Redis).
|
||||||
|
WebSocket connects with `?ticket=` instead. Server validates
|
||||||
|
and deletes on use. This is a protocol change — coordinate with
|
||||||
|
frontend `events.js` WebSocket connection logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v0.29.0 — Starlark Sandbox + Permission Model
|
## v0.29.0 — Starlark Sandbox + Permission Model
|
||||||
|
|
||||||
Server-side extension runtime. Prove the eval loop, permission pipeline,
|
Server-side extension runtime. Prove the eval loop, permission pipeline,
|
||||||
and admin UI before adding capabilities.
|
and admin UI before adding capabilities.
|
||||||
|
|
||||||
Depends on: v0.28.0 (platform polish — specifically v0.28.7 unified packaging),
|
Depends on: v0.28.8 (ICD green board — clean test baseline before feature work),
|
||||||
extension infrastructure (v0.11.0).
|
v0.28.7 (unified packaging), extension infrastructure (v0.11.0).
|
||||||
|
|
||||||
- [ ] `go.starlark.net` integration (eval loop, timeout, memory ceiling)
|
- [ ] `go.starlark.net` integration (eval loop, timeout, memory ceiling)
|
||||||
- [ ] Permission model: manifest declarations, admin grant/revoke, DB schema
|
- [ ] Permission model: manifest declarations, admin grant/revoke, DB schema
|
||||||
|
|||||||
@@ -85,8 +85,9 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# v0.27.0: Extension surface static assets (JS, CSS, images)
|
# 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/ {
|
location /surfaces/ {
|
||||||
alias /data/surfaces/;
|
alias /data/packages/;
|
||||||
expires 1h;
|
expires 1h;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
}
|
}
|
||||||
|
|||||||
42
packages/README.md
Normal file
42
packages/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Packages
|
||||||
|
|
||||||
|
Installable `.pkg` archives for Chat Switchboard. Each subdirectory is a
|
||||||
|
package — either a surface (routable UI), an extension (hooks/tools/pipes),
|
||||||
|
or both (`full` type).
|
||||||
|
|
||||||
|
v0.28.7: Renamed from `surfaces/`. The `.pkg` format replaces `.surface`.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./packages/build.sh # build all → dist/*.pkg
|
||||||
|
./packages/build.sh icd-test-runner # build one
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/v1/admin/packages/install \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-F file=@dist/icd-test-runner.pkg
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the admin UI → System → Packages → Upload.
|
||||||
|
|
||||||
|
## Archive Format
|
||||||
|
|
||||||
|
A `.pkg` file is a zip containing:
|
||||||
|
|
||||||
|
```
|
||||||
|
manifest.json — required (must have "id" and "title")
|
||||||
|
js/ — optional JavaScript assets
|
||||||
|
css/ — optional stylesheets
|
||||||
|
assets/ — optional images, icons, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
The manifest `type` field determines behavior:
|
||||||
|
- `surface` (default) — routable page at `/s/:slug`
|
||||||
|
- `extension` — hooks, tools, and/or pipe filters
|
||||||
|
- `full` — both a surface and an extension
|
||||||
|
|
||||||
|
`.surface` archives are accepted as a backward-compatible alias.
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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:
|
# Usage:
|
||||||
# ./surfaces/build.sh # build all
|
# ./packages/build.sh # build all
|
||||||
# ./surfaces/build.sh icd-test-runner # build one
|
# ./packages/build.sh icd-test-runner # build one
|
||||||
#
|
#
|
||||||
# Output: dist/<name>.surface (zip archives, ready for POST /admin/surfaces/install)
|
# Output: dist/<n>.pkg (zip archives, ready for POST /admin/packages/install)
|
||||||
|
#
|
||||||
|
# v0.28.7: Renamed from surfaces/build.sh. Outputs .pkg instead of .surface.
|
||||||
#
|
#
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|||||||
DIST_DIR="$(cd "$SCRIPT_DIR/.." && pwd)/dist"
|
DIST_DIR="$(cd "$SCRIPT_DIR/.." && pwd)/dist"
|
||||||
mkdir -p "$DIST_DIR"
|
mkdir -p "$DIST_DIR"
|
||||||
|
|
||||||
build_surface() {
|
build_package() {
|
||||||
local dir="$1"
|
local dir="$1"
|
||||||
local name
|
local name
|
||||||
name="$(basename "$dir")"
|
name="$(basename "$dir")"
|
||||||
@@ -27,7 +29,7 @@ build_surface() {
|
|||||||
local version
|
local version
|
||||||
version=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$dir/manifest.json" | head -1 | sed 's/.*"\([^"]*\)"/\1/')
|
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"
|
rm -f "$out"
|
||||||
|
|
||||||
# Zip whichever standard dirs exist alongside manifest.json
|
# Zip whichever standard dirs exist alongside manifest.json
|
||||||
@@ -40,17 +42,17 @@ build_surface() {
|
|||||||
|
|
||||||
local size
|
local size
|
||||||
size=$(du -h "$out" | cut -f1)
|
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
|
if [ $# -gt 0 ]; then
|
||||||
# Build specific surface(s)
|
# Build specific package(s)
|
||||||
for name in "$@"; do
|
for name in "$@"; do
|
||||||
dir="$SCRIPT_DIR/$name"
|
dir="$SCRIPT_DIR/$name"
|
||||||
if [ -d "$dir" ]; then
|
if [ -d "$dir" ]; then
|
||||||
build_surface "$dir"
|
build_package "$dir"
|
||||||
else
|
else
|
||||||
echo " ERROR $name: directory not found at $dir"
|
echo " ERROR $name: directory not found at $dir"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -60,9 +62,9 @@ else
|
|||||||
# Build all
|
# Build all
|
||||||
for dir in "$SCRIPT_DIR"/*/; do
|
for dir in "$SCRIPT_DIR"/*/; do
|
||||||
[ -d "$dir" ] || continue
|
[ -d "$dir" ] || continue
|
||||||
build_surface "$dir"
|
build_package "$dir"
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "=== Done ==="
|
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)"
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
});
|
});
|
||||||
T.assertHasKey(d, 'data', 'install response');
|
T.assertHasKey(d, 'data', 'install response');
|
||||||
T.assertShape(d.data, T.S.extension, 'installed extension');
|
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.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');
|
T.assert(d.data.is_system === false, 'should not be system');
|
||||||
extId = d.data.id;
|
extId = d.data.id;
|
||||||
T.registerCleanup(function () { if (extId) return T.safeDelete('/admin/extensions/' + extId); });
|
T.registerCleanup(function () { if (extId) return T.safeDelete('/admin/extensions/' + extId); });
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
description: 'Updated description'
|
description: 'Updated description'
|
||||||
});
|
});
|
||||||
T.assertHasKey(d, 'data', 'update response');
|
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');
|
T.assert(d.data.version === '2.0.0', 'version not updated');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,18 +53,18 @@
|
|||||||
|
|
||||||
await T.test('crud', 'surfaces', 'GET /admin/surfaces (list includes new)', async function () {
|
await T.test('crud', 'surfaces', 'GET /admin/surfaces (list includes new)', async function () {
|
||||||
var d = await T.apiGet('/admin/surfaces');
|
var d = await T.apiGet('/admin/surfaces');
|
||||||
T.assertHasKey(d, 'surfaces', '/admin/surfaces');
|
T.assertHasKey(d, 'data', '/admin/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, 'installed surface should appear in admin list');
|
T.assert(found, 'installed surface should appear in admin list');
|
||||||
});
|
});
|
||||||
|
|
||||||
await T.test('crud', 'surfaces', 'GET /surfaces (user list includes new)', async function () {
|
await T.test('crud', 'surfaces', 'GET /surfaces (user list includes new)', async function () {
|
||||||
var d = await T.apiGet('/surfaces');
|
var d = await T.apiGet('/surfaces');
|
||||||
T.assertHasKey(d, 'surfaces', '/surfaces');
|
T.assertHasKey(d, 'data', '/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, 'enabled extension surface should appear in user nav list');
|
T.assert(found, 'enabled extension surface should appear in user nav list');
|
||||||
// Verify nav shape: id + title + route, NOT source/enabled
|
// 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.route !== undefined, 'nav entry should have route');
|
||||||
T.assert(entry.source === undefined, 'nav entry should NOT have source');
|
T.assert(entry.source === undefined, 'nav entry should NOT have source');
|
||||||
T.assert(entry.enabled === undefined, 'nav entry should NOT have enabled');
|
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 () {
|
await T.test('crud', 'surfaces', 'GET /surfaces (disabled hidden from user)', async function () {
|
||||||
var d = await T.apiGet('/surfaces');
|
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');
|
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 () {
|
await T.test('crud', 'surfaces', 'GET /admin/surfaces (disabled still in admin)', async function () {
|
||||||
var d = await T.apiGet('/admin/surfaces');
|
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, 'disabled surface should still appear in admin list');
|
||||||
T.assert(entry.enabled === false, 'should show as disabled 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 () {
|
await T.test('crud', 'surfaces', 'GET /surfaces (re-enabled visible)', async function () {
|
||||||
var d = await T.apiGet('/surfaces');
|
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');
|
T.assert(found, 're-enabled surface should appear in user nav list');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
S.team = { id: 'string', name: 'string', created_at: 'string?' };
|
S.team = { id: 'string', name: 'string', created_at: 'string?' };
|
||||||
S.teamMember = { user_id: 'string', role: 'string' };
|
S.teamMember = { user_id: 'string', role: 'string' };
|
||||||
S.group = { id: 'string', name: '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.auditEntry = { id: 'string', action: 'string', created_at: 'string' };
|
||||||
S.file = { id: 'string', filename: 'string', content_type: 'string', origin: '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' };
|
S.participant = { id: 'string', channel_id: 'string', participant_type: 'string', role: 'string', joined_at: 'string' };
|
||||||
@@ -56,10 +56,10 @@
|
|||||||
// -- Surfaces --
|
// -- Surfaces --
|
||||||
await T.test('smoke', 'surfaces', 'GET /surfaces', async function () {
|
await T.test('smoke', 'surfaces', 'GET /surfaces', async function () {
|
||||||
var d = await T.apiGet('/surfaces');
|
var d = await T.apiGet('/surfaces');
|
||||||
T.assertHasKey(d, 'surfaces', '/surfaces');
|
T.assertHasKey(d, 'data', '/surfaces');
|
||||||
T.assertArrayOf(d.surfaces, T.S.surfaceNav, 'surfaces');
|
T.assertArrayOf(d.data, T.S.surfaceNav, 'surfaces');
|
||||||
// Our own surface should be in the list
|
// 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');
|
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 () {
|
await T.test('smoke', 'admin', 'GET /admin/surfaces', async function () {
|
||||||
var d = await T.apiGet('/admin/surfaces');
|
var d = await T.apiGet('/admin/surfaces');
|
||||||
T.assertHasKey(d, 'surfaces', '/admin/surfaces');
|
T.assertHasKey(d, 'data', '/admin/surfaces');
|
||||||
T.assert(Array.isArray(d.surfaces), 'surfaces should be array');
|
T.assert(Array.isArray(d.data), 'surfaces should be array');
|
||||||
if (d.surfaces.length > 0) {
|
if (d.data.length > 0) {
|
||||||
T.assertShape(d.surfaces[0], T.S.surfaceAdmin, 'surfaces[0]');
|
T.assertShape(d.data[0], T.S.surfaceAdmin, 'surfaces[0]');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4,6 +4,6 @@
|
|||||||
"route": "/s/icd-test-runner",
|
"route": "/s/icd-test-runner",
|
||||||
"auth": "authenticated",
|
"auth": "authenticated",
|
||||||
"layout": "single",
|
"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."
|
"description": "Integration test runner — validates ICD endpoint contracts across smoke, CRUD, AuthZ, Security, Provider, and SDK tiers."
|
||||||
}
|
}
|
||||||
445
pages/loaders.go
Normal file
445
pages/loaders.go
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Types for template data ──────────────────
|
||||||
|
|
||||||
|
// ModelOption is a flat model for template dropdowns.
|
||||||
|
type ModelOption struct {
|
||||||
|
ProviderConfigID string `json:"provider_config_id"`
|
||||||
|
ProviderName string `json:"provider_name"`
|
||||||
|
ModelID string `json:"model_id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
ModelType string `json:"model_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderOption for template dropdowns.
|
||||||
|
type ProviderOption struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TeamOption for template dropdowns.
|
||||||
|
type TeamOption struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
MemberCount int `json:"member_count,omitempty"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderDetail is an enriched provider for the providers table.
|
||||||
|
type ProviderDetail struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
ModelCount int `json:"model_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderTypeOption for the provider type dropdown.
|
||||||
|
type ProviderTypeOption struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserRow is a user for the admin users table.
|
||||||
|
type UserRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleConfig holds a role's current settings.
|
||||||
|
type RoleConfig struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Primary *RoleSelection `json:"primary"`
|
||||||
|
Fallback *RoleSelection `json:"fallback"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleSelection is provider + model pair for a role slot.
|
||||||
|
type RoleSelection struct {
|
||||||
|
ProviderID string `json:"provider_config_id"`
|
||||||
|
ModelID string `json:"model_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page data structs ────────────────────────
|
||||||
|
//
|
||||||
|
// v0.25.0: Each loader function is a "data provider" keyed by surface ID.
|
||||||
|
// The surface manifest's DataRequires field references these keys.
|
||||||
|
// Currently 1:1 (one loader per surface). Future: composite loaders
|
||||||
|
// that assemble data from multiple providers for dashboard-style surfaces.
|
||||||
|
|
||||||
|
// AdminPageData is what the admin surface templates receive.
|
||||||
|
type AdminPageData struct {
|
||||||
|
Section string `json:"section"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Providers []ProviderOption `json:"providers"`
|
||||||
|
ProviderDetails []ProviderDetail `json:"provider_details,omitempty"`
|
||||||
|
ProviderTypes []ProviderTypeOption `json:"provider_types,omitempty"`
|
||||||
|
Models []ModelOption `json:"models"`
|
||||||
|
Teams []TeamOption `json:"teams"`
|
||||||
|
Users []UserRow `json:"users,omitempty"`
|
||||||
|
Roles []RoleConfig `json:"roles"`
|
||||||
|
Policies any `json:"policies,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatPageData is what the chat surface templates receive.
|
||||||
|
type ChatPageData struct {
|
||||||
|
ChatID string `json:"chat_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditorPageData provides workspace context for the editor surface shell.
|
||||||
|
// editor-mode.js builds CodeMirror, file tree, etc. inside the template containers.
|
||||||
|
type EditorPageData struct {
|
||||||
|
WorkspaceID string `json:"WorkspaceID"`
|
||||||
|
WorkspaceName string `json:"WorkspaceName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotesPageData provides context for the notes surface shell.
|
||||||
|
type NotesPageData struct {
|
||||||
|
NoteID string `json:"NoteID,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsPageData is what the settings surface templates receive.
|
||||||
|
type SettingsPageData struct {
|
||||||
|
Section string `json:"section"`
|
||||||
|
|
||||||
|
// v0.22.7: Feature gates from admin config.
|
||||||
|
// These control which nav links/tabs are visible in the settings surface.
|
||||||
|
BYOKEnabled bool `json:"BYOKEnabled"`
|
||||||
|
UserPersonasEnabled bool `json:"UserPersonasEnabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loader registration ──────────────────────
|
||||||
|
|
||||||
|
func (e *Engine) registerLoaders() {
|
||||||
|
e.RegisterLoader("admin", e.adminLoader)
|
||||||
|
e.RegisterLoader("chat", e.chatLoader)
|
||||||
|
e.RegisterLoader("editor", e.editorLoader)
|
||||||
|
e.RegisterLoader("notes", e.notesLoader)
|
||||||
|
e.RegisterLoader("settings", e.settingsLoader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDataProviders returns the keys of all registered data providers.
|
||||||
|
// Used for manifest validation — DataRequires entries must match a key here.
|
||||||
|
func (e *Engine) ListDataProviders() []string {
|
||||||
|
keys := make([]string, 0, len(e.loaders))
|
||||||
|
for k := range e.loaders {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin loader ─────────────────────────────
|
||||||
|
// Pre-loads ALL dropdown data. Fixes bugs #1 (model roles) and #2 (team scope).
|
||||||
|
|
||||||
|
func (e *Engine) adminLoader(c *gin.Context, s store.Stores) (any, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
section := c.Param("section")
|
||||||
|
if section == "" {
|
||||||
|
section = "overview"
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &AdminPageData{
|
||||||
|
Section: section,
|
||||||
|
Category: sectionCategory(section),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Providers (global scope) ─────────────
|
||||||
|
if s.Providers != nil {
|
||||||
|
configs, err := s.Providers.ListGlobal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[pages/admin] Failed to list providers: %v", err)
|
||||||
|
} else {
|
||||||
|
for _, pc := range configs {
|
||||||
|
name := pc.Name
|
||||||
|
if name == "" {
|
||||||
|
name = pc.Provider
|
||||||
|
}
|
||||||
|
data.Providers = append(data.Providers, ProviderOption{
|
||||||
|
ID: pc.ID,
|
||||||
|
Name: name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Teams ────────────────────────────────
|
||||||
|
if s.Teams != nil {
|
||||||
|
teams, err := s.Teams.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[pages/admin] Failed to list teams: %v", err)
|
||||||
|
} else {
|
||||||
|
for _, t := range teams {
|
||||||
|
data.Teams = append(data.Teams, TeamOption{
|
||||||
|
ID: t.ID,
|
||||||
|
Name: t.Name,
|
||||||
|
Description: t.Description,
|
||||||
|
MemberCount: t.MemberCount,
|
||||||
|
IsActive: t.IsActive,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full model catalog with model_type ───
|
||||||
|
// This is what fixes bug #1: all models from all providers,
|
||||||
|
// including embedding models, with their model_type intact.
|
||||||
|
if s.Catalog != nil {
|
||||||
|
entries, err := s.Catalog.ListAllGlobal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[pages/admin] Failed to list catalog: %v", err)
|
||||||
|
} else {
|
||||||
|
provNames := make(map[string]string, len(data.Providers))
|
||||||
|
for _, p := range data.Providers {
|
||||||
|
provNames[p.ID] = p.Name
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
mt := entry.ModelType
|
||||||
|
if mt == "" {
|
||||||
|
mt = "chat"
|
||||||
|
}
|
||||||
|
dn := entry.DisplayName
|
||||||
|
if dn == "" {
|
||||||
|
dn = entry.ModelID
|
||||||
|
}
|
||||||
|
data.Models = append(data.Models, ModelOption{
|
||||||
|
ProviderConfigID: entry.ProviderConfigID,
|
||||||
|
ProviderName: provNames[entry.ProviderConfigID],
|
||||||
|
ModelID: entry.ModelID,
|
||||||
|
DisplayName: dn,
|
||||||
|
ModelType: mt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section-specific data ────────────────
|
||||||
|
switch section {
|
||||||
|
case "roles":
|
||||||
|
data.Roles = e.loadRolesConfig(ctx, s)
|
||||||
|
case "routing":
|
||||||
|
if s.RoutingPolicies != nil {
|
||||||
|
policies, err := s.RoutingPolicies.ListAll(ctx)
|
||||||
|
if err == nil {
|
||||||
|
data.Policies = policies
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "providers":
|
||||||
|
data.ProviderTypes = loadProviderTypes()
|
||||||
|
data.ProviderDetails = e.loadProviderDetails(ctx, s, data.Providers, data.Models)
|
||||||
|
case "users":
|
||||||
|
data.Users = e.loadUsers(ctx, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRolesConfig reads current role assignments.
|
||||||
|
func (e *Engine) loadRolesConfig(ctx context.Context, s store.Stores) []RoleConfig {
|
||||||
|
roleNames := []string{"utility", "embedding"}
|
||||||
|
roles := make([]RoleConfig, 0, len(roleNames))
|
||||||
|
|
||||||
|
if s.GlobalConfig == nil {
|
||||||
|
for _, name := range roleNames {
|
||||||
|
roles = append(roles, RoleConfig{Name: name})
|
||||||
|
}
|
||||||
|
return roles
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := s.GlobalConfig.Get(ctx, "model_roles")
|
||||||
|
if err != nil || raw == nil {
|
||||||
|
for _, name := range roleNames {
|
||||||
|
roles = append(roles, RoleConfig{Name: name})
|
||||||
|
}
|
||||||
|
return roles
|
||||||
|
}
|
||||||
|
|
||||||
|
rolesMap, ok := raw["roles"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
rolesMap = raw
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range roleNames {
|
||||||
|
rc := RoleConfig{Name: name}
|
||||||
|
if roleData, ok := rolesMap[name].(map[string]any); ok {
|
||||||
|
if primary, ok := roleData["primary"].(map[string]any); ok {
|
||||||
|
rc.Primary = &RoleSelection{
|
||||||
|
ProviderID: fmt.Sprintf("%v", primary["provider_config_id"]),
|
||||||
|
ModelID: fmt.Sprintf("%v", primary["model_id"]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fallback, ok := roleData["fallback"].(map[string]any); ok {
|
||||||
|
rc.Fallback = &RoleSelection{
|
||||||
|
ProviderID: fmt.Sprintf("%v", fallback["provider_config_id"]),
|
||||||
|
ModelID: fmt.Sprintf("%v", fallback["model_id"]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roles = append(roles, rc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return roles
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chat loader ──────────────────────────────
|
||||||
|
|
||||||
|
func (e *Engine) chatLoader(c *gin.Context, s store.Stores) (any, error) {
|
||||||
|
chatID := c.Param("chatID")
|
||||||
|
return &ChatPageData{ChatID: chatID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin helpers ────────────────────────────
|
||||||
|
|
||||||
|
// sectionCategory maps an admin section to its category tab.
|
||||||
|
func sectionCategory(section string) string {
|
||||||
|
switch section {
|
||||||
|
case "users", "teams", "groups":
|
||||||
|
return "people"
|
||||||
|
case "providers", "models", "personas", "roles", "knowledgeBases", "memory":
|
||||||
|
return "ai"
|
||||||
|
case "health", "routing", "capabilities":
|
||||||
|
return "routing"
|
||||||
|
case "settings", "storage", "packages", "channels", "broadcast":
|
||||||
|
return "system"
|
||||||
|
case "usage", "audit", "stats":
|
||||||
|
return "monitoring"
|
||||||
|
default:
|
||||||
|
return "ai" // default landing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadProviderTypes returns the registered provider type metadata.
|
||||||
|
func loadProviderTypes() []ProviderTypeOption {
|
||||||
|
types := providers.ListTypes()
|
||||||
|
out := make([]ProviderTypeOption, 0, len(types))
|
||||||
|
for _, t := range types {
|
||||||
|
out = append(out, ProviderTypeOption{ID: t.ID, Name: t.Name})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadProviderDetails enriches providers with model counts.
|
||||||
|
func (e *Engine) loadProviderDetails(ctx context.Context, s store.Stores, provs []ProviderOption, models []ModelOption) []ProviderDetail {
|
||||||
|
// Count models per provider
|
||||||
|
counts := make(map[string]int)
|
||||||
|
for _, m := range models {
|
||||||
|
counts[m.ProviderConfigID]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get full provider configs for endpoint/scope
|
||||||
|
if s.Providers == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
configs, err := s.Providers.ListGlobal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[pages/admin] Failed to list provider details: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]ProviderDetail, 0, len(configs))
|
||||||
|
for _, pc := range configs {
|
||||||
|
name := pc.Name
|
||||||
|
if name == "" {
|
||||||
|
name = pc.Provider
|
||||||
|
}
|
||||||
|
out = append(out, ProviderDetail{
|
||||||
|
ID: pc.ID,
|
||||||
|
Name: name,
|
||||||
|
Provider: pc.Provider,
|
||||||
|
Scope: pc.Scope,
|
||||||
|
Endpoint: pc.Endpoint,
|
||||||
|
ModelCount: counts[pc.ID],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadUsers returns the user list for the admin users table.
|
||||||
|
func (e *Engine) loadUsers(ctx context.Context, s store.Stores) []UserRow {
|
||||||
|
if s.Users == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
users, _, err := s.Users.List(ctx, store.ListOptions{Limit: 500, Sort: "username", Order: "asc"})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[pages/admin] Failed to list users: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]UserRow, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
out = append(out, UserRow{
|
||||||
|
ID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
Email: u.Email,
|
||||||
|
DisplayName: u.DisplayName,
|
||||||
|
Role: u.Role,
|
||||||
|
IsActive: u.IsActive,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Editor loader ────────────────────────────
|
||||||
|
// Resolves workspace from URL param. The heavy lifting (file tree, CodeMirror)
|
||||||
|
// is done client-side by editor-mode.js.
|
||||||
|
|
||||||
|
func (e *Engine) editorLoader(c *gin.Context, s store.Stores) (any, error) {
|
||||||
|
wsID := c.Param("wsId")
|
||||||
|
data := &EditorPageData{WorkspaceID: wsID}
|
||||||
|
|
||||||
|
if wsID != "" && s.Workspaces != nil {
|
||||||
|
ws, err := s.Workspaces.GetByID(context.Background(), wsID)
|
||||||
|
if err == nil && ws != nil {
|
||||||
|
data.WorkspaceName = ws.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notes loader ─────────────────────────────
|
||||||
|
|
||||||
|
func (e *Engine) notesLoader(c *gin.Context, s store.Stores) (any, error) {
|
||||||
|
noteID := c.Param("noteId")
|
||||||
|
return &NotesPageData{NoteID: noteID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings loader ──────────────────────────
|
||||||
|
// v0.22.7: Reads feature gates from GlobalConfig to control
|
||||||
|
// which nav links/tabs are visible (BYOK, User Personas).
|
||||||
|
|
||||||
|
func (e *Engine) settingsLoader(c *gin.Context, s store.Stores) (any, error) {
|
||||||
|
section := c.Param("section")
|
||||||
|
if section == "" {
|
||||||
|
section = "general"
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &SettingsPageData{Section: section}
|
||||||
|
|
||||||
|
// Read feature gates from policies (where admin settings saves them).
|
||||||
|
// Keys: allow_user_byok, allow_user_personas
|
||||||
|
if s.Policies != nil {
|
||||||
|
ctx := context.Background()
|
||||||
|
policies, err := s.Policies.GetAll(ctx)
|
||||||
|
if err == nil {
|
||||||
|
data.BYOKEnabled = policies["allow_user_byok"] == "true"
|
||||||
|
data.UserPersonasEnabled = policies["allow_user_personas"] == "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ const (
|
|||||||
PermTaskCreate = "task.create" // create scheduled tasks (v0.27.2)
|
PermTaskCreate = "task.create" // create scheduled tasks (v0.27.2)
|
||||||
PermTaskAdmin = "task.admin" // manage all tasks, set global task config (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)
|
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.
|
// AllPermissions is the complete set of valid permission strings.
|
||||||
@@ -49,6 +50,7 @@ var AllPermissions = []string{
|
|||||||
PermTaskCreate,
|
PermTaskCreate,
|
||||||
PermTaskAdmin,
|
PermTaskAdmin,
|
||||||
PermTaskAction,
|
PermTaskAction,
|
||||||
|
PermTaskStarlark,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Resolution ──────────────────────────────
|
// ── Resolution ──────────────────────────────
|
||||||
|
|||||||
@@ -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)
|
|
||||||
);
|
|
||||||
8
server/database/migrations/012_package_user_settings.sql
Normal file
8
server/database/migrations/012_package_user_settings.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- ==========================================
|
||||||
|
-- Chat Switchboard — 012 (placeholder)
|
||||||
|
-- ==========================================
|
||||||
|
-- Previously: extensions + extension_user_settings tables.
|
||||||
|
-- v0.28.7: Both tables removed. Unified package registry in 016.
|
||||||
|
-- package_user_settings also in 016 (FK dependency on packages).
|
||||||
|
-- This file is intentionally empty to preserve migration numbering.
|
||||||
|
-- ==========================================
|
||||||
59
server/database/migrations/016_packages.sql
Normal file
59
server/database/migrations/016_packages.sql
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
-- ==========================================
|
||||||
|
-- Chat Switchboard — 016 Packages
|
||||||
|
-- ==========================================
|
||||||
|
-- Unified package registry. Replaces surface_registry (v0.25.0)
|
||||||
|
-- and extensions (v0.11.0) tables. A package is a surface, an
|
||||||
|
-- extension, or both.
|
||||||
|
--
|
||||||
|
-- v0.28.7: packages table + package_user_settings in 012.
|
||||||
|
-- ==========================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS packages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL DEFAULT 'surface'
|
||||||
|
CHECK (type IN ('surface', 'extension', 'full')),
|
||||||
|
version TEXT NOT NULL DEFAULT '0.0.0',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
author TEXT NOT NULL DEFAULT '',
|
||||||
|
tier TEXT NOT NULL DEFAULT 'browser'
|
||||||
|
CHECK (tier IN ('browser', 'starlark', 'sidecar')),
|
||||||
|
is_system BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
scope TEXT NOT NULL DEFAULT 'global'
|
||||||
|
CHECK (scope IN ('global', 'team', 'personal')),
|
||||||
|
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
installed_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
manifest JSONB NOT NULL DEFAULT '{}',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
source TEXT NOT NULL DEFAULT 'core'
|
||||||
|
CHECK (source IN ('core', 'builtin', 'extension')),
|
||||||
|
installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packages_type ON packages(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packages_enabled ON packages(enabled) WHERE enabled = true;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packages_team ON packages(team_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packages_source ON packages(source);
|
||||||
|
|
||||||
|
COMMENT ON TABLE packages IS 'Unified package registry. Surfaces, extensions, and full packages. Replaces surface_registry + extensions tables.';
|
||||||
|
COMMENT ON COLUMN packages.id IS 'Slug identifier from manifest "id" field. Used in URLs: /s/:id';
|
||||||
|
COMMENT ON COLUMN packages.type IS 'surface = routable page, extension = hooks/tools/pipes, full = both';
|
||||||
|
COMMENT ON COLUMN packages.source IS 'core = page-engine seeded, builtin = extensions/builtin/ seeded, extension = admin-uploaded .pkg';
|
||||||
|
COMMENT ON COLUMN packages.enabled IS 'Admin toggle — disabled surfaces redirect to / and hide from nav';
|
||||||
|
|
||||||
|
|
||||||
|
-- =========================================
|
||||||
|
-- PACKAGE USER SETTINGS
|
||||||
|
-- =========================================
|
||||||
|
-- Per-user overrides for packages (enable/disable, custom config).
|
||||||
|
-- Replaces extension_user_settings from the former extensions schema.
|
||||||
|
-- Lives in 016 (not 012) because of FK dependency on packages table.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS package_user_settings (
|
||||||
|
package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
settings JSONB NOT NULL DEFAULT '{}',
|
||||||
|
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
PRIMARY KEY (package_id, user_id)
|
||||||
|
);
|
||||||
@@ -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';
|
|
||||||
@@ -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)
|
|
||||||
);
|
|
||||||
@@ -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.
|
||||||
43
server/database/migrations/sqlite/016_packages.sql
Normal file
43
server/database/migrations/sqlite/016_packages.sql
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
-- Chat Switchboard — 016 Packages (SQLite)
|
||||||
|
-- Unified package registry. Replaces surface_registry + extensions.
|
||||||
|
-- v0.28.7
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS packages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL DEFAULT 'surface'
|
||||||
|
CHECK (type IN ('surface', 'extension', 'full')),
|
||||||
|
version TEXT NOT NULL DEFAULT '0.0.0',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
author TEXT NOT NULL DEFAULT '',
|
||||||
|
tier TEXT NOT NULL DEFAULT 'browser'
|
||||||
|
CHECK (tier IN ('browser', 'starlark', 'sidecar')),
|
||||||
|
is_system INTEGER NOT NULL DEFAULT 0,
|
||||||
|
scope TEXT NOT NULL DEFAULT 'global'
|
||||||
|
CHECK (scope IN ('global', 'team', 'personal')),
|
||||||
|
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
installed_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
manifest TEXT NOT NULL DEFAULT '{}',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
source TEXT NOT NULL DEFAULT 'core'
|
||||||
|
CHECK (source IN ('core', 'builtin', 'extension')),
|
||||||
|
installed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packages_type ON packages(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packages_enabled ON packages(enabled);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packages_team ON packages(team_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packages_source ON packages(source);
|
||||||
|
|
||||||
|
|
||||||
|
-- Per-user overrides for packages (enable/disable, custom config).
|
||||||
|
-- Lives in 016 (not 012) because of FK dependency on packages table.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS package_user_settings (
|
||||||
|
package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
settings TEXT NOT NULL DEFAULT '{}',
|
||||||
|
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (package_id, user_id)
|
||||||
|
);
|
||||||
@@ -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'))
|
|
||||||
);
|
|
||||||
@@ -325,12 +325,11 @@ func TruncateAll(t *testing.T) {
|
|||||||
"refresh_tokens",
|
"refresh_tokens",
|
||||||
"user_presence",
|
"user_presence",
|
||||||
"users",
|
"users",
|
||||||
// Config & extensions
|
// Config & packages
|
||||||
"platform_policies",
|
"platform_policies",
|
||||||
"global_settings",
|
"global_settings",
|
||||||
"extension_user_settings",
|
"package_user_settings",
|
||||||
"extensions",
|
"packages",
|
||||||
"surface_registry",
|
|
||||||
"oidc_auth_state",
|
"oidc_auth_state",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -899,10 +899,10 @@ func (h *CompletionHandler) ListTools(c *gin.Context) {
|
|||||||
|
|
||||||
// Browser extension tools
|
// Browser extension tools
|
||||||
if h.hub != nil && h.hub.IsConnected(userID) {
|
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 {
|
if err == nil {
|
||||||
for _, ext := range exts {
|
for _, pkg := range pkgs {
|
||||||
if ext.Tier != "browser" {
|
if pkg.Tier != "browser" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var manifest struct {
|
var manifest struct {
|
||||||
@@ -912,7 +912,7 @@ func (h *CompletionHandler) ListTools(c *gin.Context) {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
} `json:"tools"`
|
} `json:"tools"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
|
if err := json.Unmarshal(marshalManifest(pkg.Manifest), &manifest); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, t := range manifest.Tools {
|
for _, t := range manifest.Tools {
|
||||||
|
|||||||
@@ -145,9 +145,6 @@ func TestExtension_AdminCRUDLifecycle(t *testing.T) {
|
|||||||
if extID == "" {
|
if extID == "" {
|
||||||
t.Fatal("extension should have an id")
|
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" {
|
if ext["version"].(string) != "1.0.0" {
|
||||||
t.Fatalf("version mismatch: got %q", ext["version"])
|
t.Fatalf("version mismatch: got %q", ext["version"])
|
||||||
}
|
}
|
||||||
@@ -178,8 +175,8 @@ func TestExtension_AdminCRUDLifecycle(t *testing.T) {
|
|||||||
var updated map[string]interface{}
|
var updated map[string]interface{}
|
||||||
decode(w, &updated)
|
decode(w, &updated)
|
||||||
updatedData := updated["data"].(map[string]interface{})
|
updatedData := updated["data"].(map[string]interface{})
|
||||||
if updatedData["name"].(string) != "Updated Extension" {
|
if updatedData["title"].(string) != "Updated Extension" {
|
||||||
t.Fatalf("name not updated: got %q", updatedData["name"])
|
t.Fatalf("name not updated: got %q", updatedData["title"])
|
||||||
}
|
}
|
||||||
if updatedData["version"].(string) != "2.0.0" {
|
if updatedData["version"].(string) != "2.0.0" {
|
||||||
t.Fatalf("version not updated: got %q", updatedData["version"])
|
t.Fatalf("version not updated: got %q", updatedData["version"])
|
||||||
@@ -687,8 +684,8 @@ func TestExtension_PartialUpdate(t *testing.T) {
|
|||||||
var resp map[string]interface{}
|
var resp map[string]interface{}
|
||||||
decode(w, &resp)
|
decode(w, &resp)
|
||||||
data := resp["data"].(map[string]interface{})
|
data := resp["data"].(map[string]interface{})
|
||||||
if data["name"].(string) != "New Name" {
|
if data["title"].(string) != "New Name" {
|
||||||
t.Fatalf("name not updated: got %q", data["name"])
|
t.Fatalf("name not updated: got %q", data["title"])
|
||||||
}
|
}
|
||||||
if data["version"].(string) != "1.0.0" {
|
if data["version"].(string) != "1.0.0" {
|
||||||
t.Fatalf("version should be unchanged: got %q", data["version"])
|
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
|
// ListEnabled should return only the enabled one
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
exts, err := h.stores.Extensions.ListEnabled(ctx)
|
pkgs, err := h.stores.Packages.ListEnabledByType(ctx, "extension")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListEnabled: %v", err)
|
t.Fatalf("ListEnabled: %v", err)
|
||||||
}
|
}
|
||||||
if len(exts) != 1 {
|
if len(pkgs) != 1 {
|
||||||
t.Fatalf("expected 1 enabled, got %d", len(exts))
|
t.Fatalf("expected 1 enabled, got %d", len(pkgs))
|
||||||
}
|
}
|
||||||
if exts[0].ExtID != "enabled-ext" {
|
if pkgs[0].ID != "enabled-ext" {
|
||||||
t.Fatalf("wrong extension: got %q", exts[0].ExtID)
|
t.Fatalf("wrong extension: got %q", pkgs[0].ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,7 +739,7 @@ func TestExtension_StoreGetUserSettings(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// No settings yet → sql.ErrNoRows
|
// 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 {
|
if err != sql.ErrNoRows {
|
||||||
t.Fatalf("expected ErrNoRows, got %v", err)
|
t.Fatalf("expected ErrNoRows, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -757,15 +754,15 @@ func TestExtension_StoreGetUserSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Now GetUserSettings should return them
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("GetUserSettings: %v", err)
|
t.Fatalf("GetUserSettings: %v", err)
|
||||||
}
|
}
|
||||||
if !eus.IsEnabled {
|
if !pus.IsEnabled {
|
||||||
t.Fatal("should be enabled")
|
t.Fatal("should be enabled")
|
||||||
}
|
}
|
||||||
var settings map[string]interface{}
|
var settings map[string]interface{}
|
||||||
json.Unmarshal(eus.Settings, &settings)
|
json.Unmarshal(pus.Settings, &settings)
|
||||||
if settings["key"] != "value" {
|
if settings["key"] != "value" {
|
||||||
t.Fatalf("settings mismatch: %v", settings)
|
t.Fatalf("settings mismatch: %v", settings)
|
||||||
}
|
}
|
||||||
@@ -788,19 +785,19 @@ func TestExtension_StoreDeleteUserSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify present
|
// Verify present
|
||||||
_, err := h.stores.Extensions.GetUserSettings(ctx, extID, h.userID)
|
_, err := h.stores.Packages.GetUserSettings(ctx, extID, h.userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("should exist: %v", err)
|
t.Fatalf("should exist: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete via store
|
// Delete via store
|
||||||
err = h.stores.Extensions.DeleteUserSettings(ctx, extID, h.userID)
|
err = h.stores.Packages.DeleteUserSettings(ctx, extID, h.userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DeleteUserSettings: %v", err)
|
t.Fatalf("DeleteUserSettings: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify gone
|
// Verify gone
|
||||||
_, err = h.stores.Extensions.GetUserSettings(ctx, extID, h.userID)
|
_, err = h.stores.Packages.GetUserSettings(ctx, extID, h.userID)
|
||||||
if err != sql.ErrNoRows {
|
if err != sql.ErrNoRows {
|
||||||
t.Fatalf("should be gone, got %v", err)
|
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
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("seeded extension not found: %v", err)
|
t.Fatalf("seeded extension not found: %v", err)
|
||||||
}
|
}
|
||||||
if ext.Name != "Seeded Extension" {
|
if pkg.Title != "Seeded Extension" {
|
||||||
t.Fatalf("name: got %q", ext.Name)
|
t.Fatalf("name: got %q", pkg.Title)
|
||||||
}
|
}
|
||||||
if ext.Version != "1.0.0" {
|
if pkg.Version != "1.0.0" {
|
||||||
t.Fatalf("version: got %q", ext.Version)
|
t.Fatalf("version: got %q", pkg.Version)
|
||||||
}
|
}
|
||||||
if !ext.IsSystem {
|
if !pkg.IsSystem {
|
||||||
t.Fatal("should be system extension")
|
t.Fatal("should be system extension")
|
||||||
}
|
}
|
||||||
if !ext.IsEnabled {
|
if !pkg.Enabled {
|
||||||
t.Fatal("should be enabled")
|
t.Fatal("should be enabled")
|
||||||
}
|
}
|
||||||
// Verify _script is inlined in manifest
|
// Verify _script is inlined in manifest
|
||||||
var manifest map[string]json.RawMessage
|
if _, ok := pkg.Manifest["_script"]; !ok {
|
||||||
json.Unmarshal(ext.Manifest, &manifest)
|
|
||||||
if _, ok := manifest["_script"]; !ok {
|
|
||||||
t.Fatal("manifest should contain _script")
|
t.Fatal("manifest should contain _script")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -884,17 +879,17 @@ func TestSeed_SameVersionSkips(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Seed twice
|
// Seed twice
|
||||||
SeedBuiltinExtensions(h.stores, dir)
|
SeedBuiltinPackages(h.stores, dir)
|
||||||
SeedBuiltinExtensions(h.stores, dir)
|
SeedBuiltinPackages(h.stores, dir)
|
||||||
|
|
||||||
// Should still have exactly one
|
// Should still have exactly one
|
||||||
exts, err := h.stores.Extensions.ListAll(ctx)
|
pkgs, err := h.stores.Packages.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list: %v", err)
|
t.Fatalf("list: %v", err)
|
||||||
}
|
}
|
||||||
count := 0
|
count := 0
|
||||||
for _, e := range exts {
|
for _, p := range pkgs {
|
||||||
if e.ExtID == "skip-ext" {
|
if p.ID == "skip-ext" {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -914,11 +909,11 @@ func TestSeed_VersionChangeUpdates(t *testing.T) {
|
|||||||
script: `console.log("v1");`,
|
script: `console.log("v1");`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
SeedBuiltinExtensions(h.stores, dir1)
|
SeedBuiltinPackages(h.stores, dir1)
|
||||||
|
|
||||||
ext, _ := h.stores.Extensions.GetByExtID(ctx, "versioned-ext")
|
pkg, _ := h.stores.Packages.Get(ctx, "versioned-ext")
|
||||||
if ext.Version != "1.0.0" {
|
if pkg.Version != "1.0.0" {
|
||||||
t.Fatalf("v1 version: got %q", ext.Version)
|
t.Fatalf("v1 version: got %q", pkg.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed v2 (different version triggers update)
|
// Seed v2 (different version triggers update)
|
||||||
@@ -928,14 +923,14 @@ func TestSeed_VersionChangeUpdates(t *testing.T) {
|
|||||||
script: `console.log("v2");`,
|
script: `console.log("v2");`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
SeedBuiltinExtensions(h.stores, dir2)
|
SeedBuiltinPackages(h.stores, dir2)
|
||||||
|
|
||||||
ext, _ = h.stores.Extensions.GetByExtID(ctx, "versioned-ext")
|
pkg, _ = h.stores.Packages.Get(ctx, "versioned-ext")
|
||||||
if ext.Version != "2.0.0" {
|
if pkg.Version != "2.0.0" {
|
||||||
t.Fatalf("v2 version: got %q", ext.Version)
|
t.Fatalf("v2 version: got %q", pkg.Version)
|
||||||
}
|
}
|
||||||
if ext.Name != "V2 Name" {
|
if pkg.Title != "V2 Name" {
|
||||||
t.Fatalf("v2 name: got %q", ext.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");`},
|
"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 {
|
if err != nil {
|
||||||
t.Fatalf("list: %v", err)
|
t.Fatalf("list: %v", err)
|
||||||
}
|
}
|
||||||
if len(exts) != 0 {
|
if len(pkgs) != 0 {
|
||||||
t.Fatalf("should skip dir without manifest, got %d extensions", len(exts))
|
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");`},
|
"bad-manifest": {manifest: `{not valid json`, script: `console.log("bad");`},
|
||||||
})
|
})
|
||||||
|
|
||||||
SeedBuiltinExtensions(h.stores, dir)
|
SeedBuiltinPackages(h.stores, dir)
|
||||||
|
|
||||||
exts, _ := h.stores.Extensions.ListAll(ctx)
|
pkgs, _ := h.stores.Packages.List(ctx)
|
||||||
if len(exts) != 0 {
|
if len(pkgs) != 0 {
|
||||||
t.Fatalf("should skip invalid manifest, got %d extensions", len(exts))
|
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");`},
|
"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)
|
pkgs, _ := h.stores.Packages.List(ctx)
|
||||||
if len(exts) != 0 {
|
if len(pkgs) != 0 {
|
||||||
t.Fatalf("should skip manifest without id, got %d extensions", len(exts))
|
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)
|
h := setupExtensionHarness(t)
|
||||||
|
|
||||||
// Should not panic on missing directory
|
// 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())
|
pkgs, _ := h.stores.Packages.List(context.Background())
|
||||||
if len(exts) != 0 {
|
if len(pkgs) != 0 {
|
||||||
t.Fatalf("should seed nothing from missing dir, got %d", len(exts))
|
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"}`},
|
"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 {
|
if err != nil {
|
||||||
t.Fatalf("should create manifest-only ext: %v", err)
|
t.Fatalf("should create manifest-only ext: %v", err)
|
||||||
}
|
}
|
||||||
// Manifest should NOT contain _script
|
// Manifest should NOT contain _script
|
||||||
var manifest map[string]json.RawMessage
|
if _, ok := pkg.Manifest["_script"]; ok {
|
||||||
json.Unmarshal(ext.Manifest, &manifest)
|
|
||||||
if _, ok := manifest["_script"]; ok {
|
|
||||||
t.Fatal("manifest should not contain _script when no script.js exists")
|
t.Fatal("manifest should not contain _script when no script.js exists")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ExtensionHandler serves extension management endpoints.
|
// ExtensionHandler serves extension management endpoints.
|
||||||
|
// v0.28.7: Backed by PackageStore (packages table) instead of ExtensionStore.
|
||||||
type ExtensionHandler struct {
|
type ExtensionHandler struct {
|
||||||
stores store.Stores
|
stores store.Stores
|
||||||
}
|
}
|
||||||
@@ -35,45 +36,41 @@ var validTiers = map[string]bool{
|
|||||||
// GET /api/v1/extensions
|
// GET /api/v1/extensions
|
||||||
func (h *ExtensionHandler) ListUserExtensions(c *gin.Context) {
|
func (h *ExtensionHandler) ListUserExtensions(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
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 {
|
if err != nil {
|
||||||
c.JSON(500, gin.H{"error": "failed to list extensions"})
|
c.JSON(500, gin.H{"error": "failed to list extensions"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if tier != "" {
|
if tier != "" {
|
||||||
filtered := make([]models.UserExtension, 0)
|
filtered := make([]store.UserPackage, 0)
|
||||||
for _, e := range exts {
|
for _, p := range pkgs {
|
||||||
if e.Tier == tier {
|
if p.Tier == tier {
|
||||||
filtered = append(filtered, e)
|
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.
|
// UpdateUserExtensionSettings saves per-user settings for an extension.
|
||||||
// POST /api/v1/extensions/:id/settings
|
// POST /api/v1/extensions/:id/settings
|
||||||
func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
|
func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
extID := c.Param("id")
|
pkgID := c.Param("id")
|
||||||
|
|
||||||
// Verify extension exists
|
// Verify package exists
|
||||||
ext, err := h.stores.Extensions.GetByID(c.Request.Context(), extID)
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
||||||
if err == sql.ErrNoRows {
|
if err != nil || pkg == nil {
|
||||||
c.JSON(404, gin.H{"error": "extension not found"})
|
c.JSON(404, gin.H{"error": "extension not found"})
|
||||||
return
|
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 {
|
var body struct {
|
||||||
Settings json.RawMessage `json:"settings"`
|
Settings json.RawMessage `json:"settings"`
|
||||||
IsEnabled *bool `json:"is_enabled"`
|
IsEnabled *bool `json:"is_enabled"`
|
||||||
@@ -85,7 +82,7 @@ func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
|
|||||||
|
|
||||||
enabled := true
|
enabled := true
|
||||||
if body.IsEnabled != nil {
|
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"})
|
c.JSON(403, gin.H{"error": "system extensions cannot be disabled"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -97,13 +94,13 @@ func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
|
|||||||
settings = body.Settings
|
settings = body.Settings
|
||||||
}
|
}
|
||||||
|
|
||||||
eus := &models.ExtensionUserSettings{
|
pus := &store.PackageUserSettings{
|
||||||
ExtensionID: extID,
|
PackageID: pkgID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Settings: settings,
|
Settings: settings,
|
||||||
IsEnabled: enabled,
|
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"})
|
c.JSON(500, gin.H{"error": "failed to save settings"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -113,19 +110,28 @@ func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
|
|||||||
|
|
||||||
// ── Admin endpoints ─────────────────────────────
|
// ── Admin endpoints ─────────────────────────────
|
||||||
|
|
||||||
// AdminListExtensions returns all extensions (enabled and disabled).
|
// AdminListExtensions returns all extension-type packages.
|
||||||
// GET /api/v1/admin/extensions
|
// GET /api/v1/admin/extensions
|
||||||
func (h *ExtensionHandler) AdminListExtensions(c *gin.Context) {
|
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 {
|
if err != nil {
|
||||||
c.JSON(500, gin.H{"error": "failed to list extensions"})
|
c.JSON(500, gin.H{"error": "failed to list extensions"})
|
||||||
return
|
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
|
// POST /api/v1/admin/extensions
|
||||||
|
// New installs should use POST /admin/packages/install with a .pkg archive.
|
||||||
func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
|
func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
|
|
||||||
@@ -162,32 +168,36 @@ func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate ext_id
|
// Check for duplicate (ext_id is now the package ID)
|
||||||
existing, err := h.stores.Extensions.GetByExtID(c.Request.Context(), body.ExtID)
|
existing, _ := h.stores.Packages.Get(c.Request.Context(), body.ExtID)
|
||||||
if err != nil && err != sql.ErrNoRows {
|
|
||||||
c.JSON(500, gin.H{"error": "failed to check existing extension"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
c.JSON(409, gin.H{"error": "extension with ext_id '" + body.ExtID + "' already installed"})
|
c.JSON(409, gin.H{"error": "extension with ext_id '" + body.ExtID + "' already installed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := &models.Extension{
|
// Parse manifest into map
|
||||||
ExtID: body.ExtID,
|
var manifestMap map[string]any
|
||||||
Name: body.Name,
|
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,
|
Version: body.Version,
|
||||||
Tier: body.Tier,
|
|
||||||
Description: body.Description,
|
Description: body.Description,
|
||||||
Author: body.Author,
|
Author: body.Author,
|
||||||
Manifest: body.Manifest,
|
Tier: body.Tier,
|
||||||
IsSystem: body.IsSystem,
|
IsSystem: body.IsSystem,
|
||||||
IsEnabled: body.IsEnabled,
|
Scope: "global",
|
||||||
Scope: models.ScopeGlobal,
|
Manifest: manifestMap,
|
||||||
|
Enabled: body.IsEnabled,
|
||||||
|
Source: "extension",
|
||||||
InstalledBy: &userID,
|
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) {
|
if database.IsUniqueViolation(err) {
|
||||||
c.JSON(409, gin.H{"error": "extension with ext_id '" + body.ExtID + "' already installed"})
|
c.JSON(409, gin.H{"error": "extension with ext_id '" + body.ExtID + "' already installed"})
|
||||||
return
|
return
|
||||||
@@ -196,23 +206,19 @@ func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
|
|||||||
return
|
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
|
// PUT /api/v1/admin/extensions/:id
|
||||||
func (h *ExtensionHandler) AdminUpdateExtension(c *gin.Context) {
|
func (h *ExtensionHandler) AdminUpdateExtension(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
ext, err := h.stores.Extensions.GetByID(c.Request.Context(), id)
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
||||||
if err == sql.ErrNoRows {
|
if err != nil || pkg == nil {
|
||||||
c.JSON(404, gin.H{"error": "extension not found"})
|
c.JSON(404, gin.H{"error": "extension not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
|
||||||
c.JSON(500, gin.H{"error": "failed to fetch extension"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
var body struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
@@ -229,62 +235,58 @@ func (h *ExtensionHandler) AdminUpdateExtension(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if body.Name != nil {
|
if body.Name != nil {
|
||||||
ext.Name = *body.Name
|
pkg.Title = *body.Name
|
||||||
}
|
}
|
||||||
if body.Version != nil {
|
if body.Version != nil {
|
||||||
ext.Version = *body.Version
|
pkg.Version = *body.Version
|
||||||
}
|
}
|
||||||
if body.Description != nil {
|
if body.Description != nil {
|
||||||
ext.Description = *body.Description
|
pkg.Description = *body.Description
|
||||||
}
|
}
|
||||||
if body.Author != nil {
|
if body.Author != nil {
|
||||||
ext.Author = *body.Author
|
pkg.Author = *body.Author
|
||||||
}
|
}
|
||||||
if body.IsSystem != nil {
|
if body.IsSystem != nil {
|
||||||
ext.IsSystem = *body.IsSystem
|
pkg.IsSystem = *body.IsSystem
|
||||||
}
|
}
|
||||||
if body.IsEnabled != nil {
|
if body.IsEnabled != nil {
|
||||||
ext.IsEnabled = *body.IsEnabled
|
pkg.Enabled = *body.IsEnabled
|
||||||
}
|
}
|
||||||
if body.Manifest != nil {
|
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"})
|
c.JSON(500, gin.H{"error": "failed to update extension"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(200, gin.H{"data": ext})
|
c.JSON(200, gin.H{"data": pkg})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeExtensionAsset serves browser extension JS files.
|
// ServeExtensionAsset serves browser extension JS files.
|
||||||
// GET /api/v1/extensions/:id/assets/*path
|
// 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) {
|
func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) {
|
||||||
extID := c.Param("id")
|
pkgID := c.Param("id")
|
||||||
// path := c.Param("path") // reserved for future multi-file support
|
|
||||||
|
|
||||||
ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID)
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
||||||
if err == sql.ErrNoRows {
|
if err != nil || pkg == nil {
|
||||||
c.JSON(404, gin.H{"error": "extension not found"})
|
c.JSON(404, gin.H{"error": "extension not found"})
|
||||||
return
|
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"})
|
c.JSON(404, gin.H{"error": "extension not enabled"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract inline script from manifest
|
// Extract inline script from manifest
|
||||||
|
manifestBytes := marshalManifest(pkg.Manifest)
|
||||||
var manifest map[string]json.RawMessage
|
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"})
|
c.JSON(500, gin.H{"error": "invalid manifest"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -295,7 +297,6 @@ func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// _script is a JSON string — unquote it
|
|
||||||
var script string
|
var script string
|
||||||
if err := json.Unmarshal(scriptRaw, &script); err != nil {
|
if err := json.Unmarshal(scriptRaw, &script); err != nil {
|
||||||
c.JSON(500, gin.H{"error": "invalid script in manifest"})
|
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.
|
// GetExtensionManifest returns the manifest for a specific extension.
|
||||||
// GET /api/v1/extensions/:id/manifest
|
// GET /api/v1/extensions/:id/manifest
|
||||||
func (h *ExtensionHandler) GetExtensionManifest(c *gin.Context) {
|
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)
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
||||||
if err == sql.ErrNoRows {
|
if err != nil || pkg == nil {
|
||||||
c.JSON(404, gin.H{"error": "extension not found"})
|
c.JSON(404, gin.H{"error": "extension not found"})
|
||||||
return
|
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.
|
// 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
|
// GET /api/v1/extensions/tools
|
||||||
func (h *ExtensionHandler) ListBrowserToolSchemas(c *gin.Context) {
|
func (h *ExtensionHandler) ListBrowserToolSchemas(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
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 {
|
if err != nil {
|
||||||
c.JSON(500, gin.H{"error": "failed to list extensions"})
|
c.JSON(500, gin.H{"error": "failed to list extensions"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var toolSchemas []json.RawMessage
|
var toolSchemas []json.RawMessage
|
||||||
for _, ext := range exts {
|
for _, pkg := range pkgs {
|
||||||
if ext.Tier != "browser" {
|
if pkg.Tier != "browser" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
manifestBytes := marshalManifest(pkg.Manifest)
|
||||||
var manifest struct {
|
var manifest struct {
|
||||||
Tools []json.RawMessage `json:"tools"`
|
Tools []json.RawMessage `json:"tools"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
|
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
|
||||||
log.Printf("[extensions] failed to parse manifest for %s: %v", ext.ExtID, err)
|
log.Printf("[extensions] failed to parse manifest for %s: %v", pkg.ID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
toolSchemas = append(toolSchemas, manifest.Tools...)
|
toolSchemas = append(toolSchemas, manifest.Tools...)
|
||||||
@@ -358,25 +355,39 @@ func (h *ExtensionHandler) ListBrowserToolSchemas(c *gin.Context) {
|
|||||||
c.JSON(200, gin.H{"data": toolSchemas})
|
c.JSON(200, gin.H{"data": toolSchemas})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminUninstallExtension removes an extension.
|
// AdminUninstallExtension removes an extension-type package.
|
||||||
// DELETE /api/v1/admin/extensions/:id
|
// DELETE /api/v1/admin/extensions/:id
|
||||||
func (h *ExtensionHandler) AdminUninstallExtension(c *gin.Context) {
|
func (h *ExtensionHandler) AdminUninstallExtension(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
_, err := h.stores.Extensions.GetByID(c.Request.Context(), id)
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
||||||
if err == sql.ErrNoRows {
|
if err != nil || pkg == nil {
|
||||||
c.JSON(404, gin.H{"error": "extension not found"})
|
c.JSON(404, gin.H{"error": "extension not found"})
|
||||||
return
|
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"})
|
c.JSON(500, gin.H{"error": "failed to uninstall extension"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(200, gin.H{"ok": true})
|
c.JSON(200, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// marshalManifest converts map[string]any → []byte for struct unmarshaling.
|
||||||
|
// PackageRegistration.Manifest is map[string]any (from JSONB scan), but
|
||||||
|
// several callsites need to unmarshal into typed structs.
|
||||||
|
func marshalManifest(m map[string]any) []byte {
|
||||||
|
if m == nil {
|
||||||
|
return []byte("{}")
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return []byte("{}")
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|||||||
433
server/handlers/packages.go
Normal file
433
server/handlers/packages.go
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validPackageID matches lowercase alphanumeric slugs with optional hyphens.
|
||||||
|
// No leading/trailing hyphens, 2-64 chars.
|
||||||
|
var validPackageID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`)
|
||||||
|
|
||||||
|
// PackageHandler manages unified package lifecycle (admin-only).
|
||||||
|
// Replaces SurfaceHandler (v0.28.7).
|
||||||
|
type PackageHandler struct {
|
||||||
|
stores store.Stores
|
||||||
|
packagesDir string // e.g. /data/packages — where archives are extracted
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
|
||||||
|
dir := ""
|
||||||
|
if len(packagesDir) > 0 {
|
||||||
|
dir = packagesDir[0]
|
||||||
|
}
|
||||||
|
return &PackageHandler{stores: s, packagesDir: dir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPackages returns all registered packages.
|
||||||
|
// GET /api/v1/admin/packages
|
||||||
|
// GET /api/v1/admin/surfaces (alias)
|
||||||
|
func (h *PackageHandler) ListPackages(c *gin.Context) {
|
||||||
|
typeFilter := c.Query("type")
|
||||||
|
|
||||||
|
var pkgs []store.PackageRegistration
|
||||||
|
var err error
|
||||||
|
if typeFilter != "" {
|
||||||
|
pkgs, err = h.stores.Packages.ListByType(c.Request.Context(), typeFilter)
|
||||||
|
} else {
|
||||||
|
pkgs, err = h.stores.Packages.List(c.Request.Context())
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pkgs == nil {
|
||||||
|
pkgs = []store.PackageRegistration{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": pkgs})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPackage returns a single package by ID.
|
||||||
|
// GET /api/v1/admin/packages/:id
|
||||||
|
// GET /api/v1/admin/surfaces/:id (alias)
|
||||||
|
func (h *PackageHandler) GetPackage(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
||||||
|
if err != nil || pkg == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnablePackage enables a package.
|
||||||
|
// PUT /api/v1/admin/packages/:id/enable
|
||||||
|
// PUT /api/v1/admin/surfaces/:id/enable (alias)
|
||||||
|
func (h *PackageHandler) EnablePackage(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, true); err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisablePackage disables a package. Chat and Admin cannot be disabled.
|
||||||
|
// PUT /api/v1/admin/packages/:id/disable
|
||||||
|
// PUT /api/v1/admin/surfaces/:id/disable (alias)
|
||||||
|
func (h *PackageHandler) DisablePackage(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
if id == "chat" || id == "admin" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": id + " cannot be disabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, false); err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": false})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePackage uninstalls a package. Core packages cannot be deleted.
|
||||||
|
// DELETE /api/v1/admin/packages/:id
|
||||||
|
// DELETE /api/v1/admin/surfaces/:id (alias)
|
||||||
|
func (h *PackageHandler) DeletePackage(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
||||||
|
if err != nil || pkg == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pkg.Source == "core" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "core packages cannot be uninstalled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete package"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up extracted static assets
|
||||||
|
if h.packagesDir != "" {
|
||||||
|
assetDir := filepath.Join(h.packagesDir, id)
|
||||||
|
if err := os.RemoveAll(assetDir); err != nil {
|
||||||
|
log.Printf("⚠️ Failed to clean up package assets at %s: %v", assetDir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"id": id, "deleted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallPackage uploads and installs a .pkg/.surface archive.
|
||||||
|
// POST /api/v1/admin/packages/install
|
||||||
|
// POST /api/v1/admin/surfaces/install (alias)
|
||||||
|
func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||||
|
file, header, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Validate extension
|
||||||
|
validExt := strings.HasSuffix(header.Filename, ".pkg") ||
|
||||||
|
strings.HasSuffix(header.Filename, ".surface") ||
|
||||||
|
strings.HasSuffix(header.Filename, ".zip")
|
||||||
|
if !validExt {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size limit: 50MB
|
||||||
|
if header.Size > 50*1024*1024 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read into temp file for zip processing
|
||||||
|
tmpFile, err := os.CreateTemp("", "package-*.zip")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
defer os.Remove(tmpPath)
|
||||||
|
|
||||||
|
if _, err := io.Copy(tmpFile, file); err != nil {
|
||||||
|
tmpFile.Close()
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmpFile.Close()
|
||||||
|
|
||||||
|
// Open as zip
|
||||||
|
zr, err := zip.OpenReader(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
|
||||||
|
// Find and parse manifest.json
|
||||||
|
var manifest map[string]any
|
||||||
|
for _, f := range zr.File {
|
||||||
|
name := filepath.Base(f.Name)
|
||||||
|
if name == "manifest.json" && !f.FileInfo().IsDir() {
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(rc)
|
||||||
|
rc.Close()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifest == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
pkgID, _ := manifest["id"].(string)
|
||||||
|
title, _ := manifest["title"].(string)
|
||||||
|
if pkgID == "" || title == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest must have 'id' and 'title'"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate package ID is a safe slug
|
||||||
|
if !validPackageID.MatchString(pkgID) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "package id must be a lowercase slug (a-z, 0-9, hyphens, 2-64 chars)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine type (default: surface for backward compat)
|
||||||
|
pkgType, _ := manifest["type"].(string)
|
||||||
|
if pkgType == "" {
|
||||||
|
pkgType = "surface"
|
||||||
|
}
|
||||||
|
if pkgType != "surface" && pkgType != "extension" && pkgType != "full" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', or 'full'"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type-specific validation
|
||||||
|
hasRoute := manifest["route"] != nil || manifest["routes"] != nil
|
||||||
|
hasTools := manifest["tools"] != nil
|
||||||
|
hasPipes := manifest["pipes"] != nil
|
||||||
|
hasHooks := manifest["hooks"] != nil
|
||||||
|
hasExtBehavior := hasTools || hasPipes || hasHooks
|
||||||
|
|
||||||
|
switch pkgType {
|
||||||
|
case "surface":
|
||||||
|
// route is optional for surfaces (core surfaces don't always have it in manifest)
|
||||||
|
case "extension":
|
||||||
|
if !hasExtBehavior {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "extension packages require at least one of: tools, pipes, hooks"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hasRoute {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "extension packages cannot have a route — use type 'full' for both"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "full":
|
||||||
|
if !hasRoute {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "full packages require a route"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !hasExtBehavior {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "full packages require at least one of: tools, pipes, hooks"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for conflicts with core packages
|
||||||
|
existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
||||||
|
if existing != nil && existing.Source == "core" {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core package: " + pkgID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract static assets (js/, css/, assets/) to packagesDir/{id}/
|
||||||
|
if h.packagesDir != "" {
|
||||||
|
destDir := filepath.Join(h.packagesDir, pkgID)
|
||||||
|
os.MkdirAll(destDir, 0755)
|
||||||
|
|
||||||
|
for _, f := range zr.File {
|
||||||
|
if f.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath := extractableRelPath(f.Name)
|
||||||
|
if relPath == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
destPath := filepath.Join(destDir, relPath)
|
||||||
|
|
||||||
|
// Security: prevent path traversal
|
||||||
|
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
os.MkdirAll(filepath.Dir(destPath), 0755)
|
||||||
|
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
rc.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
io.Copy(out, rc)
|
||||||
|
out.Close()
|
||||||
|
rc.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[packages] Extracted %s to %s", pkgID, destDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract manifest fields for DB columns
|
||||||
|
version, _ := manifest["version"].(string)
|
||||||
|
if version == "" {
|
||||||
|
version = "0.0.0"
|
||||||
|
}
|
||||||
|
description, _ := manifest["description"].(string)
|
||||||
|
author, _ := manifest["author"].(string)
|
||||||
|
tier, _ := manifest["tier"].(string)
|
||||||
|
if tier == "" {
|
||||||
|
tier = "browser"
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
|
||||||
|
// Register in database via Seed (upsert — handles re-install)
|
||||||
|
if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, title, "extension", manifest); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register package"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back to get the preserved enabled state (Seed doesn't override it)
|
||||||
|
existing, _ = h.stores.Packages.Get(c.Request.Context(), pkgID)
|
||||||
|
preservedEnabled := true
|
||||||
|
if existing != nil {
|
||||||
|
preservedEnabled = existing.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fields that Seed doesn't set (type, version, author, etc.)
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
Title: title,
|
||||||
|
Type: pkgType,
|
||||||
|
Version: version,
|
||||||
|
Description: description,
|
||||||
|
Author: author,
|
||||||
|
Tier: tier,
|
||||||
|
IsSystem: false,
|
||||||
|
Enabled: preservedEnabled,
|
||||||
|
Manifest: manifest,
|
||||||
|
}
|
||||||
|
if userID != "" {
|
||||||
|
pkg.InstalledBy = &userID
|
||||||
|
}
|
||||||
|
if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil {
|
||||||
|
log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"id": pkgID,
|
||||||
|
"title": title,
|
||||||
|
"type": pkgType,
|
||||||
|
"version": version,
|
||||||
|
"source": "extension",
|
||||||
|
"enabled": preservedEnabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractableRelPath returns the relative path for a zip entry if it
|
||||||
|
// belongs to an extractable static directory (js/, css/, assets/).
|
||||||
|
// Returns "" if the file should be skipped.
|
||||||
|
func extractableRelPath(name string) string {
|
||||||
|
staticPrefixes := []string{"js/", "css/", "assets/"}
|
||||||
|
|
||||||
|
for _, p := range staticPrefixes {
|
||||||
|
if strings.HasPrefix(name, p) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := strings.Index(name, "/")
|
||||||
|
if idx <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := name[idx+1:]
|
||||||
|
for _, p := range staticPrefixes {
|
||||||
|
if strings.HasPrefix(rest, p) {
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEnabledSurfaces returns enabled surface/full packages for nav rendering.
|
||||||
|
// GET /api/v1/surfaces
|
||||||
|
func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
|
||||||
|
pkgs, err := h.stores.Packages.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type navSurface struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Route string `json:"route"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var enabled []navSurface
|
||||||
|
for _, p := range pkgs {
|
||||||
|
if !p.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Only surface and full types have routes
|
||||||
|
if p.Type != "surface" && p.Type != "full" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
route, _ := p.Manifest["route"].(string)
|
||||||
|
enabled = append(enabled, navSurface{
|
||||||
|
ID: p.ID,
|
||||||
|
Title: p.Title,
|
||||||
|
Route: route,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if enabled == nil {
|
||||||
|
enabled = []navSurface{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": enabled})
|
||||||
|
}
|
||||||
@@ -206,14 +206,14 @@ func BuildToolDefs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Append browser-defined tool schemas from extensions
|
// Append browser-defined tool schemas from extensions
|
||||||
if includeBrowser && stores.Extensions != nil {
|
if includeBrowser && stores.Packages != nil {
|
||||||
exts, err := stores.Extensions.ListForUser(ctx, userID)
|
pkgs, err := stores.Packages.ListForUser(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("⚠️ Failed to load extensions for tools: %v", err)
|
log.Printf("⚠️ Failed to load extensions for tools: %v", err)
|
||||||
return defs
|
return defs
|
||||||
}
|
}
|
||||||
for _, ext := range exts {
|
for _, pkg := range pkgs {
|
||||||
if ext.Tier != "browser" {
|
if pkg.Tier != "browser" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var manifest struct {
|
var manifest struct {
|
||||||
@@ -224,7 +224,7 @@ func BuildToolDefs(
|
|||||||
Tier string `json:"tier"`
|
Tier string `json:"tier"`
|
||||||
} `json:"tools"`
|
} `json:"tools"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
|
if err := json.Unmarshal(marshalManifest(pkg.Manifest), &manifest); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, t := range manifest.Tools {
|
for _, t := range manifest.Tools {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,9 +24,9 @@ type builtinManifest struct {
|
|||||||
Settings json.RawMessage `json:"settings"`
|
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
|
// (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:
|
// Layout:
|
||||||
//
|
//
|
||||||
@@ -36,10 +35,10 @@ type builtinManifest struct {
|
|||||||
// manifest.json
|
// manifest.json
|
||||||
// script.js
|
// 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.
|
// updates those with a newer version, creates new ones.
|
||||||
func SeedBuiltinExtensions(stores store.Stores, extensionsDir string) {
|
func SeedBuiltinPackages(stores store.Stores, extensionsDir string) {
|
||||||
if stores.Extensions == nil {
|
if stores.Packages == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,64 +88,92 @@ func SeedBuiltinExtensions(stores store.Stores, extensionsDir string) {
|
|||||||
script = string(scriptData)
|
script = string(scriptData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the full manifest JSONB with _script inlined
|
// Build the full manifest with _script inlined
|
||||||
fullManifest := buildFullManifest(manifestData, script)
|
fullManifest := buildFullManifest(manifestData, script)
|
||||||
|
|
||||||
|
// Parse into map for Seed/Update
|
||||||
|
var manifestMap map[string]any
|
||||||
|
json.Unmarshal(fullManifest, &manifestMap)
|
||||||
|
|
||||||
// Check if already installed
|
// Check if already installed
|
||||||
existing, err := stores.Extensions.GetByExtID(ctx, manifest.ID)
|
existing, err := stores.Packages.Get(ctx, manifest.ID)
|
||||||
if err == nil {
|
if err == nil && existing != nil {
|
||||||
// Already exists — check version
|
// 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.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++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Version changed — update
|
// Version changed — update
|
||||||
existing.Name = manifest.Name
|
pkg := &store.PackageRegistration{
|
||||||
existing.Version = manifest.Version
|
Title: manifest.Name,
|
||||||
existing.Description = manifest.Description
|
Type: "extension",
|
||||||
existing.Author = manifest.Author
|
Version: manifest.Version,
|
||||||
existing.Manifest = fullManifest
|
Description: manifest.Description,
|
||||||
existing.IsSystem = true
|
Author: manifest.Author,
|
||||||
existing.IsEnabled = true
|
Tier: tier,
|
||||||
|
Manifest: manifestMap,
|
||||||
if err := stores.Extensions.Update(ctx, existing.ID, existing); err != nil {
|
IsSystem: true,
|
||||||
log.Printf("⚠ Failed to update builtin extension %s: %v", manifest.ID, err)
|
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
|
continue
|
||||||
}
|
}
|
||||||
updated++
|
updated++
|
||||||
continue
|
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
|
tier := manifest.Tier
|
||||||
if tier == "" {
|
if tier == "" {
|
||||||
tier = models.ExtTierBrowser
|
tier = "browser"
|
||||||
}
|
}
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
ext := &models.Extension{
|
Title: manifest.Name,
|
||||||
ExtID: manifest.ID,
|
Type: "extension",
|
||||||
Name: manifest.Name,
|
|
||||||
Version: manifest.Version,
|
Version: manifest.Version,
|
||||||
Tier: tier,
|
|
||||||
Description: manifest.Description,
|
Description: manifest.Description,
|
||||||
Author: manifest.Author,
|
Author: manifest.Author,
|
||||||
Manifest: fullManifest,
|
Tier: tier,
|
||||||
|
Manifest: manifestMap,
|
||||||
IsSystem: true,
|
IsSystem: true,
|
||||||
IsEnabled: true,
|
Enabled: true,
|
||||||
Scope: "global",
|
|
||||||
}
|
}
|
||||||
|
if err := stores.Packages.Update(ctx, manifest.ID, pkg); err != nil {
|
||||||
if err := stores.Extensions.Create(ctx, ext); err != nil {
|
log.Printf("⚠ Failed to update builtin package metadata %s: %v", manifest.ID, err)
|
||||||
log.Printf("⚠ Failed to seed builtin extension %s: %v", manifest.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
seeded++
|
seeded++
|
||||||
}
|
}
|
||||||
|
|
||||||
if seeded+updated > 0 {
|
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 {
|
} 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)
|
return json.RawMessage(rawManifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse manifest as generic map, add _script, re-serialize
|
|
||||||
var m map[string]json.RawMessage
|
var m map[string]json.RawMessage
|
||||||
if err := json.Unmarshal(rawManifest, &m); err != nil {
|
if err := json.Unmarshal(rawManifest, &m); err != nil {
|
||||||
// Fallback: just return raw manifest
|
|
||||||
return json.RawMessage(rawManifest)
|
return json.RawMessage(rawManifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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})
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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
|
// F8: Team Task Routes
|
||||||
// ═══════════════════════════════════════════════
|
// ═══════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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.
|
// v0.28.0-audit: Workflow task execution is not yet implemented.
|
||||||
// Reject at the API boundary to prevent silent wrong behavior
|
// Reject at the API boundary to prevent silent wrong behavior
|
||||||
// (workflow tasks would fall through to the prompt pipeline).
|
// (workflow tasks would fall through to the prompt pipeline).
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ func main() {
|
|||||||
handlers.SeedProviders(cfg, stores, keyResolver)
|
handlers.SeedProviders(cfg, stores, keyResolver)
|
||||||
|
|
||||||
// Seed builtin extensions from disk (idempotent, version-aware)
|
// 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)
|
// Load search provider config from DB (defaults to DuckDuckGo if not set)
|
||||||
loadSearchConfig(stores)
|
loadSearchConfig(stores)
|
||||||
@@ -662,9 +662,9 @@ func main() {
|
|||||||
protected.POST("/chat/completions", comp.Complete)
|
protected.POST("/chat/completions", comp.Complete)
|
||||||
protected.GET("/tools", comp.ListTools)
|
protected.GET("/tools", comp.ListTools)
|
||||||
|
|
||||||
// Surface discovery (v0.25.0)
|
// Surface discovery (v0.25.0, v0.28.7: unified packages)
|
||||||
surfaceH := handlers.NewSurfaceHandler(stores)
|
pkgH := handlers.NewPackageHandler(stores)
|
||||||
protected.GET("/surfaces", surfaceH.ListEnabledSurfaces)
|
protected.GET("/surfaces", pkgH.ListEnabledSurfaces)
|
||||||
|
|
||||||
// Summarize & Continue (backed by compaction service)
|
// Summarize & Continue (backed by compaction service)
|
||||||
compactionSvc := compaction.NewService(stores, roleResolver)
|
compactionSvc := compaction.NewService(stores, roleResolver)
|
||||||
@@ -1124,18 +1124,26 @@ func main() {
|
|||||||
admin.DELETE("/routing/policies/:id", routingAdm.DeletePolicy)
|
admin.DELETE("/routing/policies/:id", routingAdm.DeletePolicy)
|
||||||
admin.POST("/routing/test", routingAdm.TestRouting)
|
admin.POST("/routing/test", routingAdm.TestRouting)
|
||||||
|
|
||||||
// Surface lifecycle management (v0.25.0)
|
// Package lifecycle management (v0.28.7 — replaces surfaces + extensions admin)
|
||||||
surfacesDir := ""
|
packagesDir := ""
|
||||||
if cfg.StoragePath != "" {
|
if cfg.StoragePath != "" {
|
||||||
surfacesDir = cfg.StoragePath + "/surfaces"
|
packagesDir = cfg.StoragePath + "/packages"
|
||||||
}
|
}
|
||||||
surfaceAdm := handlers.NewSurfaceHandler(stores, surfacesDir)
|
pkgAdm := handlers.NewPackageHandler(stores, packagesDir)
|
||||||
admin.GET("/surfaces", surfaceAdm.ListSurfaces)
|
admin.GET("/packages", pkgAdm.ListPackages)
|
||||||
admin.GET("/surfaces/:id", surfaceAdm.GetSurface)
|
admin.GET("/packages/:id", pkgAdm.GetPackage)
|
||||||
admin.POST("/surfaces/install", surfaceAdm.InstallSurface)
|
admin.POST("/packages/install", pkgAdm.InstallPackage)
|
||||||
admin.PUT("/surfaces/:id/enable", surfaceAdm.EnableSurface)
|
admin.PUT("/packages/:id/enable", pkgAdm.EnablePackage)
|
||||||
admin.PUT("/surfaces/:id/disable", surfaceAdm.DisableSurface)
|
admin.PUT("/packages/:id/disable", pkgAdm.DisablePackage)
|
||||||
admin.DELETE("/surfaces/:id", surfaceAdm.DeleteSurface)
|
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)
|
// Task management — admin (v0.27.1, extended v0.27.2)
|
||||||
taskAdm := handlers.NewTaskHandler(stores)
|
taskAdm := handlers.NewTaskHandler(stores)
|
||||||
@@ -1154,15 +1162,16 @@ func main() {
|
|||||||
|
|
||||||
// v0.27.0: Extension surface static assets (JS, CSS, images).
|
// v0.27.0: Extension surface static assets (JS, CSS, images).
|
||||||
// Served without auth — same rationale as extension assets (script tags can't send headers).
|
// 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 != "" {
|
if cfg.StoragePath != "" {
|
||||||
surfacesStaticDir := cfg.StoragePath + "/surfaces"
|
packagesStaticDir := cfg.StoragePath + "/packages"
|
||||||
base.GET("/surfaces/:id/*path", func(c *gin.Context) {
|
base.GET("/surfaces/:id/*path", func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
filePath := c.Param("path")
|
filePath := c.Param("path")
|
||||||
// Security: prevent path traversal
|
// Security: prevent path traversal
|
||||||
clean := filepath.Clean(filepath.Join(surfacesStaticDir, id, filePath))
|
clean := filepath.Clean(filepath.Join(packagesStaticDir, id, filePath))
|
||||||
if !strings.HasPrefix(clean, filepath.Clean(surfacesStaticDir)) {
|
if !strings.HasPrefix(clean, filepath.Clean(packagesStaticDir)) {
|
||||||
c.Status(http.StatusForbidden)
|
c.Status(http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,11 +124,11 @@ type ExtensionNavItem struct {
|
|||||||
// extensionNavItems returns enabled extension surfaces for sidebar rendering.
|
// extensionNavItems returns enabled extension surfaces for sidebar rendering.
|
||||||
// Queries the DB at render time so newly installed surfaces appear immediately.
|
// Queries the DB at render time so newly installed surfaces appear immediately.
|
||||||
func (e *Engine) extensionNavItems() []ExtensionNavItem {
|
func (e *Engine) extensionNavItems() []ExtensionNavItem {
|
||||||
if e.stores.Surfaces == nil {
|
if e.stores.Packages == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
surfaces, err := e.stores.Surfaces.List(ctx)
|
surfaces, err := e.stores.Packages.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[pages] Failed to load extension surfaces for nav: %v", err)
|
log.Printf("[pages] Failed to load extension surfaces for nav: %v", err)
|
||||||
return nil
|
return nil
|
||||||
@@ -139,6 +139,10 @@ func (e *Engine) extensionNavItems() []ExtensionNavItem {
|
|||||||
if sr.Source == "core" || !sr.Enabled {
|
if sr.Source == "core" || !sr.Enabled {
|
||||||
continue
|
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
|
// Editor is Source="extension" but hardcoded as core — skip from dynamic nav
|
||||||
if sr.ID == "editor" {
|
if sr.ID == "editor" {
|
||||||
continue
|
continue
|
||||||
@@ -367,13 +371,13 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if e.stores.Surfaces == nil {
|
if e.stores.Packages == nil {
|
||||||
c.String(http.StatusNotFound, "Surface registry not available")
|
c.String(http.StatusNotFound, "Surface registry not available")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up by ID (slug == surface ID)
|
// 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 {
|
if err != nil || sr == nil {
|
||||||
c.String(http.StatusNotFound, "Surface not found: "+slug)
|
c.String(http.StatusNotFound, "Surface not found: "+slug)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
// Called once at startup. Does NOT overwrite the enabled flag — admin
|
// Called once at startup. Does NOT overwrite the enabled flag — admin
|
||||||
// toggles survive restarts.
|
// toggles survive restarts.
|
||||||
func (e *Engine) SeedSurfaces() {
|
func (e *Engine) SeedSurfaces() {
|
||||||
if e.stores.Surfaces == nil {
|
if e.stores.Packages == nil {
|
||||||
log.Printf("[pages] Surface registry not available — skipping seed")
|
log.Printf("[pages] Surface registry not available — skipping seed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ func (e *Engine) SeedSurfaces() {
|
|||||||
"auth": s.Auth,
|
"auth": s.Auth,
|
||||||
"layout": s.Layout,
|
"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)
|
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" {
|
if surfaceID == "chat" || surfaceID == "admin" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if e.stores.Surfaces == nil {
|
if e.stores.Packages == nil {
|
||||||
return true // No registry = all surfaces enabled (backward compat)
|
return true // No registry = all surfaces enabled (backward compat)
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
sr, err := e.stores.Surfaces.Get(ctx, surfaceID)
|
sr, err := e.stores.Packages.Get(ctx, surfaceID)
|
||||||
if err != nil || sr == nil {
|
if err != nil || sr == nil {
|
||||||
return true // Not in registry = assume enabled (backward compat)
|
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.
|
// EnabledSurfaceIDs returns a list of enabled surface IDs for nav rendering.
|
||||||
func (e *Engine) EnabledSurfaceIDs() []string {
|
func (e *Engine) EnabledSurfaceIDs() []string {
|
||||||
if e.stores.Surfaces == nil {
|
if e.stores.Packages == nil {
|
||||||
// No registry — return all core surface IDs
|
// No registry — return all core surface IDs
|
||||||
var all []string
|
var all []string
|
||||||
for _, s := range e.surfaces {
|
for _, s := range e.surfaces {
|
||||||
@@ -64,7 +64,7 @@ func (e *Engine) EnabledSurfaceIDs() []string {
|
|||||||
return all
|
return all
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
ids, err := e.stores.Surfaces.ListEnabled(ctx)
|
ids, err := e.stores.Packages.ListEnabled(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[pages] Failed to load enabled surfaces: %v", err)
|
log.Printf("[pages] Failed to load enabled surfaces: %v", err)
|
||||||
// Fallback: return all core surface IDs
|
// Fallback: return all core surface IDs
|
||||||
|
|||||||
@@ -219,7 +219,7 @@
|
|||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/workflow-api.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/workflow-api.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/workflow-admin.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/workflow-admin.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/task-admin.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/task-admin.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-surfaces.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-packages.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/broadcast-admin.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/broadcast-admin.js?v={{.Version}}"></script>
|
||||||
<script nonce="{{.CSPNonce}}">
|
<script nonce="{{.CSPNonce}}">
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ type Stores struct {
|
|||||||
GlobalConfig GlobalConfigStore
|
GlobalConfig GlobalConfigStore
|
||||||
Usage UsageStore
|
Usage UsageStore
|
||||||
Pricing PricingStore
|
Pricing PricingStore
|
||||||
Extensions ExtensionStore
|
|
||||||
Files FileStore
|
Files FileStore
|
||||||
KnowledgeBases KnowledgeBaseStore
|
KnowledgeBases KnowledgeBaseStore
|
||||||
Groups GroupStore
|
Groups GroupStore
|
||||||
@@ -53,7 +52,7 @@ type Stores struct {
|
|||||||
CapOverrides CapabilityOverrideStore
|
CapOverrides CapabilityOverrideStore
|
||||||
RoutingPolicies RoutingPolicyStore
|
RoutingPolicies RoutingPolicyStore
|
||||||
Sessions SessionStore
|
Sessions SessionStore
|
||||||
Surfaces SurfaceRegistryStore // v0.25.0: Surface lifecycle management
|
Packages PackageStore // v0.28.7: Unified package registry (surfaces + extensions)
|
||||||
Workflows WorkflowStore // v0.26.1: Workflow definitions + stages
|
Workflows WorkflowStore // v0.26.1: Workflow definitions + stages
|
||||||
Tasks TaskStore // v0.27.1: Task scheduling + run history
|
Tasks TaskStore // v0.27.1: Task scheduling + run history
|
||||||
}
|
}
|
||||||
@@ -402,29 +401,6 @@ type PricingStore interface {
|
|||||||
Delete(ctx context.Context, providerConfigID, modelID string) error
|
Delete(ctx context.Context, providerConfigID, modelID string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// EXTENSION STORE
|
|
||||||
// =========================================
|
|
||||||
|
|
||||||
type ExtensionStore interface {
|
|
||||||
// Admin CRUD
|
|
||||||
Create(ctx context.Context, ext *models.Extension) error
|
|
||||||
GetByID(ctx context.Context, id string) (*models.Extension, error)
|
|
||||||
GetByExtID(ctx context.Context, extID string) (*models.Extension, error)
|
|
||||||
Update(ctx context.Context, id string, ext *models.Extension) error
|
|
||||||
Delete(ctx context.Context, id string) error
|
|
||||||
|
|
||||||
// Listing
|
|
||||||
ListAll(ctx context.Context) ([]models.Extension, error)
|
|
||||||
ListEnabled(ctx context.Context) ([]models.Extension, error)
|
|
||||||
ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error)
|
|
||||||
|
|
||||||
// User settings
|
|
||||||
GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error)
|
|
||||||
SetUserSettings(ctx context.Context, s *models.ExtensionUserSettings) error
|
|
||||||
DeleteUserSettings(ctx context.Context, extID, userID string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// FILE STORE
|
// FILE STORE
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
101
server/store/package_iface.go
Normal file
101
server/store/package_iface.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PackageStore manages the unified package registry (surfaces + extensions).
|
||||||
|
// Replaces SurfaceRegistryStore and ExtensionStore (v0.28.7).
|
||||||
|
type PackageStore interface {
|
||||||
|
// ── Lifecycle (from SurfaceRegistryStore) ────────────
|
||||||
|
|
||||||
|
// Seed upserts a package at startup. Does NOT overwrite the enabled
|
||||||
|
// flag if the row already exists (admin toggle survives restarts).
|
||||||
|
// Called by the page engine for core surfaces and by
|
||||||
|
// SeedBuiltinPackages() for builtin extensions.
|
||||||
|
Seed(ctx context.Context, id, title, source string, manifest map[string]any) error
|
||||||
|
|
||||||
|
// List returns all registered packages, ordered by source then title.
|
||||||
|
List(ctx context.Context) ([]PackageRegistration, error)
|
||||||
|
|
||||||
|
// Get returns a single package by ID (slug).
|
||||||
|
Get(ctx context.Context, id string) (*PackageRegistration, error)
|
||||||
|
|
||||||
|
// SetEnabled toggles a package's enabled state.
|
||||||
|
SetEnabled(ctx context.Context, id string, enabled bool) error
|
||||||
|
|
||||||
|
// Delete removes a non-core package. Core packages cannot be deleted.
|
||||||
|
Delete(ctx context.Context, id string) error
|
||||||
|
|
||||||
|
// ListEnabled returns IDs of all enabled packages.
|
||||||
|
// Used by the page engine for nav rendering, not exposed via HTTP.
|
||||||
|
ListEnabled(ctx context.Context) ([]string, error)
|
||||||
|
|
||||||
|
// ── Extended lifecycle ───────────────────────────────
|
||||||
|
|
||||||
|
// Create inserts a new package with all fields. Used by the install
|
||||||
|
// handler and by SeedBuiltinPackages() for first-time seeding.
|
||||||
|
Create(ctx context.Context, pkg *PackageRegistration) error
|
||||||
|
|
||||||
|
// Update modifies mutable fields on an existing package.
|
||||||
|
Update(ctx context.Context, id string, pkg *PackageRegistration) error
|
||||||
|
|
||||||
|
// ListByType returns packages filtered by type (surface, extension, full).
|
||||||
|
ListByType(ctx context.Context, pkgType string) ([]PackageRegistration, error)
|
||||||
|
|
||||||
|
// ListEnabledByType returns enabled packages of a given type.
|
||||||
|
ListEnabledByType(ctx context.Context, pkgType string) ([]PackageRegistration, error)
|
||||||
|
|
||||||
|
// ── Extension runtime (from ExtensionStore) ─────────
|
||||||
|
|
||||||
|
// ListForUser returns enabled extension/full packages with per-user
|
||||||
|
// settings overlay from package_user_settings. System packages are
|
||||||
|
// always included (users can't disable them).
|
||||||
|
ListForUser(ctx context.Context, userID string) ([]UserPackage, error)
|
||||||
|
|
||||||
|
// GetUserSettings returns per-user settings for a specific package.
|
||||||
|
GetUserSettings(ctx context.Context, pkgID, userID string) (*PackageUserSettings, error)
|
||||||
|
|
||||||
|
// SetUserSettings upserts per-user settings for a package.
|
||||||
|
SetUserSettings(ctx context.Context, s *PackageUserSettings) error
|
||||||
|
|
||||||
|
// DeleteUserSettings removes per-user settings, reverting to defaults.
|
||||||
|
DeleteUserSettings(ctx context.Context, pkgID, userID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackageRegistration is a row from the packages table.
|
||||||
|
type PackageRegistration struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
Title string `json:"title" db:"title"`
|
||||||
|
Type string `json:"type" db:"type"`
|
||||||
|
Version string `json:"version" db:"version"`
|
||||||
|
Description string `json:"description" db:"description"`
|
||||||
|
Author string `json:"author" db:"author"`
|
||||||
|
Tier string `json:"tier" db:"tier"`
|
||||||
|
IsSystem bool `json:"is_system" db:"is_system"`
|
||||||
|
Scope string `json:"scope" db:"scope"`
|
||||||
|
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
||||||
|
InstalledBy *string `json:"installed_by,omitempty" db:"installed_by"`
|
||||||
|
Manifest map[string]any `json:"manifest" db:"manifest"`
|
||||||
|
Enabled bool `json:"enabled" db:"enabled"`
|
||||||
|
Source string `json:"source" db:"source"`
|
||||||
|
InstalledAt string `json:"installed_at" db:"installed_at"`
|
||||||
|
UpdatedAt string `json:"updated_at" db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserPackage combines package info with per-user settings for API responses.
|
||||||
|
// Returned by ListForUser for the /extensions endpoint.
|
||||||
|
type UserPackage struct {
|
||||||
|
PackageRegistration
|
||||||
|
UserEnabled *bool `json:"user_enabled,omitempty"`
|
||||||
|
UserSettings *json.RawMessage `json:"user_settings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackageUserSettings stores per-user overrides for a package.
|
||||||
|
type PackageUserSettings struct {
|
||||||
|
PackageID string `json:"package_id" db:"package_id"`
|
||||||
|
UserID string `json:"user_id" db:"user_id"`
|
||||||
|
Settings json.RawMessage `json:"settings" db:"settings"`
|
||||||
|
IsEnabled bool `json:"is_enabled" db:"is_enabled"`
|
||||||
|
}
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ExtensionStore struct{}
|
|
||||||
|
|
||||||
func NewExtensionStore() *ExtensionStore { return &ExtensionStore{} }
|
|
||||||
|
|
||||||
func (s *ExtensionStore) Create(ctx context.Context, ext *models.Extension) error {
|
|
||||||
return DB.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO extensions (ext_id, name, version, tier, description, author,
|
|
||||||
manifest, is_system, is_enabled, scope, team_id, installed_by)
|
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
|
||||||
RETURNING id, created_at, updated_at`,
|
|
||||||
ext.ExtID, ext.Name, ext.Version, ext.Tier, ext.Description, ext.Author,
|
|
||||||
ext.Manifest, ext.IsSystem, ext.IsEnabled, ext.Scope,
|
|
||||||
models.NullString(ext.TeamID), models.NullString(ext.InstalledBy),
|
|
||||||
).Scan(&ext.ID, &ext.CreatedAt, &ext.UpdatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ExtensionStore) GetByID(ctx context.Context, id string) (*models.Extension, error) {
|
|
||||||
return s.scanOne(ctx, `SELECT * FROM extensions WHERE id = $1`, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ExtensionStore) GetByExtID(ctx context.Context, extID string) (*models.Extension, error) {
|
|
||||||
return s.scanOne(ctx, `SELECT * FROM extensions WHERE ext_id = $1`, extID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ExtensionStore) Update(ctx context.Context, id string, ext *models.Extension) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE extensions SET
|
|
||||||
name = $2, version = $3, description = $4, author = $5,
|
|
||||||
manifest = $6, is_system = $7, is_enabled = $8,
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = $1`,
|
|
||||||
id, ext.Name, ext.Version, ext.Description, ext.Author,
|
|
||||||
ext.Manifest, ext.IsSystem, ext.IsEnabled,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ExtensionStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `DELETE FROM extensions WHERE id = $1`, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ExtensionStore) ListAll(ctx context.Context) ([]models.Extension, error) {
|
|
||||||
return s.scanMany(ctx, `SELECT * FROM extensions ORDER BY name`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListEnabled returns all globally enabled extensions regardless of user.
|
|
||||||
// TODO(v0.29.0): Used by Starlark runtime to load server-side extensions at startup.
|
|
||||||
func (s *ExtensionStore) ListEnabled(ctx context.Context) ([]models.Extension, error) {
|
|
||||||
return s.scanMany(ctx, `SELECT * FROM extensions WHERE is_enabled = true ORDER BY name`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListForUser returns all enabled extensions with user-specific overrides merged in.
|
|
||||||
// System extensions are always included (users can't disable them).
|
|
||||||
// Non-system extensions respect per-user enabled toggle.
|
|
||||||
func (s *ExtensionStore) ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT e.id, e.ext_id, e.name, e.version, e.tier, e.description, e.author,
|
|
||||||
e.manifest, e.is_system, e.is_enabled, e.scope, e.team_id, e.installed_by,
|
|
||||||
e.created_at, e.updated_at,
|
|
||||||
eus.is_enabled AS user_enabled,
|
|
||||||
eus.settings AS user_settings
|
|
||||||
FROM extensions e
|
|
||||||
LEFT JOIN extension_user_settings eus
|
|
||||||
ON eus.extension_id = e.id AND eus.user_id = $1
|
|
||||||
WHERE e.is_enabled = true
|
|
||||||
AND (e.is_system = true OR COALESCE(eus.is_enabled, true) = true)
|
|
||||||
ORDER BY e.name`, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.UserExtension
|
|
||||||
for rows.Next() {
|
|
||||||
var ue models.UserExtension
|
|
||||||
var teamID, installedBy sql.NullString
|
|
||||||
var userEnabled sql.NullBool
|
|
||||||
var userSettings []byte
|
|
||||||
|
|
||||||
if err := rows.Scan(
|
|
||||||
&ue.ID, &ue.ExtID, &ue.Name, &ue.Version, &ue.Tier,
|
|
||||||
&ue.Description, &ue.Author, &ue.Manifest, &ue.IsSystem,
|
|
||||||
&ue.IsEnabled, &ue.Scope, &teamID, &installedBy,
|
|
||||||
&ue.CreatedAt, &ue.UpdatedAt,
|
|
||||||
&userEnabled, &userSettings,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ue.TeamID = NullableStringPtr(teamID)
|
|
||||||
ue.InstalledBy = NullableStringPtr(installedBy)
|
|
||||||
if userEnabled.Valid {
|
|
||||||
ue.UserEnabled = &userEnabled.Bool
|
|
||||||
}
|
|
||||||
if userSettings != nil {
|
|
||||||
raw := json.RawMessage(userSettings)
|
|
||||||
ue.UserSettings = &raw
|
|
||||||
}
|
|
||||||
result = append(result, ue)
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
result = make([]models.UserExtension, 0)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserSettings returns per-user settings for a specific extension.
|
|
||||||
// TODO(v0.29.0): Used by Starlark runtime to load per-user config for server-side extensions.
|
|
||||||
func (s *ExtensionStore) GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error) {
|
|
||||||
var eus models.ExtensionUserSettings
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT extension_id, user_id, settings, is_enabled
|
|
||||||
FROM extension_user_settings
|
|
||||||
WHERE extension_id = $1 AND user_id = $2`,
|
|
||||||
extID, userID,
|
|
||||||
).Scan(&eus.ExtensionID, &eus.UserID, &eus.Settings, &eus.IsEnabled)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &eus, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ExtensionStore) SetUserSettings(ctx context.Context, eus *models.ExtensionUserSettings) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO extension_user_settings (extension_id, user_id, settings, is_enabled)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
ON CONFLICT (extension_id, user_id)
|
|
||||||
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
|
|
||||||
eus.ExtensionID, eus.UserID, eus.Settings, eus.IsEnabled,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteUserSettings removes per-user settings, reverting to defaults.
|
|
||||||
// TODO(v0.29.0): Exposed via user settings UI when Starlark extensions have per-user config.
|
|
||||||
func (s *ExtensionStore) DeleteUserSettings(ctx context.Context, extID, userID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
DELETE FROM extension_user_settings WHERE extension_id = $1 AND user_id = $2`,
|
|
||||||
extID, userID,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal helpers ────────────────────────────
|
|
||||||
|
|
||||||
func (s *ExtensionStore) scanOne(ctx context.Context, query string, args ...interface{}) (*models.Extension, error) {
|
|
||||||
var ext models.Extension
|
|
||||||
var teamID, installedBy sql.NullString
|
|
||||||
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
|
||||||
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
|
|
||||||
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
|
|
||||||
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
|
|
||||||
&ext.CreatedAt, &ext.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ext.TeamID = NullableStringPtr(teamID)
|
|
||||||
ext.InstalledBy = NullableStringPtr(installedBy)
|
|
||||||
return &ext, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ExtensionStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]models.Extension, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.Extension
|
|
||||||
for rows.Next() {
|
|
||||||
var ext models.Extension
|
|
||||||
var teamID, installedBy sql.NullString
|
|
||||||
if err := rows.Scan(
|
|
||||||
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
|
|
||||||
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
|
|
||||||
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
|
|
||||||
&ext.CreatedAt, &ext.UpdatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ext.TeamID = NullableStringPtr(teamID)
|
|
||||||
ext.InstalledBy = NullableStringPtr(installedBy)
|
|
||||||
result = append(result, ext)
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
result = make([]models.Extension, 0)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
281
server/store/postgres/packages.go
Normal file
281
server/store/postgres/packages.go
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PackageStore struct{}
|
||||||
|
|
||||||
|
func NewPackageStore() *PackageStore { return &PackageStore{} }
|
||||||
|
|
||||||
|
// ── Lifecycle ────────────────────────────────────
|
||||||
|
|
||||||
|
// Seed upserts a package at startup. Does NOT overwrite the enabled flag.
|
||||||
|
func (s *PackageStore) Seed(ctx context.Context, id, title, source string, manifest map[string]any) error {
|
||||||
|
manifestJSON := ToJSON(manifest)
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO packages (id, title, source, manifest, enabled, installed_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, true, NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
title = $2,
|
||||||
|
manifest = $4,
|
||||||
|
source = $3,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
id, title, source, manifestJSON)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) List(ctx context.Context) ([]store.PackageRegistration, error) {
|
||||||
|
return s.scanMany(ctx, `SELECT `+pkgCols+` FROM packages p ORDER BY p.source, p.title`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) Get(ctx context.Context, id string) (*store.PackageRegistration, error) {
|
||||||
|
return s.scanOne(ctx, `SELECT `+pkgCols+` FROM packages p WHERE p.id = $1`, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) SetEnabled(ctx context.Context, id string, enabled bool) error {
|
||||||
|
result, err := DB.ExecContext(ctx,
|
||||||
|
`UPDATE packages SET enabled = $2, updated_at = NOW() WHERE id = $1`,
|
||||||
|
id, enabled)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) Delete(ctx context.Context, id string) error {
|
||||||
|
result, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM packages WHERE id = $1 AND source != 'core'`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) ListEnabled(ctx context.Context) ([]string, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx,
|
||||||
|
`SELECT id FROM packages WHERE enabled = true ORDER BY title`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Extended lifecycle ───────────────────────────
|
||||||
|
|
||||||
|
func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistration) error {
|
||||||
|
manifestJSON := ToJSON(pkg.Manifest)
|
||||||
|
return DB.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO packages (id, title, type, version, description, author, tier,
|
||||||
|
is_system, scope, team_id, installed_by, manifest, enabled, source)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||||
|
RETURNING installed_at, updated_at`,
|
||||||
|
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
||||||
|
pkg.Tier, pkg.IsSystem, pkg.Scope,
|
||||||
|
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
|
||||||
|
manifestJSON, pkg.Enabled, pkg.Source,
|
||||||
|
).Scan(&pkg.InstalledAt, &pkg.UpdatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) Update(ctx context.Context, id string, pkg *store.PackageRegistration) error {
|
||||||
|
manifestJSON := ToJSON(pkg.Manifest)
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
UPDATE packages SET
|
||||||
|
title = $2, type = $3, version = $4, description = $5, author = $6,
|
||||||
|
tier = $7, manifest = $8, is_system = $9, enabled = $10,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
id, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
||||||
|
pkg.Tier, manifestJSON, pkg.IsSystem, pkg.Enabled,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) ListByType(ctx context.Context, pkgType string) ([]store.PackageRegistration, error) {
|
||||||
|
return s.scanMany(ctx, `SELECT `+pkgCols+` FROM packages p WHERE p.type = $1 ORDER BY p.title`, pkgType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) ListEnabledByType(ctx context.Context, pkgType string) ([]store.PackageRegistration, error) {
|
||||||
|
return s.scanMany(ctx,
|
||||||
|
`SELECT `+pkgCols+` FROM packages p WHERE p.type = $1 AND p.enabled = true ORDER BY p.title`,
|
||||||
|
pkgType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Extension runtime ────────────────────────────
|
||||||
|
|
||||||
|
// ListForUser returns enabled extension/full packages with user settings overlay.
|
||||||
|
func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.UserPackage, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, `
|
||||||
|
SELECT `+pkgCols+`,
|
||||||
|
pus.is_enabled AS user_enabled,
|
||||||
|
pus.settings AS user_settings
|
||||||
|
FROM packages p
|
||||||
|
LEFT JOIN package_user_settings pus
|
||||||
|
ON pus.package_id = p.id AND pus.user_id = $1
|
||||||
|
WHERE p.enabled = true
|
||||||
|
AND p.type IN ('extension', 'full')
|
||||||
|
AND (p.is_system = true OR COALESCE(pus.is_enabled, true) = true)
|
||||||
|
ORDER BY p.title`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []store.UserPackage
|
||||||
|
for rows.Next() {
|
||||||
|
var up store.UserPackage
|
||||||
|
var teamID, installedBy sql.NullString
|
||||||
|
var manifestJSON []byte
|
||||||
|
var userEnabled sql.NullBool
|
||||||
|
var userSettings []byte
|
||||||
|
|
||||||
|
if err := rows.Scan(
|
||||||
|
&up.ID, &up.Title, &up.Type, &up.Version, &up.Description,
|
||||||
|
&up.Author, &up.Tier, &up.IsSystem, &up.Scope,
|
||||||
|
&teamID, &installedBy,
|
||||||
|
&manifestJSON, &up.Enabled, &up.Source,
|
||||||
|
&up.InstalledAt, &up.UpdatedAt,
|
||||||
|
&userEnabled, &userSettings,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
up.TeamID = NullableStringPtr(teamID)
|
||||||
|
up.InstalledBy = NullableStringPtr(installedBy)
|
||||||
|
json.Unmarshal(manifestJSON, &up.Manifest)
|
||||||
|
if userEnabled.Valid {
|
||||||
|
up.UserEnabled = &userEnabled.Bool
|
||||||
|
}
|
||||||
|
if userSettings != nil {
|
||||||
|
raw := json.RawMessage(userSettings)
|
||||||
|
up.UserSettings = &raw
|
||||||
|
}
|
||||||
|
result = append(result, up)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
result = make([]store.UserPackage, 0)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) GetUserSettings(ctx context.Context, pkgID, userID string) (*store.PackageUserSettings, error) {
|
||||||
|
var pus store.PackageUserSettings
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT package_id, user_id, settings, is_enabled
|
||||||
|
FROM package_user_settings
|
||||||
|
WHERE package_id = $1 AND user_id = $2`,
|
||||||
|
pkgID, userID,
|
||||||
|
).Scan(&pus.PackageID, &pus.UserID, &pus.Settings, &pus.IsEnabled)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &pus, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) SetUserSettings(ctx context.Context, pus *store.PackageUserSettings) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO package_user_settings (package_id, user_id, settings, is_enabled)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (package_id, user_id)
|
||||||
|
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
|
||||||
|
pus.PackageID, pus.UserID, pus.Settings, pus.IsEnabled,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) DeleteUserSettings(ctx context.Context, pkgID, userID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
DELETE FROM package_user_settings WHERE package_id = $1 AND user_id = $2`,
|
||||||
|
pkgID, userID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────
|
||||||
|
|
||||||
|
// pkgCols is the explicit column list for packages scans. Avoids SELECT *
|
||||||
|
// so column additions don't silently break positional Scan().
|
||||||
|
const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author,
|
||||||
|
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
|
||||||
|
p.manifest, p.enabled, p.source, p.installed_at, p.updated_at`
|
||||||
|
|
||||||
|
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
|
||||||
|
var pkg store.PackageRegistration
|
||||||
|
var teamID, installedBy sql.NullString
|
||||||
|
var manifestJSON []byte
|
||||||
|
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
||||||
|
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||||
|
&pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope,
|
||||||
|
&teamID, &installedBy,
|
||||||
|
&manifestJSON, &pkg.Enabled, &pkg.Source,
|
||||||
|
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pkg.TeamID = NullableStringPtr(teamID)
|
||||||
|
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||||
|
json.Unmarshal(manifestJSON, &pkg.Manifest)
|
||||||
|
return &pkg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]store.PackageRegistration, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []store.PackageRegistration
|
||||||
|
for rows.Next() {
|
||||||
|
var pkg store.PackageRegistration
|
||||||
|
var teamID, installedBy sql.NullString
|
||||||
|
var manifestJSON []byte
|
||||||
|
if err := rows.Scan(
|
||||||
|
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||||
|
&pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope,
|
||||||
|
&teamID, &installedBy,
|
||||||
|
&manifestJSON, &pkg.Enabled, &pkg.Source,
|
||||||
|
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pkg.TeamID = NullableStringPtr(teamID)
|
||||||
|
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||||
|
json.Unmarshal(manifestJSON, &pkg.Manifest)
|
||||||
|
result = append(result, pkg)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullStrPtr converts *string to sql.NullString for nullable FK columns.
|
||||||
|
func nullStrPtr(s *string) sql.NullString {
|
||||||
|
if s == nil {
|
||||||
|
return sql.NullString{}
|
||||||
|
}
|
||||||
|
return sql.NullString{String: *s, Valid: true}
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
GlobalConfig: NewGlobalConfigStore(),
|
GlobalConfig: NewGlobalConfigStore(),
|
||||||
Usage: NewUsageStore(),
|
Usage: NewUsageStore(),
|
||||||
Pricing: NewPricingStore(),
|
Pricing: NewPricingStore(),
|
||||||
Extensions: NewExtensionStore(),
|
|
||||||
Files: NewFileStore(),
|
Files: NewFileStore(),
|
||||||
KnowledgeBases: NewKnowledgeBaseStore(),
|
KnowledgeBases: NewKnowledgeBaseStore(),
|
||||||
Groups: NewGroupStore(),
|
Groups: NewGroupStore(),
|
||||||
@@ -40,7 +39,7 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
CapOverrides: NewCapOverrideStore(db),
|
CapOverrides: NewCapOverrideStore(db),
|
||||||
RoutingPolicies: NewRoutingPolicyStore(db),
|
RoutingPolicies: NewRoutingPolicyStore(db),
|
||||||
Sessions: NewSessionStore(),
|
Sessions: NewSessionStore(),
|
||||||
Surfaces: NewSurfaceRegistryStore(),
|
Packages: NewPackageStore(),
|
||||||
Workflows: NewWorkflowStore(),
|
Workflows: NewWorkflowStore(),
|
||||||
Tasks: NewTaskStore(),
|
Tasks: NewTaskStore(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SurfaceRegistryStore struct{}
|
|
||||||
|
|
||||||
func NewSurfaceRegistryStore() *SurfaceRegistryStore { return &SurfaceRegistryStore{} }
|
|
||||||
|
|
||||||
// Seed upserts a core surface. Called at startup by the page engine
|
|
||||||
// to ensure core surfaces exist in the registry. Does NOT overwrite
|
|
||||||
// the enabled state if the row already exists (admin toggle survives restart).
|
|
||||||
func (s *SurfaceRegistryStore) Seed(ctx context.Context, id, title, source string, manifest map[string]any) error {
|
|
||||||
manifestJSON := ToJSON(manifest)
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO surface_registry (id, title, source, manifest, enabled, installed_at, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, true, NOW(), NOW())
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
|
||||||
title = $2,
|
|
||||||
manifest = $4,
|
|
||||||
source = $3,
|
|
||||||
updated_at = NOW()`,
|
|
||||||
// Note: ON CONFLICT does NOT update 'enabled' — preserves admin toggle
|
|
||||||
id, title, source, manifestJSON)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SurfaceRegistryStore) List(ctx context.Context) ([]store.SurfaceRegistration, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
|
|
||||||
FROM surface_registry ORDER BY source, title`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var surfaces []store.SurfaceRegistration
|
|
||||||
for rows.Next() {
|
|
||||||
var sr store.SurfaceRegistration
|
|
||||||
var manifestJSON []byte
|
|
||||||
if err := rows.Scan(&sr.ID, &sr.Title, &manifestJSON, &sr.Enabled, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
json.Unmarshal(manifestJSON, &sr.Manifest)
|
|
||||||
surfaces = append(surfaces, sr)
|
|
||||||
}
|
|
||||||
return surfaces, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SurfaceRegistryStore) Get(ctx context.Context, id string) (*store.SurfaceRegistration, error) {
|
|
||||||
var sr store.SurfaceRegistration
|
|
||||||
var manifestJSON []byte
|
|
||||||
err := DB.QueryRowContext(ctx,
|
|
||||||
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
|
|
||||||
FROM surface_registry WHERE id = $1`, id).
|
|
||||||
Scan(&sr.ID, &sr.Title, &manifestJSON, &sr.Enabled, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
json.Unmarshal(manifestJSON, &sr.Manifest)
|
|
||||||
return &sr, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SurfaceRegistryStore) SetEnabled(ctx context.Context, id string, enabled bool) error {
|
|
||||||
result, err := DB.ExecContext(ctx,
|
|
||||||
`UPDATE surface_registry SET enabled = $2, updated_at = NOW() WHERE id = $1`,
|
|
||||||
id, enabled)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
affected, _ := result.RowsAffected()
|
|
||||||
if affected == 0 {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SurfaceRegistryStore) Delete(ctx context.Context, id string) error {
|
|
||||||
// Only allow deleting extension surfaces, not core
|
|
||||||
result, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM surface_registry WHERE id = $1 AND source = 'extension'`, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
affected, _ := result.RowsAffected()
|
|
||||||
if affected == 0 {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SurfaceRegistryStore) ListEnabled(ctx context.Context) ([]string, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
`SELECT id FROM surface_registry WHERE enabled = true ORDER BY title`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var ids []string
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
return ids, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ExtensionStore struct{}
|
|
||||||
|
|
||||||
func NewExtensionStore() *ExtensionStore { return &ExtensionStore{} }
|
|
||||||
|
|
||||||
func (s *ExtensionStore) Create(ctx context.Context, ext *models.Extension) error {
|
|
||||||
ext.ID = store.NewID()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
ext.CreatedAt = now
|
|
||||||
ext.UpdatedAt = now
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO extensions (id, ext_id, name, version, tier, description, author,
|
|
||||||
manifest, is_system, is_enabled, scope, team_id, installed_by, created_at, updated_at)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
||||||
ext.ID, ext.ExtID, ext.Name, ext.Version, ext.Tier, ext.Description, ext.Author,
|
|
||||||
ext.Manifest, ext.IsSystem, ext.IsEnabled, ext.Scope,
|
|
||||||
models.NullString(ext.TeamID), models.NullString(ext.InstalledBy),
|
|
||||||
now.Format(timeFmt), now.Format(timeFmt),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ExtensionStore) GetByID(ctx context.Context, id string) (*models.Extension, error) {
|
|
||||||
return s.scanOne(ctx, `SELECT * FROM extensions WHERE id = ?`, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ExtensionStore) GetByExtID(ctx context.Context, extID string) (*models.Extension, error) {
|
|
||||||
return s.scanOne(ctx, `SELECT * FROM extensions WHERE ext_id = ?`, extID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ExtensionStore) Update(ctx context.Context, id string, ext *models.Extension) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
UPDATE extensions SET
|
|
||||||
name = ?, version = ?, description = ?, author = ?,
|
|
||||||
manifest = ?, is_system = ?, is_enabled = ?,
|
|
||||||
updated_at = datetime('now')
|
|
||||||
WHERE id = ?`,
|
|
||||||
ext.Name, ext.Version, ext.Description, ext.Author,
|
|
||||||
ext.Manifest, ext.IsSystem, ext.IsEnabled, id,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ExtensionStore) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `DELETE FROM extensions WHERE id = ?`, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ExtensionStore) ListAll(ctx context.Context) ([]models.Extension, error) {
|
|
||||||
return s.scanMany(ctx, `SELECT * FROM extensions ORDER BY name`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListEnabled returns all globally enabled extensions regardless of user.
|
|
||||||
// TODO(v0.29.0): Used by Starlark runtime to load server-side extensions at startup.
|
|
||||||
func (s *ExtensionStore) ListEnabled(ctx context.Context) ([]models.Extension, error) {
|
|
||||||
return s.scanMany(ctx, `SELECT * FROM extensions WHERE is_enabled = 1 ORDER BY name`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListForUser returns all enabled extensions with user-specific overrides merged in.
|
|
||||||
// System extensions are always included (users can't disable them).
|
|
||||||
// Non-system extensions respect per-user enabled toggle.
|
|
||||||
func (s *ExtensionStore) ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, `
|
|
||||||
SELECT e.id, e.ext_id, e.name, e.version, e.tier, e.description, e.author,
|
|
||||||
e.manifest, e.is_system, e.is_enabled, e.scope, e.team_id, e.installed_by,
|
|
||||||
e.created_at, e.updated_at,
|
|
||||||
eus.is_enabled AS user_enabled,
|
|
||||||
eus.settings AS user_settings
|
|
||||||
FROM extensions e
|
|
||||||
LEFT JOIN extension_user_settings eus
|
|
||||||
ON eus.extension_id = e.id AND eus.user_id = ?
|
|
||||||
WHERE e.is_enabled = 1
|
|
||||||
AND (e.is_system = 1 OR COALESCE(eus.is_enabled, 1) = 1)
|
|
||||||
ORDER BY e.name`, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.UserExtension
|
|
||||||
for rows.Next() {
|
|
||||||
var ue models.UserExtension
|
|
||||||
var teamID, installedBy sql.NullString
|
|
||||||
var userEnabled sql.NullBool
|
|
||||||
var userSettings []byte
|
|
||||||
|
|
||||||
if err := rows.Scan(
|
|
||||||
&ue.ID, &ue.ExtID, &ue.Name, &ue.Version, &ue.Tier,
|
|
||||||
&ue.Description, &ue.Author, &ue.Manifest, &ue.IsSystem,
|
|
||||||
&ue.IsEnabled, &ue.Scope, &teamID, &installedBy,
|
|
||||||
st(&ue.CreatedAt), st(&ue.UpdatedAt),
|
|
||||||
&userEnabled, &userSettings,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ue.TeamID = NullableStringPtr(teamID)
|
|
||||||
ue.InstalledBy = NullableStringPtr(installedBy)
|
|
||||||
if userEnabled.Valid {
|
|
||||||
ue.UserEnabled = &userEnabled.Bool
|
|
||||||
}
|
|
||||||
if userSettings != nil {
|
|
||||||
raw := json.RawMessage(userSettings)
|
|
||||||
ue.UserSettings = &raw
|
|
||||||
}
|
|
||||||
result = append(result, ue)
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
result = make([]models.UserExtension, 0)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserSettings returns per-user settings for a specific extension.
|
|
||||||
// TODO(v0.29.0): Used by Starlark runtime to load per-user config for server-side extensions.
|
|
||||||
func (s *ExtensionStore) GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error) {
|
|
||||||
var eus models.ExtensionUserSettings
|
|
||||||
err := DB.QueryRowContext(ctx, `
|
|
||||||
SELECT extension_id, user_id, settings, is_enabled
|
|
||||||
FROM extension_user_settings
|
|
||||||
WHERE extension_id = ? AND user_id = ?`,
|
|
||||||
extID, userID,
|
|
||||||
).Scan(&eus.ExtensionID, &eus.UserID, &eus.Settings, &eus.IsEnabled)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &eus, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ExtensionStore) SetUserSettings(ctx context.Context, eus *models.ExtensionUserSettings) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO extension_user_settings (extension_id, user_id, settings, is_enabled)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
ON CONFLICT (extension_id, user_id)
|
|
||||||
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
|
|
||||||
eus.ExtensionID, eus.UserID, eus.Settings, eus.IsEnabled,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteUserSettings removes per-user settings, reverting to defaults.
|
|
||||||
// TODO(v0.29.0): Exposed via user settings UI when Starlark extensions have per-user config.
|
|
||||||
func (s *ExtensionStore) DeleteUserSettings(ctx context.Context, extID, userID string) error {
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
DELETE FROM extension_user_settings WHERE extension_id = ? AND user_id = ?`,
|
|
||||||
extID, userID,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal helpers ────────────────────────────
|
|
||||||
|
|
||||||
func (s *ExtensionStore) scanOne(ctx context.Context, query string, args ...interface{}) (*models.Extension, error) {
|
|
||||||
var ext models.Extension
|
|
||||||
var teamID, installedBy sql.NullString
|
|
||||||
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
|
||||||
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
|
|
||||||
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
|
|
||||||
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
|
|
||||||
st(&ext.CreatedAt), st(&ext.UpdatedAt),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ext.TeamID = NullableStringPtr(teamID)
|
|
||||||
ext.InstalledBy = NullableStringPtr(installedBy)
|
|
||||||
return &ext, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ExtensionStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]models.Extension, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []models.Extension
|
|
||||||
for rows.Next() {
|
|
||||||
var ext models.Extension
|
|
||||||
var teamID, installedBy sql.NullString
|
|
||||||
if err := rows.Scan(
|
|
||||||
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
|
|
||||||
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
|
|
||||||
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
|
|
||||||
st(&ext.CreatedAt), st(&ext.UpdatedAt),
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ext.TeamID = NullableStringPtr(teamID)
|
|
||||||
ext.InstalledBy = NullableStringPtr(installedBy)
|
|
||||||
result = append(result, ext)
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
result = make([]models.Extension, 0)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
301
server/store/sqlite/packages.go
Normal file
301
server/store/sqlite/packages.go
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PackageStore struct{}
|
||||||
|
|
||||||
|
func NewPackageStore() *PackageStore { return &PackageStore{} }
|
||||||
|
|
||||||
|
// ── Lifecycle ────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *PackageStore) Seed(ctx context.Context, id, title, source string, manifest map[string]any) error {
|
||||||
|
manifestJSON := ToJSON(manifest)
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO packages (id, title, source, manifest, enabled, installed_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, 1, datetime('now'), datetime('now'))
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
title = excluded.title,
|
||||||
|
manifest = excluded.manifest,
|
||||||
|
source = excluded.source,
|
||||||
|
updated_at = datetime('now')`,
|
||||||
|
id, title, source, manifestJSON)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) List(ctx context.Context) ([]store.PackageRegistration, error) {
|
||||||
|
return s.scanMany(ctx, `SELECT `+pkgCols+` FROM packages p ORDER BY p.source, p.title`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) Get(ctx context.Context, id string) (*store.PackageRegistration, error) {
|
||||||
|
return s.scanOne(ctx, `SELECT `+pkgCols+` FROM packages p WHERE p.id = ?`, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) SetEnabled(ctx context.Context, id string, enabled bool) error {
|
||||||
|
enabledInt := 0
|
||||||
|
if enabled {
|
||||||
|
enabledInt = 1
|
||||||
|
}
|
||||||
|
result, err := DB.ExecContext(ctx,
|
||||||
|
`UPDATE packages SET enabled = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||||
|
enabledInt, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) Delete(ctx context.Context, id string) error {
|
||||||
|
result, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM packages WHERE id = ? AND source != 'core'`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) ListEnabled(ctx context.Context) ([]string, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx,
|
||||||
|
`SELECT id FROM packages WHERE enabled = 1 ORDER BY title`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Extended lifecycle ───────────────────────────
|
||||||
|
|
||||||
|
func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistration) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
manifestJSON := ToJSON(pkg.Manifest)
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO packages (id, title, type, version, description, author, tier,
|
||||||
|
is_system, scope, team_id, installed_by, manifest, enabled, source,
|
||||||
|
installed_at, updated_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
||||||
|
pkg.Tier, pkg.IsSystem, pkg.Scope,
|
||||||
|
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
|
||||||
|
manifestJSON, pkg.Enabled, pkg.Source,
|
||||||
|
now.Format(timeFmt), now.Format(timeFmt),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pkg.InstalledAt = now.Format(timeFmt)
|
||||||
|
pkg.UpdatedAt = now.Format(timeFmt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) Update(ctx context.Context, id string, pkg *store.PackageRegistration) error {
|
||||||
|
manifestJSON := ToJSON(pkg.Manifest)
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
UPDATE packages SET
|
||||||
|
title = ?, type = ?, version = ?, description = ?, author = ?,
|
||||||
|
tier = ?, manifest = ?, is_system = ?, enabled = ?,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE id = ?`,
|
||||||
|
pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
||||||
|
pkg.Tier, manifestJSON, pkg.IsSystem, pkg.Enabled, id,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) ListByType(ctx context.Context, pkgType string) ([]store.PackageRegistration, error) {
|
||||||
|
return s.scanMany(ctx, `SELECT `+pkgCols+` FROM packages p WHERE p.type = ? ORDER BY p.title`, pkgType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) ListEnabledByType(ctx context.Context, pkgType string) ([]store.PackageRegistration, error) {
|
||||||
|
return s.scanMany(ctx,
|
||||||
|
`SELECT `+pkgCols+` FROM packages p WHERE p.type = ? AND p.enabled = 1 ORDER BY p.title`,
|
||||||
|
pkgType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Extension runtime ────────────────────────────
|
||||||
|
|
||||||
|
func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.UserPackage, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, `
|
||||||
|
SELECT `+pkgCols+`,
|
||||||
|
pus.is_enabled AS user_enabled,
|
||||||
|
pus.settings AS user_settings
|
||||||
|
FROM packages p
|
||||||
|
LEFT JOIN package_user_settings pus
|
||||||
|
ON pus.package_id = p.id AND pus.user_id = ?
|
||||||
|
WHERE p.enabled = 1
|
||||||
|
AND p.type IN ('extension', 'full')
|
||||||
|
AND (p.is_system = 1 OR COALESCE(pus.is_enabled, 1) = 1)
|
||||||
|
ORDER BY p.title`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []store.UserPackage
|
||||||
|
for rows.Next() {
|
||||||
|
var up store.UserPackage
|
||||||
|
var teamID, installedBy sql.NullString
|
||||||
|
var manifestJSON string
|
||||||
|
var enabledInt int
|
||||||
|
var isSystemInt int
|
||||||
|
var userEnabled sql.NullBool
|
||||||
|
var userSettings []byte
|
||||||
|
|
||||||
|
if err := rows.Scan(
|
||||||
|
&up.ID, &up.Title, &up.Type, &up.Version, &up.Description,
|
||||||
|
&up.Author, &up.Tier, &isSystemInt, &up.Scope,
|
||||||
|
&teamID, &installedBy,
|
||||||
|
&manifestJSON, &enabledInt, &up.Source,
|
||||||
|
&up.InstalledAt, &up.UpdatedAt,
|
||||||
|
&userEnabled, &userSettings,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
up.IsSystem = isSystemInt != 0
|
||||||
|
up.Enabled = enabledInt != 0
|
||||||
|
up.TeamID = NullableStringPtr(teamID)
|
||||||
|
up.InstalledBy = NullableStringPtr(installedBy)
|
||||||
|
json.Unmarshal([]byte(manifestJSON), &up.Manifest)
|
||||||
|
if userEnabled.Valid {
|
||||||
|
up.UserEnabled = &userEnabled.Bool
|
||||||
|
}
|
||||||
|
if userSettings != nil {
|
||||||
|
raw := json.RawMessage(userSettings)
|
||||||
|
up.UserSettings = &raw
|
||||||
|
}
|
||||||
|
result = append(result, up)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
result = make([]store.UserPackage, 0)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) GetUserSettings(ctx context.Context, pkgID, userID string) (*store.PackageUserSettings, error) {
|
||||||
|
var pus store.PackageUserSettings
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT package_id, user_id, settings, is_enabled
|
||||||
|
FROM package_user_settings
|
||||||
|
WHERE package_id = ? AND user_id = ?`,
|
||||||
|
pkgID, userID,
|
||||||
|
).Scan(&pus.PackageID, &pus.UserID, &pus.Settings, &pus.IsEnabled)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &pus, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) SetUserSettings(ctx context.Context, pus *store.PackageUserSettings) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO package_user_settings (package_id, user_id, settings, is_enabled)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT (package_id, user_id)
|
||||||
|
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
|
||||||
|
pus.PackageID, pus.UserID, pus.Settings, pus.IsEnabled,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) DeleteUserSettings(ctx context.Context, pkgID, userID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
DELETE FROM package_user_settings WHERE package_id = ? AND user_id = ?`,
|
||||||
|
pkgID, userID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────
|
||||||
|
|
||||||
|
const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author,
|
||||||
|
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
|
||||||
|
p.manifest, p.enabled, p.source, p.installed_at, p.updated_at`
|
||||||
|
|
||||||
|
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
|
||||||
|
var pkg store.PackageRegistration
|
||||||
|
var teamID, installedBy sql.NullString
|
||||||
|
var manifestJSON string
|
||||||
|
var enabledInt int
|
||||||
|
var isSystemInt int
|
||||||
|
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
||||||
|
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||||
|
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
|
||||||
|
&teamID, &installedBy,
|
||||||
|
&manifestJSON, &enabledInt, &pkg.Source,
|
||||||
|
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pkg.IsSystem = isSystemInt != 0
|
||||||
|
pkg.Enabled = enabledInt != 0
|
||||||
|
pkg.TeamID = NullableStringPtr(teamID)
|
||||||
|
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||||
|
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
|
||||||
|
return &pkg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]store.PackageRegistration, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []store.PackageRegistration
|
||||||
|
for rows.Next() {
|
||||||
|
var pkg store.PackageRegistration
|
||||||
|
var teamID, installedBy sql.NullString
|
||||||
|
var manifestJSON string
|
||||||
|
var enabledInt int
|
||||||
|
var isSystemInt int
|
||||||
|
if err := rows.Scan(
|
||||||
|
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||||
|
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
|
||||||
|
&teamID, &installedBy,
|
||||||
|
&manifestJSON, &enabledInt, &pkg.Source,
|
||||||
|
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pkg.IsSystem = isSystemInt != 0
|
||||||
|
pkg.Enabled = enabledInt != 0
|
||||||
|
pkg.TeamID = NullableStringPtr(teamID)
|
||||||
|
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||||
|
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
|
||||||
|
result = append(result, pkg)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullStrPtr(s *string) sql.NullString {
|
||||||
|
if s == nil {
|
||||||
|
return sql.NullString{}
|
||||||
|
}
|
||||||
|
return sql.NullString{String: *s, Valid: true}
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
GlobalConfig: NewGlobalConfigStore(),
|
GlobalConfig: NewGlobalConfigStore(),
|
||||||
Usage: NewUsageStore(),
|
Usage: NewUsageStore(),
|
||||||
Pricing: NewPricingStore(),
|
Pricing: NewPricingStore(),
|
||||||
Extensions: NewExtensionStore(),
|
|
||||||
Files: NewFileStore(),
|
Files: NewFileStore(),
|
||||||
KnowledgeBases: NewKnowledgeBaseStore(),
|
KnowledgeBases: NewKnowledgeBaseStore(),
|
||||||
Groups: NewGroupStore(),
|
Groups: NewGroupStore(),
|
||||||
@@ -40,7 +39,7 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
CapOverrides: NewCapOverrideStore(),
|
CapOverrides: NewCapOverrideStore(),
|
||||||
RoutingPolicies: NewRoutingPolicyStore(),
|
RoutingPolicies: NewRoutingPolicyStore(),
|
||||||
Sessions: NewSessionStore(),
|
Sessions: NewSessionStore(),
|
||||||
Surfaces: NewSurfaceRegistryStore(),
|
Packages: NewPackageStore(),
|
||||||
Workflows: NewWorkflowStore(),
|
Workflows: NewWorkflowStore(),
|
||||||
Tasks: NewTaskStore(),
|
Tasks: NewTaskStore(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SurfaceRegistryStore struct{}
|
|
||||||
|
|
||||||
func NewSurfaceRegistryStore() *SurfaceRegistryStore { return &SurfaceRegistryStore{} }
|
|
||||||
|
|
||||||
// Seed upserts a core surface. Called at startup by the page engine
|
|
||||||
// to ensure core surfaces exist in the registry. Does NOT overwrite
|
|
||||||
// the enabled state if the row already exists (admin toggle survives restart).
|
|
||||||
func (s *SurfaceRegistryStore) Seed(ctx context.Context, id, title, source string, manifest map[string]any) error {
|
|
||||||
manifestJSON := ToJSON(manifest)
|
|
||||||
_, err := DB.ExecContext(ctx, `
|
|
||||||
INSERT INTO surface_registry (id, title, source, manifest, enabled, installed_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, 1, datetime('now'), datetime('now'))
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
|
||||||
title = excluded.title,
|
|
||||||
manifest = excluded.manifest,
|
|
||||||
source = excluded.source,
|
|
||||||
updated_at = datetime('now')`,
|
|
||||||
// Note: ON CONFLICT does NOT update 'enabled' — preserves admin toggle
|
|
||||||
id, title, source, manifestJSON)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SurfaceRegistryStore) List(ctx context.Context) ([]store.SurfaceRegistration, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
|
|
||||||
FROM surface_registry ORDER BY source, title`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var surfaces []store.SurfaceRegistration
|
|
||||||
for rows.Next() {
|
|
||||||
var sr store.SurfaceRegistration
|
|
||||||
var manifestJSON string
|
|
||||||
var enabledInt int
|
|
||||||
if err := rows.Scan(&sr.ID, &sr.Title, &manifestJSON, &enabledInt, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sr.Enabled = enabledInt != 0
|
|
||||||
json.Unmarshal([]byte(manifestJSON), &sr.Manifest)
|
|
||||||
surfaces = append(surfaces, sr)
|
|
||||||
}
|
|
||||||
return surfaces, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SurfaceRegistryStore) Get(ctx context.Context, id string) (*store.SurfaceRegistration, error) {
|
|
||||||
var sr store.SurfaceRegistration
|
|
||||||
var manifestJSON string
|
|
||||||
var enabledInt int
|
|
||||||
err := DB.QueryRowContext(ctx,
|
|
||||||
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
|
|
||||||
FROM surface_registry WHERE id = ?`, id).
|
|
||||||
Scan(&sr.ID, &sr.Title, &manifestJSON, &enabledInt, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sr.Enabled = enabledInt != 0
|
|
||||||
json.Unmarshal([]byte(manifestJSON), &sr.Manifest)
|
|
||||||
return &sr, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SurfaceRegistryStore) SetEnabled(ctx context.Context, id string, enabled bool) error {
|
|
||||||
enabledInt := 0
|
|
||||||
if enabled {
|
|
||||||
enabledInt = 1
|
|
||||||
}
|
|
||||||
result, err := DB.ExecContext(ctx,
|
|
||||||
`UPDATE surface_registry SET enabled = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
||||||
enabledInt, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
affected, _ := result.RowsAffected()
|
|
||||||
if affected == 0 {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SurfaceRegistryStore) Delete(ctx context.Context, id string) error {
|
|
||||||
// Only allow deleting extension surfaces, not core
|
|
||||||
result, err := DB.ExecContext(ctx,
|
|
||||||
`DELETE FROM surface_registry WHERE id = ? AND source = 'extension'`, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
affected, _ := result.RowsAffected()
|
|
||||||
if affected == 0 {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SurfaceRegistryStore) ListEnabled(ctx context.Context) ([]string, error) {
|
|
||||||
rows, err := DB.QueryContext(ctx,
|
|
||||||
`SELECT id FROM surface_registry WHERE enabled = 1 ORDER BY title`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var ids []string
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
return ids, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
// SurfaceRegistryStore — CRUD for the surface_registry table.
|
|
||||||
// Core surfaces are seeded at startup. Extension surfaces are managed via admin API.
|
|
||||||
type SurfaceRegistryStore interface {
|
|
||||||
// Seed upserts a core surface (called at startup by page engine).
|
|
||||||
Seed(ctx context.Context, id, title, source string, manifest map[string]any) error
|
|
||||||
|
|
||||||
// List returns all registered surfaces, ordered by title.
|
|
||||||
List(ctx context.Context) ([]SurfaceRegistration, error)
|
|
||||||
|
|
||||||
// Get returns a single surface by ID.
|
|
||||||
Get(ctx context.Context, id string) (*SurfaceRegistration, error)
|
|
||||||
|
|
||||||
// SetEnabled toggles a surface's enabled state.
|
|
||||||
SetEnabled(ctx context.Context, id string, enabled bool) error
|
|
||||||
|
|
||||||
// Delete removes an extension surface. Core surfaces cannot be deleted.
|
|
||||||
Delete(ctx context.Context, id string) error
|
|
||||||
|
|
||||||
// ListEnabled returns IDs of all enabled surfaces.
|
|
||||||
ListEnabled(ctx context.Context) ([]string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SurfaceRegistration is a row from surface_registry.
|
|
||||||
type SurfaceRegistration struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
Title string `json:"title" db:"title"`
|
|
||||||
Manifest map[string]any `json:"manifest" db:"manifest"`
|
|
||||||
Enabled bool `json:"enabled" db:"enabled"`
|
|
||||||
Source string `json:"source" db:"source"`
|
|
||||||
InstalledAt string `json:"installed_at" db:"installed_at"`
|
|
||||||
UpdatedAt string `json:"updated_at" db:"updated_at"`
|
|
||||||
}
|
|
||||||
191
src/js/admin-packages.js
Normal file
191
src/js/admin-packages.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Chat Switchboard — Admin Packages UI
|
||||||
|
// ==========================================
|
||||||
|
// Renders in the "Packages" admin section via ADMIN_LOADERS.
|
||||||
|
// Replaces admin-surfaces.js (v0.28.7).
|
||||||
|
//
|
||||||
|
// Exports: window._loadAdminPackages
|
||||||
|
|
||||||
|
async function _loadAdminPackages() {
|
||||||
|
const container = document.getElementById('adminPackagesContent');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML =
|
||||||
|
'<div style="margin-bottom:16px">' +
|
||||||
|
'<p class="text-muted" style="font-size:12px;margin-bottom:12px">' +
|
||||||
|
'Surfaces, extensions, and full packages. Install .pkg or .surface archives.' +
|
||||||
|
'</p>' +
|
||||||
|
'<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px">' +
|
||||||
|
'<button class="btn-small" id="pkgFilterAll">All</button>' +
|
||||||
|
'<button class="btn-small" id="pkgFilterSurface">Surfaces</button>' +
|
||||||
|
'<button class="btn-small" id="pkgFilterExtension">Extensions</button>' +
|
||||||
|
'<button class="btn-small" id="pkgFilterFull">Full</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div id="adminPkgInstallForm" style="display:none;margin-bottom:16px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:8px">' +
|
||||||
|
'<h4 style="margin:0 0 8px 0;font-size:13px">Install Package</h4>' +
|
||||||
|
'<input type="file" id="pkgInstallFile" accept=".pkg,.surface,.zip" style="font-size:12px">' +
|
||||||
|
'<div style="display:flex;gap:8px;margin-top:8px">' +
|
||||||
|
'<button class="btn-small btn-primary" id="pkgInstallSubmit">Upload & Install</button>' +
|
||||||
|
'<button class="btn-small" id="pkgInstallCancel">Cancel</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div id="adminPkgList" class="admin-list"><div class="loading">Loading...</div></div>';
|
||||||
|
|
||||||
|
var base = document.body.dataset.basePath || '';
|
||||||
|
var currentFilter = '';
|
||||||
|
|
||||||
|
// Filter buttons
|
||||||
|
['All', 'Surface', 'Extension', 'Full'].forEach(function(label) {
|
||||||
|
var btn = document.getElementById('pkgFilter' + label);
|
||||||
|
if (btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
currentFilter = label === 'All' ? '' : label.toLowerCase();
|
||||||
|
loadList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Install form
|
||||||
|
var installSubmit = document.getElementById('pkgInstallSubmit');
|
||||||
|
var installCancel = document.getElementById('pkgInstallCancel');
|
||||||
|
if (installSubmit) {
|
||||||
|
installSubmit.addEventListener('click', async function() {
|
||||||
|
var fileInput = document.getElementById('pkgInstallFile');
|
||||||
|
if (!fileInput || !fileInput.files[0]) {
|
||||||
|
UI.toast('Select a .pkg, .surface, or .zip file', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('file', fileInput.files[0]);
|
||||||
|
try {
|
||||||
|
var resp = await fetch(base + '/api/v1/admin/packages/install', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + API.accessToken },
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
var err = await resp.json();
|
||||||
|
UI.toast(err.error || 'Install failed', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var data = await resp.json();
|
||||||
|
UI.toast('Installed: ' + (data.title || data.id), 'success');
|
||||||
|
fileInput.value = '';
|
||||||
|
document.getElementById('adminPkgInstallForm').style.display = 'none';
|
||||||
|
loadList();
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Install failed: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (installCancel) {
|
||||||
|
installCancel.addEventListener('click', function() {
|
||||||
|
document.getElementById('adminPkgInstallForm').style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
var listEl = document.getElementById('adminPkgList');
|
||||||
|
if (!listEl) return;
|
||||||
|
listEl.innerHTML = '<div class="loading">Loading...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var url = '/api/v1/admin/packages';
|
||||||
|
if (currentFilter) url += '?type=' + currentFilter;
|
||||||
|
var resp = await API._get(url);
|
||||||
|
var packages = resp.data || [];
|
||||||
|
|
||||||
|
if (packages.length === 0) {
|
||||||
|
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--text-3);">No packages found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = '<table class="admin-table" style="width:100%"><thead><tr>' +
|
||||||
|
'<th style="width:30%">Package</th>' +
|
||||||
|
'<th>Type</th>' +
|
||||||
|
'<th>Version</th>' +
|
||||||
|
'<th>Source</th>' +
|
||||||
|
'<th>Status</th>' +
|
||||||
|
'<th style="width:100px">Actions</th>' +
|
||||||
|
'</tr></thead><tbody>';
|
||||||
|
|
||||||
|
packages.forEach(function(pkg) {
|
||||||
|
var typeBadge = {
|
||||||
|
surface: '<span class="badge badge-info">surface</span>',
|
||||||
|
extension: '<span class="badge badge-accent">extension</span>',
|
||||||
|
full: '<span class="badge badge-warning">full</span>',
|
||||||
|
}[pkg.type] || '<span class="badge">' + pkg.type + '</span>';
|
||||||
|
|
||||||
|
var sourceBadge = {
|
||||||
|
core: '<span class="text-muted">core</span>',
|
||||||
|
builtin: '<span class="text-muted">builtin</span>',
|
||||||
|
extension: '<span class="text-muted">installed</span>',
|
||||||
|
}[pkg.source] || pkg.source;
|
||||||
|
|
||||||
|
var statusBadge = pkg.enabled
|
||||||
|
? '<span class="badge badge-success">enabled</span>'
|
||||||
|
: '<span class="badge badge-muted">disabled</span>';
|
||||||
|
|
||||||
|
var actions = '';
|
||||||
|
// Toggle enable/disable (not for chat/admin)
|
||||||
|
if (pkg.id !== 'chat' && pkg.id !== 'admin') {
|
||||||
|
if (pkg.enabled) {
|
||||||
|
actions += '<button class="btn-small btn-ghost pkg-disable" data-id="' + pkg.id + '" title="Disable">Disable</button> ';
|
||||||
|
} else {
|
||||||
|
actions += '<button class="btn-small btn-ghost pkg-enable" data-id="' + pkg.id + '" title="Enable">Enable</button> ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Delete (non-core only)
|
||||||
|
if (pkg.source !== 'core') {
|
||||||
|
actions += '<button class="btn-small btn-danger-ghost pkg-delete" data-id="' + pkg.id + '" title="Delete">Delete</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var desc = pkg.description ? '<div class="text-muted" style="font-size:11px">' + pkg.description + '</div>' : '';
|
||||||
|
var system = pkg.is_system ? ' <span class="badge badge-muted" style="font-size:10px">system</span>' : '';
|
||||||
|
|
||||||
|
html += '<tr>' +
|
||||||
|
'<td><strong>' + pkg.title + '</strong>' + system + '<div class="text-muted" style="font-size:11px">' + pkg.id + '</div>' + desc + '</td>' +
|
||||||
|
'<td>' + typeBadge + '</td>' +
|
||||||
|
'<td style="font-size:12px;font-family:monospace">' + (pkg.version || '—') + '</td>' +
|
||||||
|
'<td>' + sourceBadge + '</td>' +
|
||||||
|
'<td>' + statusBadge + '</td>' +
|
||||||
|
'<td>' + actions + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
listEl.innerHTML = html;
|
||||||
|
|
||||||
|
// Wire action buttons
|
||||||
|
listEl.querySelectorAll('.pkg-enable').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', async function() {
|
||||||
|
await API._put('/api/v1/admin/packages/' + btn.dataset.id + '/enable');
|
||||||
|
UI.toast('Enabled', 'success');
|
||||||
|
loadList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
listEl.querySelectorAll('.pkg-disable').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', async function() {
|
||||||
|
await API._put('/api/v1/admin/packages/' + btn.dataset.id + '/disable');
|
||||||
|
UI.toast('Disabled', 'success');
|
||||||
|
loadList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
listEl.querySelectorAll('.pkg-delete').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', async function() {
|
||||||
|
if (!confirm('Delete package "' + btn.dataset.id + '"? This cannot be undone.')) return;
|
||||||
|
await API._del('/api/v1/admin/packages/' + btn.dataset.id);
|
||||||
|
UI.toast('Deleted', 'success');
|
||||||
|
loadList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
listEl.innerHTML = '<div style="padding:12px;color:var(--danger);">Failed to load packages: ' + e.message + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadList();
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.register('_loadAdminPackages', _loadAdminPackages);
|
||||||
@@ -190,30 +190,8 @@
|
|||||||
|
|
||||||
SCAFFOLDING.storage = '<div id="adminStorageContent"></div>';
|
SCAFFOLDING.storage = '<div id="adminStorageContent"></div>';
|
||||||
|
|
||||||
SCAFFOLDING.extensions =
|
SCAFFOLDING.packages =
|
||||||
'<div id="adminExtensionsList" class="admin-list"></div>' +
|
'<div id="adminPackagesContent"></div>';
|
||||||
'<div id="adminInstallExtForm" class="admin-inline-form" style="display:none">' +
|
|
||||||
'<h4>Install Extension</h4>' +
|
|
||||||
'<div class="form-row">' +
|
|
||||||
'<div class="form-group"><label>ID</label><input type="text" id="extInstallId" placeholder="my-extension"></div>' +
|
|
||||||
'<div class="form-group"><label>Name</label><input type="text" id="extInstallName" placeholder="My Extension"></div>' +
|
|
||||||
'<div class="form-group"><label>Version</label><input type="text" id="extInstallVersion" placeholder="1.0.0"></div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="form-row">' +
|
|
||||||
'<div class="form-group"><label>Author</label><input type="text" id="extInstallAuthor" placeholder="Author"></div>' +
|
|
||||||
'<div class="form-group"><label>System</label>' +
|
|
||||||
'<select id="extInstallSystem"><option value="browser">Browser JS</option><option value="starlark">Starlark</option><option value="sidecar">Sidecar</option></select>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="form-group"><label>Description</label><textarea id="extInstallDesc" rows="2" placeholder="What it does..."></textarea></div>' +
|
|
||||||
'<div class="form-group"><label>Manifest JSON</label><textarea id="extInstallManifest" rows="4"></textarea></div>' +
|
|
||||||
'<div class="form-group"><label>Script Source</label><textarea id="extInstallScript" rows="6"></textarea></div>' +
|
|
||||||
'<label class="toggle-label"><input type="checkbox" id="extInstallEnabled" checked><span class="toggle-track"></span><span>Enabled</span></label>' +
|
|
||||||
'<div class="form-row" style="margin-top:8px">' +
|
|
||||||
'<button class="btn-small btn-primary" id="adminInstallExtSubmit">Install</button>' +
|
|
||||||
'<button class="btn-small" id="adminInstallExtCancelBtn">Cancel</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
|
||||||
|
|
||||||
// -- Routing ----------------------------------------------------------
|
// -- Routing ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -268,9 +246,6 @@
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'<div id="adminArchivedList" class="admin-list"><div class="loading">Loading...</div></div>';
|
'<div id="adminArchivedList" class="admin-list"><div class="loading">Loading...</div></div>';
|
||||||
|
|
||||||
SCAFFOLDING.surfaces =
|
|
||||||
'<div id="adminSurfacesContent"></div>';
|
|
||||||
|
|
||||||
|
|
||||||
// === Add button actions ==============================================
|
// === Add button actions ==============================================
|
||||||
|
|
||||||
@@ -303,8 +278,8 @@
|
|||||||
var n = document.getElementById('adminGroupName');
|
var n = document.getElementById('adminGroupName');
|
||||||
if (n) n.focus();
|
if (n) n.focus();
|
||||||
},
|
},
|
||||||
extensions: function() {
|
packages: function() {
|
||||||
var f = document.getElementById('adminInstallExtForm');
|
var f = document.getElementById('adminPkgInstallForm');
|
||||||
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
|
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
// ==========================================
|
|
||||||
// Chat Switchboard — Admin Surfaces UI
|
|
||||||
// ==========================================
|
|
||||||
// v0.25.0: Surface lifecycle management.
|
|
||||||
// Renders in the "Surfaces" admin section via ADMIN_LOADERS.
|
|
||||||
//
|
|
||||||
// Exports: window._loadAdminSurfaces
|
|
||||||
|
|
||||||
|
|
||||||
async function _loadAdminSurfaces() {
|
|
||||||
const container = document.getElementById('adminSurfacesContent');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
|
||||||
<p style="font-size:13px;color:var(--text-2);margin:0;line-height:1.6;flex:1;">
|
|
||||||
Manage which surfaces are available. Disabled surfaces redirect to Chat and are hidden from navigation.
|
|
||||||
</p>
|
|
||||||
<button id="adminSurfaceInstallBtn" class="btn-small" style="margin-left:16px;white-space:nowrap;">
|
|
||||||
+ Install Surface
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="adminSurfaceInstallForm" style="display:none;margin-bottom:16px;padding:16px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;">
|
|
||||||
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">Install Surface Archive</div>
|
|
||||||
<p style="font-size:12px;color:var(--text-3);margin:0 0 12px;">Upload a <code>.surface</code> or <code>.zip</code> archive containing manifest.json, js/, css/, and assets/.</p>
|
|
||||||
<div style="display:flex;align-items:center;gap:8px;">
|
|
||||||
<input type="file" id="adminSurfaceFile" accept=".surface,.zip" style="font-size:12px;color:var(--text-2);">
|
|
||||||
<button id="adminSurfaceUploadBtn" class="btn-small btn-primary" disabled>Upload</button>
|
|
||||||
<button id="adminSurfaceCancelBtn" class="btn-small">Cancel</button>
|
|
||||||
</div>
|
|
||||||
<div id="adminSurfaceInstallStatus" style="font-size:12px;margin-top:8px;"></div>
|
|
||||||
</div>
|
|
||||||
<div id="adminSurfaceList" class="admin-surface-list">
|
|
||||||
<div style="padding:12px;font-size:12px;color:var(--text-3);">Loading…</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Wire install form
|
|
||||||
var installBtn = document.getElementById('adminSurfaceInstallBtn');
|
|
||||||
var installForm = document.getElementById('adminSurfaceInstallForm');
|
|
||||||
var fileInput = document.getElementById('adminSurfaceFile');
|
|
||||||
var uploadBtn = document.getElementById('adminSurfaceUploadBtn');
|
|
||||||
var cancelBtn = document.getElementById('adminSurfaceCancelBtn');
|
|
||||||
var statusEl = document.getElementById('adminSurfaceInstallStatus');
|
|
||||||
|
|
||||||
if (installBtn) installBtn.addEventListener('click', function() {
|
|
||||||
installForm.style.display = '';
|
|
||||||
installBtn.style.display = 'none';
|
|
||||||
});
|
|
||||||
if (cancelBtn) cancelBtn.addEventListener('click', function() {
|
|
||||||
installForm.style.display = 'none';
|
|
||||||
installBtn.style.display = '';
|
|
||||||
fileInput.value = '';
|
|
||||||
uploadBtn.disabled = true;
|
|
||||||
statusEl.textContent = '';
|
|
||||||
});
|
|
||||||
if (fileInput) fileInput.addEventListener('change', function() {
|
|
||||||
uploadBtn.disabled = !fileInput.files.length;
|
|
||||||
});
|
|
||||||
if (uploadBtn) uploadBtn.addEventListener('click', async function() {
|
|
||||||
var file = fileInput.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
uploadBtn.disabled = true;
|
|
||||||
uploadBtn.textContent = 'Uploading…';
|
|
||||||
statusEl.textContent = '';
|
|
||||||
statusEl.style.color = 'var(--text-3)';
|
|
||||||
|
|
||||||
try {
|
|
||||||
var formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
var base = window.__BASE__ || '';
|
|
||||||
var resp = await fetch(base + '/api/v1/admin/surfaces/install', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: API._authHeaders ? (function() { var h = API._authHeaders(); delete h['Content-Type']; return h; })() : {},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
var data = await resp.json();
|
|
||||||
if (!resp.ok) throw new Error(data.error || 'Upload failed');
|
|
||||||
|
|
||||||
statusEl.style.color = 'var(--success)';
|
|
||||||
statusEl.textContent = 'Installed: ' + (data.title || data.id);
|
|
||||||
if (typeof UI !== 'undefined') UI.toast('Surface installed: ' + (data.title || data.id), 'success');
|
|
||||||
|
|
||||||
// Refresh the list
|
|
||||||
installForm.style.display = 'none';
|
|
||||||
installBtn.style.display = '';
|
|
||||||
fileInput.value = '';
|
|
||||||
_loadSurfaceList();
|
|
||||||
} catch (e) {
|
|
||||||
statusEl.style.color = 'var(--danger)';
|
|
||||||
statusEl.textContent = 'Error: ' + e.message;
|
|
||||||
uploadBtn.disabled = false;
|
|
||||||
uploadBtn.textContent = 'Upload';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
_loadSurfaceList();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _loadSurfaceList() {
|
|
||||||
var listEl = document.getElementById('adminSurfaceList');
|
|
||||||
if (!listEl) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
var resp = await API._get('/api/v1/admin/surfaces');
|
|
||||||
var surfaces = resp.surfaces || [];
|
|
||||||
|
|
||||||
if (surfaces.length === 0) {
|
|
||||||
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--text-3);">No surfaces registered</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
listEl.innerHTML = '';
|
|
||||||
surfaces.forEach(function(s) {
|
|
||||||
var row = document.createElement('div');
|
|
||||||
row.className = 'admin-surface-row';
|
|
||||||
row.dataset.surfaceId = s.id;
|
|
||||||
|
|
||||||
var isProtected = s.id === 'chat' || s.id === 'admin';
|
|
||||||
var sourceLabel = s.source === 'core' ? 'Core' : 'Extension';
|
|
||||||
var route = s.manifest && s.manifest.route ? s.manifest.route : '';
|
|
||||||
|
|
||||||
row.innerHTML =
|
|
||||||
'<div class="admin-surface-info">' +
|
|
||||||
'<div class="admin-surface-title">' +
|
|
||||||
'<span class="admin-surface-name">' + esc(s.title) + '</span>' +
|
|
||||||
'<span class="admin-surface-badge admin-surface-badge--' + s.source + '">' + sourceLabel + '</span>' +
|
|
||||||
(isProtected ? '<span class="admin-surface-badge admin-surface-badge--required">Required</span>' : '') +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="admin-surface-meta">' +
|
|
||||||
'<span class="admin-surface-id">' + esc(s.id) + '</span>' +
|
|
||||||
(route ? '<span class="admin-surface-route">' + esc(route) + '</span>' : '') +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="admin-surface-actions">' +
|
|
||||||
'<label class="admin-surface-toggle' + (isProtected ? ' admin-surface-toggle--locked' : '') + '">' +
|
|
||||||
'<input type="checkbox"' + (s.enabled ? ' checked' : '') + (isProtected ? ' disabled' : '') +
|
|
||||||
' data-surface-id="' + esc(s.id) + '">' +
|
|
||||||
'<span class="admin-surface-toggle-label">' + (s.enabled ? 'Enabled' : 'Disabled') + '</span>' +
|
|
||||||
'</label>' +
|
|
||||||
(s.source === 'extension' ? '<button class="btn-small btn-danger admin-surface-uninstall" data-surface-id="' + esc(s.id) + '">Uninstall</button>' : '') +
|
|
||||||
'</div>';
|
|
||||||
|
|
||||||
// Toggle handler
|
|
||||||
var checkbox = row.querySelector('input[type="checkbox"]');
|
|
||||||
if (checkbox && !isProtected) {
|
|
||||||
checkbox.addEventListener('change', (function(surface) {
|
|
||||||
return async function() {
|
|
||||||
var id = this.dataset.surfaceId;
|
|
||||||
var action = this.checked ? 'enable' : 'disable';
|
|
||||||
var label = row.querySelector('.admin-surface-toggle-label');
|
|
||||||
var cb = this;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await API._put('/api/v1/admin/surfaces/' + id + '/' + action);
|
|
||||||
if (label) label.textContent = cb.checked ? 'Enabled' : 'Disabled';
|
|
||||||
if (typeof UI !== 'undefined') {
|
|
||||||
UI.toast(surface.title + ' ' + (cb.checked ? 'enabled' : 'disabled') + ' — takes effect on next page load', 'success');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
cb.checked = !cb.checked;
|
|
||||||
if (label) label.textContent = cb.checked ? 'Enabled' : 'Disabled';
|
|
||||||
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})(s));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uninstall handler
|
|
||||||
var uninstallBtn = row.querySelector('.admin-surface-uninstall');
|
|
||||||
if (uninstallBtn) {
|
|
||||||
uninstallBtn.addEventListener('click', (function(surface) {
|
|
||||||
return async function() {
|
|
||||||
var ok = typeof showConfirm === 'function'
|
|
||||||
? await showConfirm('Uninstall ' + surface.title + '? This removes all files.')
|
|
||||||
: window.confirm('Uninstall ' + surface.title + '?');
|
|
||||||
if (!ok) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await API._del('/api/v1/admin/surfaces/' + surface.id);
|
|
||||||
row.remove();
|
|
||||||
if (typeof UI !== 'undefined') UI.toast(surface.title + ' uninstalled', 'success');
|
|
||||||
} catch (e) {
|
|
||||||
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})(s));
|
|
||||||
}
|
|
||||||
|
|
||||||
listEl.appendChild(row);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--danger);">Failed to load: ' + esc(e.message) + '</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ── Exports ─────────────────────────────────
|
|
||||||
sb.register('_loadAdminSurfaces', _loadAdminSurfaces);
|
|
||||||
@@ -11,7 +11,7 @@ const ADMIN_SECTIONS = {
|
|||||||
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
|
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
|
||||||
workflows: ['workflows', 'tasks'],
|
workflows: ['workflows', 'tasks'],
|
||||||
routing: ['health', 'routing', 'capabilities'],
|
routing: ['health', 'routing', 'capabilities'],
|
||||||
system: ['settings', 'storage', 'extensions', 'channels', 'surfaces', 'broadcast'],
|
system: ['settings', 'storage', 'packages', 'channels', 'broadcast'],
|
||||||
monitoring: ['usage', 'audit', 'stats'],
|
monitoring: ['usage', 'audit', 'stats'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ const ADMIN_LABELS = {
|
|||||||
providers: 'Providers', models: 'Models', personas: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
|
providers: 'Providers', models: 'Models', personas: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
|
||||||
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
|
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
|
||||||
workflows: 'Workflows', tasks: 'Tasks',
|
workflows: 'Workflows', tasks: 'Tasks',
|
||||||
settings: 'Settings', storage: 'Storage', extensions: 'Extensions', channels: 'Channels', surfaces: 'Surfaces', broadcast: 'Broadcast',
|
settings: 'Settings', storage: 'Storage', packages: 'Packages', channels: 'Channels', broadcast: 'Broadcast',
|
||||||
usage: 'Usage', audit: 'Audit', stats: 'Stats',
|
usage: 'Usage', audit: 'Audit', stats: 'Stats',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,8 +43,7 @@ const ADMIN_LOADERS = {
|
|||||||
},
|
},
|
||||||
settings: () => UI.loadAdminSettings(),
|
settings: () => UI.loadAdminSettings(),
|
||||||
storage: () => typeof loadAdminStorage === 'function' ? loadAdminStorage() : null,
|
storage: () => typeof loadAdminStorage === 'function' ? loadAdminStorage() : null,
|
||||||
extensions: () => UI.loadAdminExtensions(),
|
packages: () => typeof _loadAdminPackages === 'function' ? _loadAdminPackages() : null,
|
||||||
surfaces: () => typeof _loadAdminSurfaces === 'function' ? _loadAdminSurfaces() : null,
|
|
||||||
health: () => UI.loadAdminHealth(),
|
health: () => UI.loadAdminHealth(),
|
||||||
routing: () => UI.loadAdminRouting(),
|
routing: () => UI.loadAdminRouting(),
|
||||||
capabilities: () => UI.loadAdminCapabilities(),
|
capabilities: () => UI.loadAdminCapabilities(),
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
# surfaces/
|
|
||||||
|
|
||||||
Source directories for installable extension surfaces. Each subdirectory
|
|
||||||
contains a `manifest.json` plus `js/`, `css/`, and optionally `assets/`
|
|
||||||
directories — matching the archive format expected by
|
|
||||||
`POST /admin/surfaces/install`.
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
surfaces/
|
|
||||||
build.sh ← packages each subdir into dist/<name>.surface
|
|
||||||
hello-dashboard/ ← sample surface (verifies /s/:slug pipeline)
|
|
||||||
manifest.json
|
|
||||||
js/main.js
|
|
||||||
css/main.css
|
|
||||||
icd-test-runner/ ← ICD contract test runner (dev/test tool)
|
|
||||||
manifest.json
|
|
||||||
js/
|
|
||||||
css/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build all surfaces → dist/*.surface
|
|
||||||
./surfaces/build.sh
|
|
||||||
|
|
||||||
# Build one
|
|
||||||
./surfaces/build.sh icd-test-runner
|
|
||||||
```
|
|
||||||
|
|
||||||
Output goes to `dist/` (gitignored). Each `.surface` file is a zip
|
|
||||||
archive ready for upload via the admin UI or:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST https://host/api/v1/admin/surfaces/install \
|
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
|
||||||
-F "file=@dist/icd-test-runner.surface"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding a surface
|
|
||||||
|
|
||||||
1. Create `surfaces/<id>/manifest.json` with at least `id` and `title`
|
|
||||||
2. Add `js/`, `css/`, `assets/` as needed
|
|
||||||
3. Run `./surfaces/build.sh <id>` to verify it packages
|
|
||||||
4. Install via admin UI or API
|
|
||||||
|
|
||||||
See [EXTENSION-SURFACES.md](../docs/EXTENSION-SURFACES.md) for the full
|
|
||||||
authoring guide including manifest schema, platform API, and CSS custom
|
|
||||||
properties.
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- `id` in manifest.json must be unique across all surfaces (core + extension)
|
|
||||||
- Core surfaces (`chat`, `admin`, `settings`, `notes`, `editor`) cannot
|
|
||||||
be overridden — the install endpoint returns 409
|
|
||||||
- Surface routes are served at `/s/<id>` (auto-derived from manifest)
|
|
||||||
- Static assets are served from `/surfaces/<id>/js/`, `/surfaces/<id>/css/`
|
|
||||||
- The `dist/` directory is gitignored — archives are build artifacts
|
|
||||||
Binary file not shown.
Reference in New Issue
Block a user