From 9b9e2eb756ddb0c96a1c0d64fb4505df565f6a38 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Fri, 13 Mar 2026 09:29:04 +0000 Subject: [PATCH] Changeset 0.28.0.10 (#182) --- .gitea/workflows/ci.yaml | 2 +- docs/ICD/surfaces.md | 91 +- docs/ROADMAP.md | 1320 +++-------------- server/handlers/surface_store_test.go | 386 +++++ server/handlers/surfaces.go | 70 +- server/handlers/surfaces_test.go | 604 ++++++++ server/pages/templates/base.html | 44 + .../pages/templates/surfaces/extension.html | 4 +- src/js/editor-surface.js | 19 +- 9 files changed, 1412 insertions(+), 1128 deletions(-) create mode 100644 server/handlers/surface_store_test.go create mode 100644 server/handlers/surfaces_test.go diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 30bf260..37734e1 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -351,7 +351,7 @@ jobs: PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }} run: | echo "━━━ Postgres Integration Tests ━━━" - go test -v -race -count=1 -p 1 -timeout 8m \ + go test -v -race -count=1 -p 1 -timeout 12m \ ./store/postgres/... \ ./handlers/... echo "✓ Postgres integration tests complete" diff --git a/docs/ICD/surfaces.md b/docs/ICD/surfaces.md index 9cafec6..1d612cb 100644 --- a/docs/ICD/surfaces.md +++ b/docs/ICD/surfaces.md @@ -12,14 +12,15 @@ Extension surfaces are uploaded by admins and served from the GET /surfaces ``` -Returns surfaces the user can access (enabled in registry). +Returns enabled surfaces with minimal nav info (no `source` or `enabled` +fields — these are admin concerns). ```json { "surfaces": [ - { "id": "chat", "title": "Chat", "source": "core", "enabled": true }, - { "id": "editor", "title": "Editor", "source": "core", "enabled": true }, - { "id": "hello-dashboard", "title": "Hello Dashboard", "source": "extension", "enabled": true } + { "id": "chat", "title": "Chat", "route": "/" }, + { "id": "editor", "title": "Editor", "route": "/editor" }, + { "id": "hello-dashboard", "title": "Hello Dashboard", "route": "/s/hello-dashboard" } ] } ``` @@ -34,7 +35,17 @@ Returns surfaces the user can access (enabled in registry). GET /admin/surfaces ``` -Returns all surfaces including disabled ones. +Returns all surfaces including disabled ones. Each entry is a full +[Surface Registry Object](#surface-registry-object). + +```json +{ + "surfaces": [ + { "id": "chat", "title": "Chat", "manifest": {...}, "enabled": true, "source": "core", "installed_at": "...", "updated_at": "..." }, + { "id": "hello-dashboard", "title": "Hello Dashboard", "manifest": {...}, "enabled": false, "source": "extension", "installed_at": "...", "updated_at": "..." } + ] +} +``` **Auth:** Platform admin. @@ -46,6 +57,13 @@ GET /admin/surfaces/:id Returns full surface details including manifest. +**Response:** A single [Surface Registry Object](#surface-registry-object). + +**Errors:** +- `404` — surface not found (or store error). + +**Auth:** Platform admin. + ### Install Surface ``` @@ -53,9 +71,18 @@ POST /admin/surfaces/install Content-Type: multipart/form-data ``` -Field: `archive` — a `.surface` archive (tar.gz containing `manifest.json` -+ static assets). The manifest declares `id`, `title`, `route`, required -data loaders, scripts, and CSS. +Field: `file` — a `.surface` or `.zip` archive (zip containing +`manifest.json` + static assets). The manifest declares `id`, `title`, +`route`, required data loaders, scripts, and CSS. + +**Size limit:** 50 MB. + +**Manifest validation:** `id` and `title` are required. + +**Errors:** +- `400` — no file, wrong extension, too large, invalid zip, missing or + invalid manifest, missing `id`/`title`. +- `409` — surface ID conflicts with an existing core surface. **Auth:** Platform admin. @@ -68,6 +95,13 @@ PUT /admin/surfaces/:id/disable Disabled surfaces redirect to `/` and hide from navigation. +**Undisableable surfaces:** `chat` and `admin` cannot be disabled +(returns `400`). + +**Errors:** +- `400` — attempting to disable `chat` or `admin`. +- `404` — surface not found. + **Auth:** Platform admin. ### Delete Surface @@ -76,10 +110,19 @@ Disabled surfaces redirect to `/` and hide from navigation. DELETE /admin/surfaces/:id ``` -Removes the surface and its static assets. Core surfaces cannot be deleted. +Removes the surface, its DB row, and its extracted static assets. +Core surfaces cannot be deleted. + +**Errors:** +- `400` — attempting to delete a core surface. +- `404` — surface not found. **Auth:** Platform admin. +**Defense-in-depth:** The store layer also enforces +`WHERE source = 'extension'` on delete, so even if the handler check +were bypassed, core surfaces are protected. + ## Surface Registry Object ```json @@ -94,25 +137,45 @@ Removes the surface and its static assets. Core surfaces cannot be deleted. } ``` +## Internal Store Methods + +The `SurfaceRegistryStore` interface exposes methods beyond what the +HTTP API uses directly: + +- `Seed(ctx, id, title, source, manifest)` — upserts a surface at + startup. Does **not** overwrite the `enabled` flag (admin toggle + survives restarts). Called by the page engine's `SeedSurfaces()`. +- `ListEnabled(ctx) → []string` — returns IDs of all enabled + surfaces. Used by the page engine for nav rendering + (`EnabledSurfaceIDs()`), not exposed via HTTP. + ## Page Routes Extension surfaces are served at `/s/:slug` via `RenderExtensionSurface()`. The page engine does a runtime DB lookup — no server restart needed after install. Static assets are served from `/surfaces/:id/` (nginx location block in -both unified and split deployment). +both unified and split deployment). In unified mode, the Go server +handles this route with path-traversal protection. In split deployment, +nginx serves directly from `/data/surfaces/`. ## Surface Archive Format -A `.surface` file is a tar.gz containing: +A `.surface` file is a **zip** archive containing: ``` -manifest.json — required -script.js — optional -style.css — optional +manifest.json — required (must have "id" and "title") +js/ — optional (extracted to /data/surfaces/{id}/js/) +css/ — optional (extracted to /data/surfaces/{id}/css/) assets/ — optional (icons, images, etc.) ``` +The archive may have a single top-level directory prefix (e.g., +`my-surface/manifest.json`) — the installer strips it when locating +`manifest.json` and when extracting `js/`, `css/`, and `assets/` +directories. Files outside these directories (e.g., `script.js` at +root) are not extracted. + See [EXTENSION-SURFACES.md](../EXTENSION-SURFACES.md) for the full authoring guide including manifest schema, platform API, and CSS custom properties. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6725d57..d268374 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -18,1060 +18,236 @@ No compatibility guarantees before 1.0. Features have real dependencies. This ordering respects them. ``` -v0.9.x Stability + Quick UX Wins ✅ - │ -v0.9.3 Content Visibility & Block Controls ✅ - │ -v0.9.4 API Key Encryption + Vault ✅ - │ -v0.10.0 Model Roles (utility + embedding) ✅ - + Usage Tracking - │ -v0.10.2 Summarize & Continue ✅ - │ -v0.10.3 Frontend Refactor ✅ - │ -v0.10.4 Model Type Pipeline + Role Fixes ✅ - │ -v0.10.5 UI Primitives + Extension Surfaces ✅ - │ -v0.11.0 Extension Foundation (browser tier) ✅ - │ - ┌───────┴──────────────┐ - │ │ -v0.12.0 File Handling v0.13.0 Admin Panel - + Vision ✅ Refactor (fullscreen) ✅ - │ │ - │ v0.13.1 Web Search - │ + url_fetch ✅ - │ │ - └───────┬──────────────┘ - │ -v0.14.0 Knowledge Bases ✅ (embedding role + file storage + pgvector) - │ -v0.15.0 Compaction ✅ (utility role + background job) - │ -v0.15.1 Context Recall Tools ✅ (attachment_recall, conversation_search) - │ - ┌───────┴──────────────┐ - │ │ -v0.16.0 User Groups v0.17.0 Persona-KB Binding - + Resource Grants ✅ + Enterprise KB Mode - │ + QOL / UX Wins - └───────┬──────────────┘ - │ - ┌───────┴──────────────┐ - │ │ -v0.17.1 SQLite Backend ✅ v0.17.2 CodeMirror 6 ✅ -v0.17.3 Notes Graph + Wikilinks ✅ - (dual DB) (editor bundle, chat - │ input, ext editor) - └───────┬──────────────┘ - │ -v0.18.0 Memory ✅ (user + persona scopes, review pipeline) - │ -v0.18.1 Side Panel Architecture ✅ (single-slot, ctx.ui primitives, mermaid refactor) - │ -v0.19.0 Projects / Workspaces ✅ (project groups, KB resolution, drag-drop sidebar) - │ -v0.19.1 Active Project + System Prompt + Detail Panel ✅ - │ -v0.19.2 Project Persona Default + Archive + Reorder ✅ - │ -v0.20.0 Notifications + @mention Routing + Multi-model ✅ - │ -v0.21.0 Workspace Storage Primitive ✅ - │ - ┌───────┴──────────────┐ - │ │ -v0.21.1 Workspace ✅ v0.21.3 Surface Infra ✅ - Tools + Bindings + REPL (parallel) - │ │ -v0.21.2 Workspace ✅ v0.21.5 Editor Surface ✅ - Indexing + Search (Development Mode) - │ │ -v0.21.4 Git ✅ v0.21.6 Editor Surface ✅ - Integration + Document Output - └───────┬──────────────┘ - │ -v0.21.7 Bugfix: scanJSON driver buffer aliasing ✅ - │ -v0.22.0 Provider Health + Capability Overrides ✅ - + Workspace Pane Refactor (FE) - │ -v0.22.1 Provider Extensions (declarative config) ✅ - │ -v0.22.2 Routing Policies + Fallback Chains ✅ - │ -v0.22.3 Provider Admin UI + Deferred Polish ✅ - │ -v0.22.4 Provider Health UX + Project Files + Export ✅ - │ -v0.22.5 Surfaces + Go Templates + Admin Bug Fixes ✅ - │ -v0.22.6 Split Deployment Fix + CI Timeout + Code Pruning ✅ - │ -v0.22.7 Surface Prototypes (Theme + ChatPane + Splash) ✅ - │ -v0.22.8 Surface Integration (Wire into existing JS) ✅ - │ -v0.23.0 @mention Routing + Persona Handles + Proxy ✅ - │ -v0.23.1 Multi-User Navigation + Conversation Taxonomy ✅ - │ -v0.23.2 Multi-User Polish + Channel Lifecycle ✅ - │ -v0.24.0 Auth Abstraction + User Identity ✅ - │ - ├── v0.24.1 mTLS + OIDC Providers (parallel) ✅ - │ │ - │ └── v0.24.3 Anonymous Sessions ✅ - │ - └── v0.24.2 Fine-Grained Permissions (parallel) ✅ - │ -v0.25.0 Dynamic Surfaces + Component Extraction ✅ - (Go template engine, base.html shell, surface - manifests, admin/settings/editor surfaces, - pane container, persona tool grants UI) - │ -v0.25.1 Surface Integration Fixes ✅ - │ -v0.25.2 Drag-Resize Utility ✅ - (unified primitive for workspace + pane splits) - │ -v0.25.3 UI Bug Cleanup ✅ - (theme system, scaling, debug modal, settings - back-button, profile save, resize bar) - │ -v0.25.4 Admin + Settings Surface Hardening ✅ - (admin audit fixes, BYOK/persona toggles, - group permissions 500, settings surface init, - user settings NULL fix, export/import, - workspace mgmt, multi-provider test failover) - │ -v0.26.0 Workflow Engine ✅ - (team-owned staged processes, - human + AI collab, visitor - intake, assignment queue) - │ -v0.27.0 Debt Clearance ✅ - (extension surface routes, - workflow polish, editor debt) - │ -v0.27.1 Tasks Foundation ✅ - (service channels, scheduler, - task definitions, cron) - │ -v0.27.2 Task Execution ✅ - (budgets, admin controls, - run history, kill switch) - │ -v0.27.3 Task Chaining ✅ - (webhooks, task_create tool, - workflow-to-workflow) - │ -v0.27.4 Personal Tasks ✅ - (BYOK scheduling, user task UI, - starter templates) - │ -v0.27.5 Team Tasks ✅ - (team task routes, member visibility, - team badges in settings + sidebar) - │ -v0.28.0 Platform Polish - (virtual scroll, KB auto-inject, - Helm chart, provider model prefs) - │ -v0.29.0 Starlark Extensions - (permissioned server-side plugins, - capability-gated module access, - admin grant/revoke per-extension) +v0.9.x–v0.27.5 Foundation → Extensions → Surfaces → Auth ✅ + → Workflows → Tasks → Team Tasks + (see CHANGELOG.md for full history) + │ + v0.28.0 Platform Polish + ├─ v0.28.1 Surfaces ICD audit ✅ + ├─ v0.28.2 ICD audit round 2 + ├─ v0.28.3 Security tier (red team) + ├─ v0.28.4 Infrastructure + │ (virtual scroll, KB auto-inject, + │ Helm chart, provider model prefs, + │ git credentials UI) + └─ v0.28.5 Frontend SDK + │ + v0.29.0 Starlark Sandbox + (eval loop, permissions, admin UI) + │ + v0.29.1 API Extensions + (Starlark route handlers, + outbound HTTP, requires_provider) + │ + v0.29.2 DB Extensions + (namespaced tables, scoped db module, + declarative schema in manifest) + │ + v0.30.0 Surface Packages + (full-stack .surface archives: + UI + API + schema + settings) + │ + v0.31.0 Editor Surface Package + (E2E proof: rebuild editor as + installable surface, zero + platform special-casing) ``` --- -## Completed Releases - -> Full details for all completed versions are in [CHANGELOG.md](../CHANGELOG.md). - -### ✅ v0.9.0 — Schema Consolidation + BYOK -21 migrations → single schema, store layer abstraction, persona-as-trust-boundary, -BYOK with auto-fetch, composite model IDs, journey integration tests. - -### ✅ v0.9.1 — Capability Architecture + Docs -Removed static known model table. Resolution chain: catalog → heuristic only. -Frontend relies solely on backend for capabilities. Docs rewrite. - -### ✅ v0.9.2 — Quick UX Wins + Hardening -Collapsible code blocks, HTML preview, token counter, context warning, proxy -interception detection, environment injection, team admin audit scoping. - -### ✅ v0.9.3 — Content Visibility & Block Controls -Button-driven code collapse, always-rendered thinking blocks, tool call -persistence in history, Notes panel → unified side panel, streaming refactor -(`streamWithToolLoop`), regenerate fix. - -### ✅ v0.9.4 — API Key Encryption + Vault -Two-tier AES-256-GCM: env-var key for global/team, per-user UEK (Argon2id) -for personal BYOK. `server/crypto/` package. Migration 003. Startup backfill. -DOMPurify strict allowlist (XSS fix). - -### ✅ v0.10.0 — Model Roles + Usage Tracking + Vault Debt -Named role slots (utility, embedding) with fallback. `Provider.Embed()` interface. -`usage_log` + `model_pricing` tables. Streaming token capture. Vault debt: -UEK re-wrap on password change, destruction on admin reset. - -### ✅ v0.10.1 — Polish + UX -Admin system prompt injection, Personas tab, Team Management modal extraction, -preview pane enhancements, code download, personal usage scoped to BYOK. - -### ✅ v0.10.2 — Summarize & Continue + Role Cleanup -User-triggered conversation compaction via utility role. Summary as tree node -with boundary metadata. BYOK role overrides (personal → team → global). -Utility rate limiting. Generation role removed. - -### ✅ v0.10.3 — Frontend Refactor -2 monolith files → 13 domain-scoped files (~544 lines avg). Zero features, -no function renames. Vanilla JS, no build step. -See [REFACTOR-0.10.3.md](REFACTOR-0.10.3.md). - -### ✅ v0.10.4 — Model Type Pipeline + Role Fixes -`model_type` (chat/embedding/image) captured end-to-end from provider API -through catalog to frontend. Role dropdowns filter by type. - -### ✅ v0.10.5 — UI Primitives + Extension Surfaces -Shared `Providers`/`Roles` registries, 6 consolidated render primitives, -`showConfirm()` replacing native dialogs, `.popup-menu` CSS primitive. -Removed ~400 lines of duplication. - -### ✅ v0.11.0 — Extension Foundation (Browser Tier) -Full extension lifecycle: manifest, loader, scoped `ctx.*` API, browser tool -bridge via WebSocket. Custom renderer pipeline. 2 server tools (calculator, -datetime), 6 built-in browser extensions (Mermaid, KaTeX, CSV, Diff, JS -Sandbox, Regex). See [EXTENSIONS.md](EXTENSIONS.md). - -### ✅ v0.12.0 — File Handling + Vision -S3/PVC storage backend, image/file upload (📎, drag-drop, paste), multimodal -message assembly, text extraction pipeline (PDF/DOCX/XLSX/PPTX/ODT/RTF), -vault CLI (`rekey`/`status`), per-chat model persistence. - -### ✅ v0.13.0 — Admin Panel Refactor -12-tab modal → fullscreen admin panel. 4 categories (People, AI, System, -Monitoring) × section sidebar. URL-based routing, responsive, banner-aware. -CSS design token cleanup. - -### ✅ v0.13.1 — Web Search + URL Fetch -`web_search` + `url_fetch` tools. Search provider abstraction (DuckDuckGo, -SearXNG). Tool categories with per-tool toggle UI in chat bar. - -### ✅ v0.14.0 — Knowledge Bases -RAG: upload → chunk → embed (pgvector) → `kb_search` tool. Channel KB toggle. -Team/personal KB scopes. Notes semantic search. Admin panel KB management. -See [DESIGN-0.14.0.md](DESIGN-0.14.0.md). - -### ✅ v0.15.0 — Compaction -Background scanner with configurable thresholds. Automatic summarization via -utility role. Per-channel opt-in/out. Context budget guard rail (80% ceiling). - -### ✅ v0.15.1 — Context Recall Tools -`attachment_recall` (list + read, channel-scoped), `conversation_search` -(full-text via `plainto_tsquery`). Token estimator attachment awareness. - -### ✅ v0.16.0 — User Groups + Resource Grants -Groups (global/team-scoped ACLs) decoupled from team membership. Three-way -resource grants (team_only/global/groups) for Personas and KBs. Grant picker -UI. Schema consolidation: 9 migrations → single `001_v016_schema.sql`. - - -### ✅ v0.17.0 — Persona-KB Binding + Enterprise KB Mode + QOL -Personas as KB gateways. `persona_knowledge_bases` binding, auto-search -mode, enterprise KB discoverability controls. Team admin workflow. -`HandleFromName()` auto-generation. Store layer sole DB interface. - -### ✅ v0.17.1 — SQLite Backend -Dual-database support (Postgres + SQLite). `database/compat.go` dialect -abstraction, `database.Q()` query rewriting, SQLite-specific migration -set. Single-user / dev deployment mode. -See [v0171-sqlite-backend.md](v0171-sqlite-backend.md). - -### ✅ v0.17.2 — CodeMirror 6 Integration -CM6 replaces textarea: `codeEditor()` + `chatInput()` + `noteEditor()` -factories. 20+ languages bundled, Vim/Emacs keybindings, `[[wikilink]]` -autocomplete. esbuild bundle. See [DESIGN-CM6.md](DESIGN-CM6.md). - -### ✅ v0.17.3 — Notes Graph + Wikilinks -`[[wikilink]]` bi-directional linking, `note_links` junction table, -Canvas force-directed graph visualization. Daily notes, folder tree, -markdown preview. See [DESIGN-0.17.3.md](archive/DESIGN-0.17.3.md). - -### ✅ v0.18.0 — Memory (User + Persona Scopes) -User + persona memory extraction pipeline. Background summarization via -utility role. Memory injection into system prompt. Admin review panel. -`memories` + `persona_memories` tables. - -### ✅ v0.18.1 — Side Panel Architecture -Single-slot panel system. Panel registry with independent open/close. -Dual-view mode (two panels side-by-side). `ctx.ui.openPreview()`, -keyboard shortcuts. Extension UI participation. Mobile swipe. - -### ✅ v0.19.0 — Projects / Workspaces -Project container: channels, KBs, personas, system prompt, team scope. -`projects` + `project_channels` tables. Drag-drop sidebar. - -### ✅ v0.19.1 — Active Project + System Prompt + Detail Panel -Active project context in chat. Project system prompt prepend. Detail -panel (info, members, KBs, channels). Channel-project binding UI. - -### ✅ v0.19.2 — Project Persona Default + Archive + Reorder -Project-level default persona. Archive/unarchive. Drag-to-reorder -sidebar items. Project settings persistence. - -### ✅ v0.20.0 — Notifications + @mention Routing + Multi-model -Bell dropdown notifications. `@mention` resolves to persona handles and -model IDs. Multi-model chat. `notifications` table, WebSocket push, -`channel_participants` table. AI-to-AI chaining, context poisoning -prevention (Anthropic rewrite, OpenAI Name field). - -### ✅ v0.21.x — Workspace Platform + Extension Surfaces -Seven-release series. v0.21.0: workspace storage primitive. v0.21.1: -workspace tools + channel/project binding. v0.21.2: workspace indexing + -semantic search. v0.21.3: surface infrastructure + REPL. v0.21.4: git -integration (clone/push/pull/commit, credential management). v0.21.5: -editor surface (IDE-like: file tree + CM6 + chat pane). v0.21.6: article -surface + document output pipeline + hash router. v0.21.7: scanJSON -driver buffer aliasing bugfix. See [DESIGN-0.21.0.md](archive/DESIGN-0.21.0.md). - -### ✅ v0.22.0 — Provider Health + Capability Overrides -`provider_health` accumulator (5-min windows, 1h retention). Health -badges. `capability_overrides` table. Two-tier capability resolution -(catalog sync → heuristic → optional admin override). Provider-scoped -capabilities. - -### ✅ v0.22.1 — Provider Extensions (Declarative Config) -YAML-based provider type registration. Settings schema per provider type. -Venice.ai provider. Provider-specific parameter pass-through. Extended -thinking support (Anthropic). - -### ✅ v0.22.2 — Routing Policies + Fallback Chains -`routing_policies` table. Three policy types: `model_preference`, -`provider_failover`, `cost_optimize`. Channel-bindable. Failover -execution in completion handler. Health-aware skip. - -### ✅ v0.22.3 — Provider Admin UI + Deferred Polish -Provider health dashboard, capability override editor, routing policy -builder. Model catalog sync trigger. Dynamic provider config form. - -### ✅ v0.22.4 — Provider Health UX + Project Files + Export -Health dots in model selector. Project files tab (channel file -aggregation). Conversation export (JSON, Markdown). Bulk file download. - -### ✅ v0.22.5 — Surfaces + Go Templates + Admin Bug Fixes -Go `html/template` engine with `//go:embed`. Six surfaces (chat, editor, -notes, settings, admin, login). Reusable components (model-select, -team-select, file-upload). `AuthOrRedirect` middleware. CSP nonces. - -### ✅ v0.22.6 — Split Deployment Fix + CI Timeout + Code Pruning -`BACKEND_URL` fail-fast, ~2,430 lines OBE code removed (router.js, -surfaces.js, SPA shell). Extension surfaces intentionally broken pending -pane refactor (→ v0.25.0). Service worker BUILD_HASH cache key. - -### ✅ v0.22.7 — Surface Prototypes (Theme + ChatPane + Splash) -`theme.css` with CSS custom properties. `ChatPane.create()` factory -pattern. Login/splash redesign. Toast stack, badges, icon buttons, -avatar upload, confirm dialog. Settings feature gates (BYOK, Personas). - -### ✅ v0.22.8 — Surface Integration (Wire into Existing JS) -`ChatPane.primary` bridge from server-rendered mount points. Theme wired -to settings appearance. Admin hybrid section loaders. Naming cleanup: -preset → persona, APIConfigID → ProviderConfigID. - -### ✅ v0.23.0 — @mention Routing + Persona Handles + Proxy -@mention resolution in any chat against full model catalog + persona registry. -AI-to-AI chaining with depth cap. Provider proxy mode (system/direct/custom). -`channel_participants` + `channel_models` tables. Persona handles. - -### ✅ v0.23.1 — Multi-User Navigation + Conversation Taxonomy -Five-type conversation taxonomy (direct/dm/group/channel/workflow). Three-section -sidebar (Projects → Channels → Chats). Folder system. DM plumbing. Channel -`ai_mode` (auto/mention_only/off). Presence heartbeat. Migration 016. - -### ✅ v0.23.2 — Multi-User Polish + Channel Lifecycle -Unified `App.activeConversation` replacing split tracking. Group leader -routing, `@all` fan-out. Channel archive/delete with retention policy. -Human message attribution. Unread cursor via `last_read_at`. Migration 017. - -### ✅ v0.24.0 — Auth Abstraction + User Identity -`auth.Provider` interface decoupling builtin auth from handlers. User model -extended with `auth_source`, `external_id`, `handle`. @mention resolution -upgraded to handles. `UniqueHandle()` collision-safe generation. Migration 018. - -### ✅ v0.24.1 — mTLS + OIDC Providers -mTLS via reverse proxy headers + cert DN parsing. OIDC authorization code flow -with JWKS caching and split-horizon issuer. Keycloak docker-compose overlay. -`QArgs()` dialect adapter for SQLite placeholder expansion. Migration 019. - -### ✅ v0.24.2 — Fine-Grained Permissions -12 permission constants (`domain.action`). Per-group permissions, token budgets, -model allowlists. Everyone group (system-seeded, editable). `RequirePermission()` -middleware. BYOK bypass for personal providers. Migration 020. - -### ✅ v0.24.3 — Anonymous Sessions -Unauthenticated visitors for workflow intake. `session_participants` table, -`AuthOrSession` middleware, cookie-based + mTLS session paths. Channel -`allow_anonymous` flag. Workflow entry page at `/w/:id`. Migration 021. - -### ✅ v0.25.0 — Dynamic Surfaces + Component Extraction -Go template engine with `base.html` shell, surface manifest registry (DB-backed, -admin-toggleable), five core surfaces (chat, admin, settings, editor, notes). -Component extraction: UserMenu, ModelSelector, FileTree, CodeEditor, NoteEditor. -PaneContainer for multi-pane layouts. Admin and settings promoted from modals to -full-page surfaces. CSS decomposed from monolithic `styles.css` into 13 files. -Persona tool grants UI. Migration 022: `surface_registry` table. - -### ✅ v0.25.1 — Surface Integration Fixes -Post-deployment corrections for v0.25.0 surface migration. - -### ✅ v0.25.2 — Drag-Resize Utility -Unified `drag-resize.js` primitive for workspace handle and pane split handles. -Full-viewport overlay prevents iframe/CM6 event swallowing. Touch support. - -### ✅ v0.25.3 — UI Bug Cleanup -Theme system mode fix (system → dark fallback), resize bar jump-on-click, -debug modal styling, settings/admin back-button navigation, profile save -(wrong IDs, wrong API routes, empty user context), scale slider range/commit. - -### ✅ v0.25.4 — Admin + Settings Surface Hardening -Admin audit: 6 navigation/wiring fixes, settings export/import, group permissions -500 (Postgres SetNull argIdx), BYOK/persona policy store mismatch. Settings surface: -full init pipeline (policies, models, handlers, visibility checks), user settings -NULL/null/array corruption fix, model roles notice states, bulk model visibility. -Chat surface: stale modal HTML cleanup (320 lines), team admin modal layout/wiring, -persona create button. Backend: project files admin bypass, workspace delete API, -multi-provider live test failover. - ---- - -## Technical Debt + Deferred Items - -Items deferred from completed releases that remain outstanding. -Organized by domain. Pull into a version when the surrounding work -makes them a natural addition. - -**Surfaces + Editor** -- [x] ~~Extension loader: surfaces from manifest.json~~ (v0.27.0 — `/s/:slug` route) -- [ ] Editor drag-drop file reorder (was v0.21.5 — deferred, no DnD infra in file-tree.js) -- [ ] Article drag-to-reorder outline sections (was v0.21.6) -- [ ] Article-specific AI tools: suggest_outline, expand_section, check_citations (was v0.21.6) -- [x] ~~PDF export via pandoc~~ (shipped v0.22.4) -- [ ] Mobile: mode selector collapses to hamburger/bottom nav (was v0.21.3) -- [x] ~~Pane state persistence per-user/per-project~~ (v0.27.0 — debounced sync to user_settings) - -**Workspace + Git** -- [ ] Workspace settings UI: git config section (was v0.21.4 — deferred, needs vault-encrypted git credentials) -- [ ] User settings: git credentials management UI (was v0.21.4 — needs vault extension, v0.28.0+) -- [x] ~~`.gitignore` respect in workspace indexing~~ (v0.27.0 — Reconcile walk filter) -- [ ] Integration tests: clone, commit, push/pull cycle — requires git binary in CI (was v0.21.4) - -**Provider Health + Routing** -- [x] ~~`RecordOutcome` for web_search and url_fetch~~ (shipped v0.22.4 — tool health tracking) -- [x] ~~Rate limit tracking per provider config~~ (shipped v0.22.4 — `rate_limit_count` on `provider_health`) -- [x] ~~Auto-disable: mark provider inactive when `down` for N consecutive health windows~~ (shipped v0.22.4) -- [ ] `capability_match` policy type ("cheapest model with tool_calling") — v0.28.0 candidate -- [ ] Latency-aware routing: track response time percentiles, prefer faster providers -- [ ] Cost-aware routing with budget ceiling per request -- [ ] New provider types registrable via config file (OpenAI-compatible + custom schema) — v0.28.0 candidate -- [ ] Provider profile editor: key-value config per provider type, preview of effective settings -- [ ] Fallback chain visualizer: drag-to-reorder provider priority per model family - -**Sessions + Auth** -- [x] ~~Session cleanup job~~ (shipped v0.26.0 — background goroutine, `SESSION_EXPIRY_DAYS`) - -**Tool System** -- [x] ~~Persona tool grant enforcement~~ (shipped v0.25.0 — second-pass allowlist in `completion.go:928`) -- [x] ~~Workflow version snapshot includes tool grants~~ (shipped v0.26.0 — `workflows.go:263`) - - ---- - -## ✅ v0.23.0 — @mention Routing + Persona Handles + Proxy - -@mention routing works in any chat against the full model catalog and persona -registry. No roster, groups, or participant setup required — just type `@handle` -or `@model-id` and send. - -**@mention Resolution** -- [x] `resolveMention()`: direct DB lookup — persona handle (exact/prefix) → model catalog (exact/prefix) -- [x] Autocomplete popup on `@` in any chat — all enabled models + personas -- [x] Handle field on personas: auto-generated from name, unique, editable -- [x] @mention pill rendering in message content (accent-colored, code-aware) -- [x] Context boundaries: persona→plain and persona→persona style isolation -- [x] Participant list injection: system prompt tells LLM who it can @mention - -**AI-to-AI Chaining** -- [x] `chainIfMentioned()`: uses `resolveMention()` on assistant response content -- [x] Self-mention blocked, depth capped at 5 -- [x] WebSocket delivery (`message.created` + typing indicators) -- [x] Same resolution path for user→LLM and LLM→LLM routing - -**Provider Proxy** -- [x] `proxy_mode` (system/direct/custom) + `proxy_url` on `provider_configs` -- [x] All four providers use `cfg.Client()` with mode-aware HTTP transport -- [x] Bad custom URL falls back to system (not direct) — safe for mandatory-proxy environments - -**Channel Infrastructure** -- [x] `channel_participants` table with polymorphic types and roles -- [x] `channel_models` partial indexes: persona entries keyed per-persona, raw models per-provider -- [x] Per-provider model preferences (composite keys in `user_model_settings`) -- [x] `persona_groups` + `persona_group_members` tables (schema ready, CRUD not yet wired) - ---- - -## ✅ v0.23.1 — Multi-User Navigation + Conversation Taxonomy - -Conversation type taxonomy (direct/dm/group/channel/workflow), three-section -sidebar (Projects → Channels → Chats), folder system for chats, DM plumbing, -channel configuration with `ai_mode`, presence heartbeat, and the unified -active conversation model. - -Depends on: @mention routing (v0.23.0), WebSocket infrastructure (exists). -See [DESIGN-0_23_1.md](DESIGN-0_23_1.md) for the full spec. - -**Conversation Types:** -- [x] Five-type taxonomy: direct (1:1 AI), dm (human-to-human), group (multi-participant), channel (named persistent), workflow (staged, deferred) -- [x] Channel type constraint extended: `dm`, `channel` added to `type` CHECK -- [x] `ai_mode` column on channels: `auto` | `mention_only` | `off` -- [x] `ai_mode` guard in completion handler — `off` returns 403, `mention_only` without @mention delivers without AI -- [x] `topic` column on channels for header display - -**Sidebar Architecture:** -- [x] Three collapsible sections: Projects → Channels → Chats -- [x] `renderChannelsSection()` with # / person icons, unread badges, online dots -- [x] Channel items: context menu (rename, set topic, delete), ⋯ hover button -- [x] Section collapse/expand state in localStorage - -**Folder System:** -- [x] `chat_folders` table, CRUD handler (`handlers/folders.go`) -- [x] Drag chats into/out of folders; folder context menu (rename, delete) -- [x] Folder delete modal: "Keep chats" / "Delete chats too" / Cancel -- [x] Unfiled drop zone when folders exist - -**DM Plumbing:** -- [x] `resolveMention()` extended to resolve users (exact + prefix on username) -- [x] User @mention skips AI, delivers `user.mentioned` WebSocket notification -- [x] DM creation flow with user search modal -- [x] DM dedup guard — one channel per participant pair - -**Presence:** -- [x] `user_presence` table with heartbeat upsert -- [x] `POST /presence/heartbeat` (30s interval), `GET /presence?users=...` (90s threshold) -- [x] Presence WebSocket events: `presence.changed` - -**Channel Participants:** -- [x] `channel_participants` CRUD handler, auto-created on channel creation -- [x] Persona group CRUD endpoints wired (`persona_groups`, `persona_group_members`) -- [x] DM participant auto-creation from `req.Participants` - -**Migration:** -- [x] 016_v023_multiuser: `ai_mode`, `topic`, `user_presence`, channel type extension - ---- - -## ✅ v0.23.2 — Multi-User Polish + Channel Lifecycle - -Bug fixes, unified active conversation refactor, channel lifecycle (archive/delete), -group chat leader routing, `@all` fan-out, message attribution, and deferred -surface integration from v0.22.8. - -Depends on: v0.23.1 sidebar and conversation taxonomy. - -**Unified Active Conversation:** -- [x] `App.activeConversation = { id, type }` replaces `currentChatId` + `currentChannelId` -- [x] `setActive(id, type)`, `activeId` getter, `getActiveChat()` helper -- [x] Send path, model bar, streaming, session restore all keyed off `activeConversation.id` -- [x] Sidebar highlight works across both chat and channel sections - -**Group Chat + Persona Groups:** -- [x] Group leader default response: no @mention in group → leader persona responds -- [x] `@all` fan-out: routes to every persona participant, depth-1 only -- [x] Participant mutation guards: DMs keep ≥2 humans, groups keep ≥1 persona -- [x] "New Group Chat" flow with ad-hoc persona picker - -**Message Attribution:** -- [x] Human sender display: other participants show avatar + display name (not "You") -- [x] Persona sender display verified for loaded history in channel context - -**Channel Lifecycle:** -- [x] Archive action via context menu (`is_archived`, `archived_at`) -- [x] Delete gated by `channel_retention.mode` (flexible/retain) -- [x] Admin purge with `purge_after_days` floor -- [x] Retention config keys in `global_config` - -**Bug Fixes:** -- [x] Channel persistence through refresh (resp.channels → resp.data) -- [x] Folder drag-and-drop (folderId mapping, drop handlers, unfiled zone) -- [x] DM creation 404 → added `GET /users/search` endpoint -- [x] Channel ⋯ menu consistency (replaced ✕ with hover ⋯ pattern) -- [x] Folder ⋯ button reflow (visibility:hidden instead of display:none) -- [x] Unread subquery using `last_read_at` (not migration-017-dependent column) -- [x] Settings Models section template wiring -- [x] `u.avatar` → `u.avatar_url` in ListMessages JOIN and treepath - -**Surface Integration (deferred v0.22.8):** -- [x] `ChatPane.primary` bridge from server-rendered mount points -- [x] Theme save/load cycle wired to settings appearance -- [x] Admin hybrid section loaders completed -- [x] Settings surface: models toggles, persona CRUD, BYOK, teams - -**@mention UX:** -- [x] Autocomplete includes users alongside personas and models -- [x] User mention pill styling (distinct color from persona pills) -- [x] `user.mentioned` WebSocket notification with toast - -**Migration:** -- [x] 017_unread: `last_read_message_id` on `channel_participants` - ---- - -## v0.24.0 — Auth Abstraction + User Identity ✅ - -Auth provider interface decoupling builtin auth from the handler/middleware -layer. User model extended with `auth_source`, `external_id`, `handle`. -@mention resolution upgraded to use handles. Zero behavior change for -existing users — builtin mode goes through the new abstraction identically. - -See [DESIGN-0.24.0.md](DESIGN-0.24.0.md) for the full spec including -v0.24.1–v0.24.3 subversions. - -Depends on: v0.23.0 (channel_participants), v0.16.0 (groups), v0.9.4 (vault). - -**Auth Provider Interface (`server/auth/`):** -- [x] `Provider` interface: `Mode()`, `Authenticate()`, `SupportsRegistration()`, `Register()` -- [x] `BuiltinProvider`: extracts login/register from `handlers/auth.go` -- [x] `UniqueHandle()`: collision-safe handle generation with `-2`, `-3` suffixes -- [x] `ErrorToHTTPStatus()`: maps auth errors to HTTP codes -- [x] `ParseMode()`: validates `AUTH_MODE` string with error return - -**Auth Handler Refactor:** -- [x] `AuthHandler` gains `provider auth.Provider` field -- [x] `Login()` delegates to `provider.Authenticate()`, then vault unlock + JWT -- [x] `Register()` delegates to `provider.Register()`, then vault init + JWT -- [x] `generateTokens()` response includes `handle`, `auth_source` -- [x] `BootstrapAdmin()` / `SeedUsers()` backfill handles for existing users - -**User Model Extension:** -- [x] `AuthSource` (builtin/mtls/oidc), `ExternalID` (nullable), `Handle` (unique) -- [x] All user store queries (PG + SQLite) include three new columns -- [x] `GetByHandle()`, `GetByExternalID()` on both store implementations -- [x] Unified `scanOneUser` / `scanOne` helpers replace per-method scan blocks - -**Config + Middleware:** -- [x] `AUTH_MODE` env var (default `builtin`) -- [x] `main.go` dispatch: builtin wired, mTLS/OIDC fail-fast at startup -- [x] Middleware unchanged — JWT validation is auth-mode-agnostic - -**@mention Resolution:** -- [x] `resolveMention()` uses `LOWER(handle)` instead of `LOWER(username)` -- [x] `SearchUsers` returns `handle`, filters on handle -- [x] Frontend autocomplete matches on handle, uses handle as @mention token - -**Migration:** -- [x] 018_auth_abstraction: `auth_source`, `external_id`, `handle` columns + unique indexes + backfill - ---- - -## v0.24.1 — mTLS + OIDC Providers ✅ - -Two external auth implementations plugging into v0.24.0's provider interface. -Keycloak integration test environment via docker-compose overlay. - -Depends on: auth abstraction (v0.24.0). -See [DESIGN-0.24.0.md](DESIGN-0.24.0.md) §v0.24.1 for full spec. - -- [x] mTLS provider: trusted header extraction, cert DN parsing, auto-provision -- [x] OIDC provider: well-known discovery, JWKS caching, token validation, claim extraction -- [x] OIDC authorization code flow: login redirect, code exchange, token handoff via fragment -- [x] Split-horizon issuer support (`OIDC_EXTERNAL_ISSUER_URL` for Docker/K8s) -- [x] OIDC claim → group mapping (external groups sync to internal groups with `source=oidc`) -- [x] Login page adapts by `AUTH_MODE` (form / cert status / SSO button) -- [x] Keycloak docker-compose overlay with pre-configured realm (`ci/keycloak-realm.json`) -- [x] `QArgs()` dialect adapter: handles Postgres `$N` reuse for SQLite arg expansion -- [x] `database.QueryRow/Query/Exec` wrappers with automatic arg expansion -- [x] Migration 019: `oidc_auth_state` table, `groups.source` column -- [x] Unit tests: mTLS (ParseDN, config), OIDC (discovery, auth URL, mode), QArgs (5 tests) -- [x] Regression test: `TestIntegration_ChannelListWithTypeFilter` -- [x] SQLite local dev fixes: `_time_format` DSN, store wiring, nginx resolver, extension assets - ---- - -## v0.24.2 — Fine-Grained Permissions - -Groups become capability carriers. Permission model on top of existing -group infrastructure. - -Depends on: auth abstraction (v0.24.0). Parallel to v0.24.1. -See [DESIGN-0.24.0.md](DESIGN-0.24.0.md) §v0.24.2 for full spec. - -- [x] Permission constants (`domain.action` convention, code-level enum) -- [x] `permissions` JSONB column on groups -- [x] `ResolvePermissions()`: union of group permissions + Everyone group -- [x] `RequirePermission()` middleware -- [x] Token budgets: per-group daily/monthly ceilings, enforced in completion handler -- [x] Model access control: per-group model allowlists -- [x] Admin UI: permission checklist, budget fields, model picker on group edit -- [x] Everyone group supersedes `DefaultUserPerms` (seeded in migration, editable in admin) -- [x] Migration 020: permissions, budgets, allowed_models on groups - ---- - -## v0.24.3 — Anonymous / Session Participants - -Unauthenticated visitors for workflow intake channels. Direct -prerequisite for v0.26.0 (Workflow Engine). - -Depends on: mTLS provider (v0.24.1) for cert-based anonymous identity. -See [DESIGN-0.24.0.md](DESIGN-0.24.0.md) §v0.24.3 for full spec. - -- [x] `session_participants` table (channel-scoped, ephemeral token) -- [x] `AuthOrSession()` middleware: tries JWT first, falls back to session cookie (cookie-based, no separate session JWT needed) -- [x] Capability scoping: session participants limited to their bound channel -- [x] mTLS anonymous path: cert fingerprint as stable session identity -- [x] `channels.allow_anonymous` flag -- [x] Session added as channel participant (`participant_type=session`, `role=visitor`) -- [x] Workflow entry page: `GET /w/:id` (Go template, standalone chat UI) -- [x] Migration 021: `session_participants` table, `allow_anonymous` column - ---- - -## v0.25.0–v0.25.4 — Dynamic Surfaces + Hardening ✅ - -Pane-based surface architecture replacing the "one surface active at a -time" model (v0.21.3). Manifest-driven surface registration, component -extraction, admin/settings promoted to full-page surfaces. Editor -surface rebuilt as proof-of-concept. Five patch releases for integration -fixes, drag-resize, UI bugs, and admin/settings surface hardening. - -Depends on: Go template engine (v0.22.5), ChatPane component (v0.22.7), -surface integration (v0.22.8). See [DESIGN-SURFACES.md](DESIGN-SURFACES.md) -for the layer model. - -See [DESIGN-0.25.0.md](DESIGN-0.25.0.md) for full spec. - -**Pane System** ✅ -- [x] Pane container: workspace area holds 1+ panes side-by-side (replaces single-surface model) -- [x] Pane lifecycle: create, mount, resize, minimize, destroy — independent of surface lifecycle -- [x] Resizable splits: drag handles between panes, user-resizable -- [x] Layout presets: single (default), split-2 (editor+chat), split-3 (tree+editor+chat) -- [x] Default: single Chat pane — zero cognitive overhead, identical to current UX -- [x] Unified drag-resize primitive (`drag-resize.js`): overlay prevents iframe/CM6 event swallowing, touch support (v0.25.2) - -**Surface Manifest + Registration** ✅ -- [x] Surface manifest schema: `id`, `route`, `title`, `components`, `data_requires`, `script`, `auth` -- [x] Dynamic route generation in page engine from registered surfaces -- [x] Data loader registry: surfaces declare data requirements, engine assembles loaders -- [x] Core surfaces (chat, admin, settings, editor, notes) on manifest pattern -- [x] `window.__PAGE_DATA__` injection from declared `data_requires` -- [x] `surface_registry` table — admin can enable/disable surfaces (Migration 022) -- [x] `/s/:slug` route namespace for extension/dynamic surfaces (shipped v0.27.0) - -**Component Extraction** ✅ -- [x] FileTree: extracted from `editor-mode.js`, standalone component with Go template partial + JS hydration -- [x] CodeEditor: CM6 `codeEditor()` factory formalized as component -- [x] NoteEditor: extracted from `notes.js`, standalone component -- [x] ModelSelector: extracted from chat surface, reusable across surfaces -- [x] UserMenu: extracted as standalone component -- [x] Each component: Go template partial (server shell) + JS class (hydration) + CSS (scoped) - -**Editor Surface Rebuild (Dog Food)** ✅ -- [x] Editor surface rebuilt on pane system: file tree pane + code editor pane + ChatPane (assist) -- [x] Surface manifest declares three panes with resizable splits -- [x] File tree pane: workspace_ls backed, context menu, nested directories -- [x] Code editor pane: CM6 with language detection, tab bar, Ctrl+S save -- [x] Chat pane: `ChatPane.create()` in assist role — same channel context, workspace-scoped tools -- [x] Replaces broken `editor-mode.js` (48K, dead since v0.22.6 code pruning) - -**Admin + Settings Surfaces** ✅ -- [x] Admin surface at `/admin/:section` — full-page, category tabs, section scaffold -- [x] Settings surface at `/settings/:section` — full-page, left nav, section-specific templates -- [x] Settings export/import (versioned JSON envelope) (v0.25.4) -- [x] Bulk model visibility (Show All / Hide All) (v0.25.4) -- [x] Workspace rename/delete (v0.25.4) - -**CSS Decomposition** ✅ -- [x] Monolithic `styles.css` → 13 files: variables, layout, primitives, modals, chat, panels, surfaces, splash, pane-container, chat-pane, user-menu, tool-grants, admin-surfaces - -**Persona Tool Grants UI** ✅ -- [x] Persona create/edit UI: "Tools" section — multi-select checklist grouped by category -- [x] `loadToolList()` fetches from `/api/v1/tools`, `loadToolGrants()` reads per-persona grants -- [x] Completion handler applies `GetToolGrants()` as second-pass allowlist (`completion.go:928`) -- [x] Version snapshot includes persona tool grants at snapshot time (`workflows.go:263`) - -**Bug Fixes (v0.25.3–v0.25.4)** -- [x] Theme system mode (system preference → dark fallback) -- [x] Resize bar jump-on-click (inline width pin on start) -- [x] Debug modal styling (modal-content → modal class) -- [x] Settings/admin back-button navigation (sessionStorage return URL) -- [x] Profile save (wrong element IDs, wrong API routes, empty user context) -- [x] Scale slider range (120→175) and commit-on-release -- [x] Admin surface: 6 nav/wiring fixes (stray `>`, dual-bind, nav flash, history, storage, back URL) -- [x] BYOK/Personas toggles (policies store mismatch) -- [x] Group permissions 500 (Postgres SetNull argIdx misalignment) -- [x] Settings surface init (missing policies/models fetch, handler wiring) -- [x] User settings NULL/null/array corruption (COALESCE + jsonb_typeof guard) -- [x] Stale modal HTML cleanup (320 lines removed from chat surface) -- [x] Team admin modal layout/wiring, persona create button -- [x] Project files admin bypass -- [x] Multi-provider live test failover - -**Shared Surface Routes** — partially shipped -- [x] `/admin/:section`, `/settings/:section` — full-page surface routes -- [ ] `/s/editor/:wsId`, `/s/article/:wsId/:path` — TBD (editor uses `/editor/:wsId` today) -- [ ] `/p/:id` — shared project view — TBD - -**Shipped in v0.26.0:** -- [x] Context-Aware Tool System (`ToolContext`, `Require` predicates, `AvailableFor()`) -- [x] `ExecutionContext` extension (`WorkflowID`, `TeamID` fields) - -**Shipped (verified in code audit):** -- [x] Tool grant enforcement in completion handler (`completion.go:928`) -- [x] Workflow version snapshot includes tool grants (`workflows.go:263`) - -**Moved to v0.27.0:** -- [x] ~~Extension surface route namespace (`/s/:slug`)~~ → v0.27.0 Phase 1 - -**Migration:** -- [x] 022_surfaces.sql: `surface_registry` table (UUID PK, unique surface_id, is_enabled, is_core) - ---- - -## ✅ v0.26.0 — Workflow Engine - -Team-owned, stage-based process execution. The channel is the runtime, -personas drive each stage, and the existing tool/notes infrastructure -handles structured data collection. See [DESIGN-0.26.0.md](DESIGN-0.26.0.md) -for full spec and [CHANGELOG-0.26.0.md](../CHANGELOG-0.26.0.md) for detailed -release notes. - -**Shipped (v0.26.0–v0.26.5):** -- [x] Session cleanup job (background goroutine, `SESSION_EXPIRY_DAYS`) -- [x] `ToolContext` + `Require` predicates + `BaseTool` embed + `AvailableFor()` -- [x] `ExecutionContext` TeamID wiring -- [x] Workflow definitions: `workflows`, `workflow_stages`, `workflow_versions` (migration 023) -- [x] Full CRUD + slug generation + publish + version snapshot -- [x] Workflow instances: `workflow_id`, `current_stage`, `stage_data`, `workflow_status` on channels (migration 024) -- [x] Start/advance/reject/status endpoints -- [x] Staleness sweep (background goroutine, `WORKFLOW_STALE_HOURS`) -- [x] Visitor landing page (`/w/:id/:slug`, branded, session resume) -- [x] Visitor start flow (`POST /api/v1/workflow-entry/:scope/:slug`) -- [x] `workflow_advance` tool (`RequireWorkflow` predicate, AI-triggered) -- [x] Form template → system prompt injection in completion handler -- [x] Stage notes (persisted as channel-scoped notes) -- [x] `workflow_assignments` table + claim/complete/list endpoints (migration 025) -- [x] Admin builder UI (Workflows category tab, CRUD, stage editor) -- [x] Queue sidebar section (badge count, claim/complete) -- [x] Route registration test (`TestRouteRegistration`) -- [x] Workflow CRUD test (`TestWorkflowCRUD`, `TestWorkflowValidation`) - -**Shipped in v0.27.0 Phase 2 (Workflow Polish):** -- [x] Team-scoped workflow management UI (Settings → Workflows section) -- [x] Drag-and-drop stage reorder in builder (`_wfWireStageDnD()`) -- [x] `on_complete` workflow chaining (`triggerOnComplete()` in workflow_instances.go) -- [x] Workflow retention enforcement (staleness sweep extended) -- [x] Stage persona auto-switch in chat UI (via `workflow.advanced` WS event) -- [x] Round-robin auto-assignment (`tryRoundRobin()` in workflow_instances.go) -- [x] Assignment notifications via WebSocket (`workflow.assigned`, `workflow.claimed`) -- [x] Channel header stage indicator + advance/reject controls - -**Shipped in v0.27.0 Phase 1:** -- [x] Extension surface routes (`/s/:slug` namespace — `RenderExtensionSurface()`) - -**Shipped (verified in code audit):** -- [x] Persona tool grant enforcement in completion handler (`completion.go:928`) - ---- - -## v0.27.0 — Debt Clearance - -Extension surface routes, workflow engine polish, workspace/editor debt. -Depends on: workflow engine (v0.26.0), dynamic surfaces (v0.25.0). - -See [DESIGN-0.27.0.md](DESIGN-0.27.0.md) for full spec. - -**Phase 1: Extension Surface Routes (`/s/:slug`)** ✅ -- [x] `surface-extension` Go template (HTML + CSS + scripts blocks) -- [x] `base.html` conditional chain extended with `{{if .Manifest}}` fallthrough -- [x] `RenderExtensionSurface()` catch-all handler — runtime DB lookup, no restart needed -- [x] nginx location block for `/surfaces/` static assets (unified + split deployment) -- [x] Frontend nav renders extension surfaces from DB query at render time -- [x] Admin surfaces section: upload/enable/disable/uninstall (API + UI wired) -- [x] Sample `.surface` archive (`hello-dashboard`) for testing -- [x] CSP nonce propagation for extension scripts -- [x] `EXTENSION-SURFACES.md` authoring guide (manifest, API, CSS properties, admin API) -- [x] `EXTENSIONS.md` §6 rewritten to reference new authoring guide - -**Phase 2: Workflow Engine Polish (v0.26.0 debt)** ✅ -- [x] Channel header workflow stage indicator + advance/reject controls -- [x] Stage persona auto-switch in chat UI (via `workflow.advanced` WS event → stage bar refresh) -- [x] Assignment notifications via WebSocket (`workflow.assigned`, `workflow.claimed`) -- [x] Round-robin auto-assignment (per-stage `transition_rules.auto_assign`) -- [x] `on_complete` workflow chaining (`triggerOnComplete()` — slug lookup, data mapping, channel creation) -- [x] Workflow retention enforcement (staleness sweep extended — archive/delete by policy) -- [x] Drag-and-drop stage reorder in workflow builder UI (`_wfWireStageDnD()`) -- [x] Team-scoped workflow management UI (Settings → Workflows section) - -**Phase 3: Workspace + Editor Debt** (partial) -- [x] `.gitignore` respect in workspace indexing (`workspace/fs.go` — Reconcile walk, `loadGitignore`, `matchesGitignore`) -- [x] Pane state persistence per-user/per-project (debounced sync to `user_settings` API, cross-device restore) -- [ ] Workspace settings UI: git config section — deferred (broader scope, needs vault-encrypted git credentials) -- [ ] Editor drag-drop file reorder — deferred (greenfield, no existing DnD in file-tree.js) - ---- - -## v0.27.1 — Tasks Foundation: Service Channels + Scheduler ✅ - -Core primitive for autonomous agents: a channel with no human -participant, driven by a cron scheduler. -Depends on: v0.27.0 (debt clearance). - -- [x] `tasks` + `task_runs` tables + store interface + PG (UUID) / SQLite implementations -- [x] `type: 'service'` channel type (PG CHECK extended, SQLite by convention) -- [x] `TaskStore` interface: CRUD, `ListDue`, `SetNextRun`, `IncrementRunCount`, run history -- [x] `TaskScheduler` background goroutine (30s poll, skip-if-running, one-shot + cron) -- [x] Minimal cron parser (hourly, N-minute intervals, daily H:M, weekly DOW patterns) -- [x] Service channel creation per task (reuse `output_channel_id` on subsequent runs) -- [x] User prompt persisted as message in service channel -- [x] Task CRUD handler with ownership checks + admin bypass -- [x] `POST /tasks/:id/run` — manual "Run Now" trigger -- [x] Migration 026: `tasks` + `task_runs` tables, `service` channel type - -**Deferred to v0.27.2:** -- [ ] Completion invocation from scheduler (wire into `streamWithToolLoop`) -- [ ] Full cron parsing via `robfig/cron/v3` (replacing minimal parser) -- [ ] Provider resolution for task context (BYOK → team → global → routing policy) -- [ ] Workflow task execution (instantiate workflow in service channel) -- [ ] Tasks sidebar section (read-only view of service channels) - -**API routes (v0.27.1):** -- `GET /api/v1/tasks` — list my tasks -- `POST /api/v1/tasks` — create task -- `GET /api/v1/tasks/:id` — get task -- `PUT /api/v1/tasks/:id` — update task -- `DELETE /api/v1/tasks/:id` — delete task -- `GET /api/v1/tasks/:id/runs` — run history -- `POST /api/v1/tasks/:id/run` — manual trigger -- `GET /api/v1/admin/tasks` — list all tasks (admin) - ---- - -## v0.27.2 — Task Execution: Budgets + Admin Controls ✅ - -Guardrails for unattended execution. -Depends on: v0.27.1 (task scheduler). - -- [x] Execution budget enforcement: `max_tokens`, `max_tool_calls`, `max_wall_clock` -- [x] ~~`task_runs` table + store~~ (shipped v0.27.1 — table, PG+SQLite store, run history API) -- [x] Budget breach → `budget_exceeded` status + owner notification -- [x] ~~Concurrent run skip~~ (shipped v0.27.1 — scheduler skips if `GetActiveRun` returns non-nil) -- [x] Global config: `tasks.enabled`, `tasks.allow_personal`, `tasks.max_concurrent` -- [x] Default budget ceilings in global config (overridable per task) -- [x] `tasks.create` and `tasks.admin` permissions -- [x] Admin panel → Tasks section (CRUD, history, kill switch, budget config) -- [x] Completion invocation from scheduler (headless executor via `CoreToolLoop`) -- [x] Full cron parsing via `robfig/cron/v3` (replacing minimal parser) -- [x] Provider resolution for task context (BYOK → team → global → routing policy) -- [x] Kill switch: `POST /tasks/:id/kill` cancels active run - ---- - -## v0.27.3 — Task Chaining: Webhooks + Workflow-to-Workflow ✅ - -External integration and multi-workflow orchestration. -Depends on: v0.27.2 (task budgets). - -- [x] Completion webhooks (POST to external URL on task/workflow completion) -- [x] Webhook retry (3 attempts, exponential backoff) + HMAC signature -- [x] `task_create` tool (AI spawns sub-tasks, RequireWorkflow/RequireTeam predicate, depth limit) -- [x] `on_complete` chaining via task system (completed workflow → one-shot task → target workflow) -- [x] Webhook secret generation per task/workflow -- [x] Bug fix: `workflow_advance` tool now triggers `on_complete` chaining on completion - ---- - -## v0.27.4 — Personal Tasks: BYOK Scheduling + User Task UI ✅ - -User-facing task experience. Personal tasks run against BYOK providers. -Depends on: v0.27.2 (admin controls, `tasks.allow_personal`). - -- [x] Settings → Tasks section (CRUD, schedule builder, budget config) -- [x] Schedule builder: preset crons + custom 5-field cron + timezone selector -- [x] BYOK provider routing for personal tasks -- [x] `tasks.personal_require_byok` config key (prevent fallthrough to org providers) -- [x] Output modes: channel (default), note, webhook -- [x] Task notification booleans: `notify_on_complete` (default false), `notify_on_failure` (default true) -- [x] Tasks sidebar section with status indicators (✓/⏳/▶) -- [x] "Run Now" button for manual trigger -- [x] 5 starter templates (news digest, standup prep, weekly summary, stock check, research digest) - ---- - ## v0.28.0 — Platform Polish -TBD pull-forward: high-value items whose dependencies are met post-tasks. +Audit arc, frontend SDK, and infrastructure improvements. -**Tier 1 — High value, dependencies met:** +### v0.28.1 — Surfaces ICD Audit ✅ +- [x] ICD `surfaces.md` corrected (6 discrepancies: field name, archive format, response shape) +- [x] Surface ID slug validation + `extractableRelPath` install hardening +- [x] 19 E2E surface CRUD tests in ICD runner (install, enable/disable, delete, error paths) +- [x] 22 handler-level tests + 14 store-level tests (PG + SQLite) +- [x] CI timeout 8m → 12m for PG integration tests + +### v0.28.2 — ICD Audit: Notifications + Profile + Knowledge + Notes + Providers +Known ICD report failures to investigate (likely envelope shape mismatches): +- [ ] `GET /settings` — `missing key "settings"` (profile) +- [ ] `GET /notifications/preferences` — `missing key "preferences"` +- [ ] `GET /workspaces` — `missing key "data"` +- [ ] `GET /git-credentials` — `missing key "data"` +- [ ] Notes, providers — passed clean but need full trace (route → handler → store → tests) +- [ ] WebSocket event contract audit: document `message.created`, `workflow.advanced`, + `presence.changed`, `typing`, `workflow.assigned` event shapes in ICD + +### v0.28.3 — Security Tier (ICD Runner Red Team) +New `security` tier in ICD test runner. Multi-user fixtures already exist. + +**Auth boundary:** +- [ ] JWT revocation: disable user via admin API, verify existing token is rejected + immediately. **This will likely expose a real bug** — JWT middleware validates + signature + expiry only, does not check `is_active` in DB. Fix: add `is_active` + check in auth middleware (DB lookup, cached with short TTL). +- [ ] Token reuse after `POST /auth/logout` — revoked refresh token must fail +- [ ] Expired token rejection (craft a token with past expiry) +- [ ] Role escalation: modify JWT claims client-side, attempt admin endpoints +- [ ] User ID substitution: swap `user_id` in request bodies for another user's ID + +**Cross-tenant data access:** +- [ ] User A's token → GET user B's notes, memories, workspaces, BYOK configs, task runs +- [ ] Team member → access other team's providers, personas, tasks +- [ ] Non-participant → read channel messages, files + +**Input validation:** +- [ ] Path traversal in surface install (covered by cs12, verify E2E) +- [ ] SQL injection in search endpoints (notes, users, channels) +- [ ] XSS payloads in channel titles, note content, persona names (round-trip) +- [ ] Oversized request bodies, malformed JSON + +**Session security:** +- [ ] Visitor session → access channels outside bound workflow +- [ ] Visitor → escalate to authenticated user via crafted headers +- [ ] Cross-visitor isolation: visitor A's cookie cannot read visitor B's messages + +### v0.28.4 — Infrastructure - [ ] Virtual scroll for long conversations (prerequisite for heavy task output channels) - [ ] KB auto-injection: top-K chunk prepend, context budget aware, per-channel toggle - [ ] Helm chart (replaces raw k8s manifests, `helm install switchboard ./chart`) -- [ ] Per-provider model preferences — finalize: make `provider_config_id` required on `PUT /models/preferences` (fixes NULL-in-UNIQUE dedup bug), migrate `/models/enabled` and `/models/preferences` to `{"data": [...]}` envelope, add integration test coverage (currently zero for preference endpoints) +- [ ] Per-provider model preferences — finalize: make `provider_config_id` required on + `PUT /models/preferences` (fixes NULL-in-UNIQUE dedup bug), migrate `/models/enabled` + and `/models/preferences` to `{"data": [...]}` envelope, add integration test coverage +- [ ] Git credentials settings UI: vault-encrypted per-user credentials, SSH key + management, per-workspace remote config. Table exists (`git_credentials`), vault + pattern exists — needs settings section and CRUD handler. -**Tier 2 — Medium value:** +### v0.28.5 — Frontend SDK +`switchboard-sdk.js` — composition layer over existing globals. Surface +authors consume a single coherent API instead of hunting through 15 JS files. + +- [ ] `Switchboard.init({ mount })` — idempotent boot: tokens, profile, theme, events, user menu +- [ ] `sw.user`, `sw.isAdmin` — resolved identity +- [ ] `sw.api.get()` / `sw.api.post()` — authenticated REST, no token/base-path management +- [ ] `sw.on(event, fn)` — WebSocket subscription (no `Events` global knowledge) +- [ ] `sw.chat(container, opts)` — drop-in ChatPane (wraps `ChatPane.create()`) +- [ ] `sw.notes(container, opts)` — drop-in notes component +- [ ] `sw.toast()`, `sw.confirm()` — UI primitives +- [ ] `sw.theme.current`, `sw.theme.on('change', fn)` — theme queries +- [ ] ICD runner gains `sdk` test tier: validates init, component mounting, event hooks +- [ ] Absorbs cs15 UserMenu band-aid — universal hydration moves into `init()` + +**Tier 2 — Medium value (pull into any v0.28.x):** - [ ] Memory compaction: summarize old memories, confidence decay, prune low-confidence - [ ] `capability_match` routing policy ("cheapest model with tool_calling") - [ ] New provider types registrable via config file (OpenAI-compatible + custom schema) --- -## v0.29.0 — Starlark Extensions: Permissioned Server-Side Plugins +## v0.29.0 — Starlark Sandbox + Permission Model + +Server-side extension runtime. Prove the eval loop, permission pipeline, +and admin UI before adding capabilities. -Server-side extension runtime with capability-gated module access. Depends on: v0.28.0 (platform polish), extension infrastructure (v0.11.0). -Manifest declares requested permissions (flatpak/Android model): -```json -{ - "permissions": ["http", "store.read", "files.read", "notifications"] -} -``` - -Admin reviews and grants/revokes per-permission. Runtime enforces: -only approved modules are injected into the Starlark sandbox. - - [ ] `go.starlark.net` integration (eval loop, timeout, memory ceiling) - [ ] Permission model: manifest declarations, admin grant/revoke, DB schema (`extension_permissions` table: extension_id, permission, granted, granted_by) -- [ ] Privileged module library: - - `http` — outbound HTTP requests (allowlist/blocklist per extension) - - `store` — key/value read/write (extension-namespaced, quota-limited) - - `files` — project-scoped file read/write - - `notifications` — emit notifications to users - - `secrets` — vault-backed per-extension secret storage - [ ] Runtime enforcement: sandbox loader checks granted permissions, injects only approved modules. Denied permissions → clean error, not silent no-op. - [ ] Admin UI: permission review on install, per-extension grant/revoke toggle, audit log of permission changes -- [ ] Server-side tool execution in completion handler (Starlark tools run - server-side, browser tools run client-side — unified tool schema) -- [ ] Extension lifecycle: `install → pending_review → approved → active` +- [ ] Extension lifecycle states: `install → pending_review → approved → active` +- [ ] Initial modules: `secrets` (vault-backed per-extension), `notifications` + (emit to users) - [ ] Migration: `extension_permissions` table - [ ] ICD: update `extensions.md` with Starlark-specific endpoints -- [ ] Multi-file asset routing: `ServeExtensionAsset` currently ignores `*path` param - and returns inline `_script` for all requests. Starlark extensions need multi-file - bundles (source + manifest + static assets). Implement path-based lookup or - replace inline `_script` with filesystem/blob serving. -- [ ] Deduplicate tool schema extraction: `ListBrowserToolSchemas` (extensions.go) and - `ListTools` (completion.go) both independently iterate user extensions, parse - manifests, and extract tool schemas. Unify into a shared helper that handles - both browser and server-side tool types. The completion handler also has a - `hub.IsConnected` guard that the extension handler lacks — reconcile. + +--- + +## v0.29.1 — API Extensions + +Starlark route handlers. Surfaces can serve custom JSON endpoints. + +Depends on: v0.29.0 (sandbox + permissions). + +- [ ] `api_routes` manifest key: `[{"method": "GET", "path": "/data", "handler": "handlers.list"}]` +- [ ] Routes mounted at `/s/{surface_id}/api/...`, auth context injected +- [ ] Starlark request/response primitives (headers, body, status) +- [ ] `http` outbound module: allowlist/blocklist per extension +- [ ] `requires_provider` manifest key: extensions declare provider capability + needs (e.g. `"image_generation"`). Platform resolves via existing provider + chain (BYOK → team → global → routing policy). Starlark `provider` module + makes calls on behalf of extension — raw API keys never exposed to sandbox. +- [ ] Server-side tool execution in completion handler (Starlark tools run + server-side, browser tools run client-side — unified tool schema) +- [ ] Multi-file asset routing: `ServeExtensionAsset` path-based lookup + (replaces inline `_script` for all requests) +- [ ] Deduplicate tool schema extraction: unify `ListBrowserToolSchemas` and + `ListTools` into shared helper handling both browser and server-side tools + +--- + +## v0.29.2 — DB Extensions + +Namespaced tables for extension data. Create-only (no migrations yet). + +Depends on: v0.29.1 (API extensions — handlers need somewhere to persist). + +- [ ] Namespaced tables: `ext_{surface_id}_*` prefix enforced by platform +- [ ] Declarative schema in manifest: `"tables": [{"name": "items", "columns": [...]}]` + Platform generates dialect-correct DDL (PG + SQLite) +- [ ] `db` Starlark module: `query()`, `exec()` scoped to extension tables + declared views +- [ ] Views as read contract: extension declares views over platform tables with + explicit column allowlist — never direct access to platform tables +- [ ] SQL validation: `db` module rejects references to tables outside namespace +- [ ] Schema creation on install, drop on uninstall + +--- + +## v0.30.0 — Surface Packages + +The `.surface` archive becomes the full-stack packaging unit. + +Depends on: v0.29.2 (DB extensions). + +- [ ] Install creates schema + mounts routes + serves assets in one operation +- [ ] Schema versioning: `"schema_version": N` in manifest +- [ ] Schema migrations: `"migrations": [{"from": 1, "to": 2, "sql": "..."}]` +- [ ] Settings extension point: surfaces declare settings sections in manifest, + platform settings surface renders them alongside core sections +- [ ] Export/import format for sharing surfaces across instances +- [ ] Surface marketplace (discovery, not hosting — instances pull from URLs) + +--- + +## v0.31.0 — Editor Surface Package + +Rebuild the editor as an installable surface package. Zero platform +special-casing — same `POST /admin/surfaces/install`, same manifest, +same `/s/:slug` route as any third-party surface. If `pages.go` or +`main.go` need changes to support it, the platform abstraction is +wrong, not the editor. + +Validates the full v0.29.x–v0.30.0 stack E2E. + +Depends on: v0.30.0 (surface packages with settings extension point). + +**Platform primitives consumed (not owned):** +- CM6 (core UI primitive — chat input, notes, code blocks all use it) +- Workspace tools (`workspace_ls`, `workspace_read`, `workspace_write`) +- Git tools + credentials (v0.28.0 — core settings, vault-encrypted) +- Provider resolution via `requires_provider` (v0.29.1) + +**Surface package delivers:** +- [ ] Editor `.surface` archive: manifest, JS, CSS, Starlark handlers +- [ ] Built separately, installed via admin API — follows exact same + patterns as any other surface +- [ ] Editor settings section (via v0.30.0 settings extension point): + keybindings (vim/emacs/standard), font size, editor theme +- [ ] Editor state persistence via `ext_editor_*` tables: open tabs, + cursor positions, split layout, per-workspace config +- [ ] File tree, tab bar, multi-pane layout — all via surface JS, + consuming platform CSS custom properties +- [ ] Markdown preview (improved renderer, shared with notes surface) +- [ ] Remove editor from core: delete `surface-editor` template, + `"editor"` manifest in `pages.go`, `"editor"` data loader, + `editor-surface.js` --- @@ -1080,14 +256,28 @@ only approved modules are injected into the Starlark sandbox. Items that are real but don't yet have a version assignment. Pull left based on need. -**Extension System — Additional Extensions** -- Image generation tool (browser or sidecar, provider-agnostic) -- STT/TTS (browser extension, Web Speech API — personal use case) -- Code execution sandbox (server-side, container isolation) +**Surfaces + Editor** +- Article drag-to-reorder outline sections +- Article-specific AI tools: suggest_outline, expand_section, check_citations +- Mobile: mode selector collapses to hamburger/bottom nav +- Surface IDE: built-in surface for building surfaces (Go template editor, + JS/CSS editor, live preview in sandboxed region) +- Project-bound surface/pane defaults: project config specifies which panes + are available and default layout -**Extension System — Server Tiers** -- ~~Starlark runtime integration (Tier 1 — server sandbox)~~ → scheduled v0.29.0 -- ~~Server-side tool execution in completion handler~~ → scheduled v0.29.0 +**Workspace + Git** +- Integration tests: clone, commit, push/pull cycle (requires git binary in CI) + +**Provider Health + Routing** +- Latency-aware routing: track response time percentiles, prefer faster providers +- Cost-aware routing with budget ceiling per request +- Provider profile editor: key-value config per provider type, preview of effective settings +- Fallback chain visualizer: drag-to-reorder provider priority per model family + +**Extensions** +- Image generation tool (browser or sidecar, provider-agnostic) +- STT/TTS (browser extension, Web Speech API) +- Code execution sandbox (server-side, container isolation) - Sidecar HTTP tool protocol (Tier 2 — container isolation) **Desktop + Mobile** @@ -1100,52 +290,37 @@ based on need. - ChatGPT/other tool import - GDPR-style "download my data" - Backup/restore CronJob manifests +- Admin settings team/user export (user export blocked by vault-encrypted + BYOK keys; team export needs merge-vs-replace semantics) **UX / Multi-Seat** -- "Group" scope badge on model selector / KB list: show access-source annotation so users see _why_ they have access (global, team, group grant). Requires backend to include `access_source` in list responses. -- ~~Per-provider model preferences~~ → scheduled v0.28.0. DB unique key is already `(user_id, model_id, provider_config_id)` and the store/API accept the column. **Remaining issue:** `provider_config_id` is nullable in the handler — NULL ≠ NULL in both PG and SQLite, so omitting it creates non-deduplicable entries. Fix: make `provider_config_id` required on write. Frontend composite key (`configId:modelId`) already works correctly. -- Git credentials store: vault-encrypted per-user git credentials (similar to BYOK pattern). Git provider abstraction (GitHub, GitLab, Gitea). Clone/pull/push handlers. Natural fit for extension sidecar tier since git operations are long-running. -- Admin settings team/user export: v0.25.4 ships admin-only settings export/import. User export blocked by vault-encrypted BYOK keys (can't round-trip). Team export needs merge-vs-replace semantics for member lists. +- "Group" scope badge on model selector / KB list: access-source annotation + so users see _why_ they have access (global, team, group grant) +- Multi-tenant SaaS mode +- Plugin/extension marketplace -**Projects — Future** -- Project-specific files: full project-level upload (own endpoint, storage path, UI surface — not just channel attachment re-linking) -- Project templates: create new projects from predefined configurations (persona, KBs, system prompt) -- Project creation dialog: replace `prompt()` with proper modal (name, description, persona, KB picker) -- Admin-level project management: cross-instance visibility, reassign ownership, enforce team policies (scope: enterprise only, BYOK personal projects stay private) -- Sub-projects / nested hierarchy: child inherits parent KBs, system prompt, persona (deferred until usage patterns clarify need vs tags/labels) +**Projects** +- `/p/:id` — shared project view +- Project-specific files: full project-level upload (own endpoint, storage path) +- Project templates: create from predefined configurations +- Project creation dialog: replace `prompt()` with proper modal +- Admin-level project management: cross-instance visibility, ownership reassign +- Sub-projects / nested hierarchy -**Knowledge Bases — Future** -- ~~KB auto-injection~~ → scheduled v0.28.0 +**Knowledge Bases** - Hybrid search: combine vector similarity with full-text `tsvector`, re-rank - Semantic chunking: embedding-based boundary detection for smarter splits - HNSW index: better query performance than IVFFlat for large datasets - Web scraping source: ingest URLs as KB documents (extends url_fetch) - Scheduled re-indexing: periodic rebuild when source documents update -- ~~Store cleanup: add `UpdateDocumentStorageKey()` to `KnowledgeBaseStore` interface~~ _(done in v0.17.0)_ - (currently uses direct `database.DB.ExecContext` in handler — works but bypasses store layer) -**Memory — Future** -- ~~Memory compaction~~ → scheduled v0.28.0 -- ~~Memory confidence decay~~ → scheduled v0.28.0 (combined with compaction) +**Memory** - Memory export/import: portable memory format across instances -- Cross-persona memory sharing (opt-in): e.g. "coding assistant" can read facts from "project manager" -- Memory analytics: dashboard showing what Personas are learning, memory growth trends +- Cross-persona memory sharing (opt-in) +- Memory analytics: dashboard showing what Personas are learning, growth trends **Platform** -- Rate limiting per user/team/tier (token budgets) -- ~~Provider health monitoring~~ → v0.22.0 + key rotation -- Multi-tenant SaaS mode -- Plugin/extension marketplace -- ~~Virtual scroll for long conversations~~ → scheduled v0.28.0 -- ~~SQLite backend option (single-user / dev)~~ → v0.17.1 -- ~~**Helm chart.**~~ → scheduled v0.28.0 - -~~**Pane Architecture (Workspace Container)**~~ → shipped in v0.25.0 - -~~**Surfaces as Extensions**~~ → core infrastructure shipped in v0.25.0. Marketplace, Surface IDE, and project-bound defaults remain future items: -- Surface IDE: built-in surface for building surfaces — Go template editor for server-rendered shells, JS/CSS editor for client behavior, live preview in sandboxed region -- Surface marketplace: share custom surfaces across instances (presentation mode, kanban, form builder, dashboard, etc.) -- Project-bound surface/pane defaults: project config specifies which panes are available and default layout +- Rate limiting per user/team/tier (token budgets beyond current group budgets) --- @@ -1170,18 +345,3 @@ and are a source of PG/SQLite divergence bugs. Known instances: - `knowledge/` handlers — direct `database.DB.ExecContext` for storage key updates Full sweep: grep for `database.DB.Exec`, `database.TestDB.Exec`, `DB.QueryRow` outside of `store/` packages. Move to store methods or use existing dialect-safe helpers. - ---- - -## v0.27.5 — Team Tasks ✅ - -Team-visible task management. Members can view, admins can CRUD. -Depends on: v0.27.4 (personal task UI). - -- [x] `GET /api/v1/teams/:teamId/tasks` — member-visible team task list -- [x] Team admin CRUD: create/update/delete/run/kill via team-scoped routes -- [x] `canAccessTask` / `canMutateTask` helpers — team member read, team admin write -- [x] Access check on `ListRuns` (was missing — any authenticated user could read run history) -- [x] Settings Tasks section shows team tasks alongside personal (with team badge) -- [x] Sidebar Tasks section includes team tasks (with team label) -- [x] `CreateTeamTask` handler — injects team scope on creation diff --git a/server/handlers/surface_store_test.go b/server/handlers/surface_store_test.go new file mode 100644 index 0000000..cb92ff8 --- /dev/null +++ b/server/handlers/surface_store_test.go @@ -0,0 +1,386 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/store" + postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" + sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" +) + +// surfaceStore returns the appropriate SurfaceRegistryStore for the +// current test database backend. +func surfaceStore() store.SurfaceRegistryStore { + if database.IsSQLite() { + return sqlite.NewSurfaceRegistryStore() + } + return postgres.NewSurfaceRegistryStore() +} + +// ── Store: Seed ───────────────────────────── + +func TestSurfaceStore_Seed_Basic(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + s := surfaceStore() + ctx := context.Background() + + manifest := map[string]any{"route": "/test", "scripts": []string{"app.js"}} + if err := s.Seed(ctx, "test-surface", "Test Surface", "core", manifest); err != nil { + t.Fatalf("Seed: %v", err) + } + + sr, err := s.Get(ctx, "test-surface") + if err != nil || sr == nil { + t.Fatal("Get after Seed: surface should exist") + } + if sr.ID != "test-surface" { + t.Errorf("ID: got %q, want %q", sr.ID, "test-surface") + } + if sr.Title != "Test Surface" { + t.Errorf("Title: got %q, want %q", sr.Title, "Test Surface") + } + if sr.Source != "core" { + t.Errorf("Source: got %q, want %q", sr.Source, "core") + } + if !sr.Enabled { + t.Error("newly seeded surface should be enabled") + } + route, _ := sr.Manifest["route"].(string) + if route != "/test" { + t.Errorf("Manifest route: got %q, want %q", route, "/test") + } +} + +func TestSurfaceStore_Seed_PreservesEnabledFlag(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + s := surfaceStore() + ctx := context.Background() + + // Seed, then disable + s.Seed(ctx, "my-surface", "v1", "core", map[string]any{"route": "/v1"}) + s.SetEnabled(ctx, "my-surface", false) + + // Re-seed with updated title and manifest + s.Seed(ctx, "my-surface", "v2", "core", map[string]any{"route": "/v2"}) + + sr, _ := s.Get(ctx, "my-surface") + if sr == nil { + t.Fatal("surface should exist after re-seed") + } + if sr.Title != "v2" { + t.Errorf("Title should be updated: got %q, want %q", sr.Title, "v2") + } + if sr.Enabled { + t.Error("Enabled should be preserved as false across re-seed") + } + route, _ := sr.Manifest["route"].(string) + if route != "/v2" { + t.Errorf("Manifest should be updated: got %q, want %q", route, "/v2") + } +} + +func TestSurfaceStore_Seed_NilManifest(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + s := surfaceStore() + ctx := context.Background() + + // Seed with nil manifest — should not panic + err := s.Seed(ctx, "nil-manifest", "Nil Manifest", "core", nil) + if err != nil { + t.Fatalf("Seed with nil manifest: %v", err) + } + + sr, _ := s.Get(ctx, "nil-manifest") + if sr == nil { + t.Fatal("surface should exist") + } +} + +// ── Store: List ───────────────────────────── + +func TestSurfaceStore_List_Empty(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + s := surfaceStore() + ctx := context.Background() + + surfaces, err := s.List(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + // Should return nil or empty slice, not an error + if surfaces != nil && len(surfaces) != 0 { + t.Errorf("empty DB should return 0 surfaces, got %d", len(surfaces)) + } +} + +func TestSurfaceStore_List_Order(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + s := surfaceStore() + ctx := context.Background() + + // Seed in reverse alphabetical order + s.Seed(ctx, "zebra", "Zebra", "extension", map[string]any{}) + s.Seed(ctx, "alpha", "Alpha", "core", map[string]any{}) + s.Seed(ctx, "beta", "Beta", "core", map[string]any{}) + + surfaces, err := s.List(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(surfaces) != 3 { + t.Fatalf("want 3 surfaces, got %d", len(surfaces)) + } + + // Ordered by source, then title: core first (Alpha, Beta), then extension (Zebra) + if surfaces[0].ID != "alpha" { + t.Errorf("first: got %q, want %q", surfaces[0].ID, "alpha") + } + if surfaces[1].ID != "beta" { + t.Errorf("second: got %q, want %q", surfaces[1].ID, "beta") + } + if surfaces[2].ID != "zebra" { + t.Errorf("third: got %q, want %q", surfaces[2].ID, "zebra") + } +} + +func TestSurfaceStore_List_IncludesDisabled(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + s := surfaceStore() + ctx := context.Background() + + s.Seed(ctx, "enabled-one", "Enabled", "core", map[string]any{}) + s.Seed(ctx, "disabled-one", "Disabled", "extension", map[string]any{}) + s.SetEnabled(ctx, "disabled-one", false) + + surfaces, err := s.List(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(surfaces) != 2 { + t.Fatalf("List should return all surfaces including disabled, got %d", len(surfaces)) + } +} + +// ── Store: ListEnabled ────────────────────── + +func TestSurfaceStore_ListEnabled(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + s := surfaceStore() + ctx := context.Background() + + s.Seed(ctx, "chat", "Chat", "core", map[string]any{}) + s.Seed(ctx, "editor", "Editor", "core", map[string]any{}) + s.Seed(ctx, "my-ext", "My Ext", "extension", map[string]any{}) + s.SetEnabled(ctx, "editor", false) + + ids, err := s.ListEnabled(ctx) + if err != nil { + t.Fatalf("ListEnabled: %v", err) + } + if len(ids) != 2 { + t.Fatalf("want 2 enabled IDs, got %d: %v", len(ids), ids) + } + + // Verify disabled one is excluded + for _, id := range ids { + if id == "editor" { + t.Error("disabled surface 'editor' should not appear in ListEnabled") + } + } +} + +func TestSurfaceStore_ListEnabled_Empty(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + s := surfaceStore() + ctx := context.Background() + + ids, err := s.ListEnabled(ctx) + if err != nil { + t.Fatalf("ListEnabled: %v", err) + } + if ids != nil && len(ids) != 0 { + t.Errorf("empty DB should return 0 enabled IDs, got %d", len(ids)) + } +} + +// ── Store: Get ────────────────────────────── + +func TestSurfaceStore_Get_NotFound(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + s := surfaceStore() + ctx := context.Background() + + sr, err := s.Get(ctx, "nonexistent") + if err != nil { + t.Errorf("Get nonexistent should return (nil, nil), got err: %v", err) + } + if sr != nil { + t.Error("Get nonexistent should return nil surface") + } +} + +func TestSurfaceStore_Get_ManifestRoundTrip(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + s := surfaceStore() + ctx := context.Background() + + manifest := map[string]any{ + "route": "/s/complex", + "scripts": []any{"app.js", "vendor.js"}, + "css": []any{"style.css"}, + "nested": map[string]any{"key": "value"}, + } + s.Seed(ctx, "complex", "Complex", "extension", manifest) + + sr, err := s.Get(ctx, "complex") + if err != nil || sr == nil { + t.Fatal("Get should return the surface") + } + + // Verify manifest round-trips correctly + got, _ := json.Marshal(sr.Manifest) + want, _ := json.Marshal(manifest) + if string(got) != string(want) { + t.Errorf("Manifest round-trip mismatch:\n got: %s\n want: %s", got, want) + } +} + +// ── Store: SetEnabled ─────────────────────── + +func TestSurfaceStore_SetEnabled_NotFound(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + s := surfaceStore() + ctx := context.Background() + + err := s.SetEnabled(ctx, "nonexistent", true) + if err == nil { + t.Error("SetEnabled on nonexistent surface should return error") + } +} + +// ── Store: Delete ─────────────────────────── + +func TestSurfaceStore_Delete_ExtensionOnly(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + s := surfaceStore() + ctx := context.Background() + + // Seed a core surface + s.Seed(ctx, "chat", "Chat", "core", map[string]any{}) + + // Try to delete it at the store level — should fail + err := s.Delete(ctx, "chat") + if err == nil { + t.Error("Delete of core surface should return error at store level") + } + + // Verify still exists + sr, _ := s.Get(ctx, "chat") + if sr == nil { + t.Error("core surface should survive Delete attempt") + } +} + +func TestSurfaceStore_Delete_Extension(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + s := surfaceStore() + ctx := context.Background() + + s.Seed(ctx, "my-ext", "My Extension", "extension", map[string]any{}) + + err := s.Delete(ctx, "my-ext") + if err != nil { + t.Fatalf("Delete extension: %v", err) + } + + sr, _ := s.Get(ctx, "my-ext") + if sr != nil { + t.Error("extension surface should be gone after Delete") + } +} + +func TestSurfaceStore_Delete_NotFound(t *testing.T) { + database.RequireTestDB(t) + database.TruncateAll(t) + + s := surfaceStore() + ctx := context.Background() + + err := s.Delete(ctx, "nonexistent") + if err == nil { + t.Error("Delete nonexistent should return error") + } +} + +// ── Handler + Store Integration: ListEnabled response shape ───── + +// This test verifies the exact JSON contract that the frontend depends on. +func TestSurface_ListEnabled_ResponseContract(t *testing.T) { + h := setupSurfaceHarness(t) + h.seedCoreSurface("chat", "Chat") + + w := h.jsonReq("GET", "/api/v1/surfaces", h.userToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d", w.Code) + } + + // Parse as raw JSON to verify exact shape + var raw map[string]json.RawMessage + json.Unmarshal(w.Body.Bytes(), &raw) + + surfacesJSON, ok := raw["surfaces"] + if !ok { + t.Fatal("response must have 'surfaces' key") + } + + var surfaces []map[string]interface{} + json.Unmarshal(surfacesJSON, &surfaces) + + if len(surfaces) != 1 { + t.Fatalf("want 1 surface, got %d", len(surfaces)) + } + + s := surfaces[0] + // Must have exactly id, title, route + expectedKeys := map[string]bool{"id": true, "title": true, "route": true} + for key := range s { + if !expectedKeys[key] { + t.Errorf("unexpected key in response: %q", key) + } + } + for key := range expectedKeys { + if _, ok := s[key]; !ok { + t.Errorf("missing expected key: %q", key) + } + } +} diff --git a/server/handlers/surfaces.go b/server/handlers/surfaces.go index 4668611..57f12c7 100644 --- a/server/handlers/surfaces.go +++ b/server/handlers/surfaces.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "regexp" "strings" "github.com/gin-gonic/gin" @@ -15,6 +16,10 @@ import ( "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 @@ -197,6 +202,13 @@ func (h *SurfaceHandler) InstallSurface(c *gin.Context) { 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" { @@ -213,21 +225,15 @@ func (h *SurfaceHandler) InstallSurface(c *gin.Context) { if f.FileInfo().IsDir() { continue } - // Only extract js/, css/, assets/ directories - name := f.Name - // Strip leading directory if archive has one (e.g., "my-surface/js/foo.js" → "js/foo.js") - if idx := strings.Index(name, "/"); idx > 0 { - rest := name[idx+1:] - if strings.HasPrefix(rest, "js/") || strings.HasPrefix(rest, "css/") || strings.HasPrefix(rest, "assets/") { - name = rest - } else if strings.HasPrefix(name, "js/") || strings.HasPrefix(name, "css/") || strings.HasPrefix(name, "assets/") { - // Already relative - } else { - continue // Skip non-static files (templates, manifest) - } + + // 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, name) + destPath := filepath.Join(destDir, relPath) // Security: prevent path traversal if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) { @@ -267,6 +273,44 @@ func (h *SurfaceHandler) InstallSurface(c *gin.Context) { }) } +// 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) { diff --git a/server/handlers/surfaces_test.go b/server/handlers/surfaces_test.go new file mode 100644 index 0000000..aca0e18 --- /dev/null +++ b/server/handlers/surfaces_test.go @@ -0,0 +1,604 @@ +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) + } + + 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)) + userGroup.GET("/surfaces", surfaceH.ListEnabledSurfaces) + + // Admin endpoints + surfaceAdm := NewSurfaceHandler(stores, tmpDir) + adminGroup := api.Group("/admin") + adminGroup.Use(middleware.Auth(cfg)) + adminGroup.Use(middleware.RequireAdmin()) + adminGroup.GET("/surfaces", surfaceAdm.ListSurfaces) + adminGroup.GET("/surfaces/:id", surfaceAdm.GetSurface) + adminGroup.POST("/surfaces/install", surfaceAdm.InstallSurface) + adminGroup.PUT("/surfaces/:id/enable", surfaceAdm.EnableSurface) + adminGroup.PUT("/surfaces/:id/disable", surfaceAdm.DisableSurface) + adminGroup.DELETE("/surfaces/:id", surfaceAdm.DeleteSurface) + + // Seed admin user + adminID := database.SeedTestUser(t, "surf-admin", "surf-admin@test.com") + database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), adminID) + adminToken := makeToken(adminID, "surf-admin@test.com", "admin") + + // Seed regular user + userID := database.SeedTestUser(t, "surf-user", "surf-user@test.com") + database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID) + userToken := makeToken(userID, "surf-user@test.com", "user") + + return &surfaceHarness{ + router: r, + t: t, + stores: stores, + adminToken: adminToken, + userToken: userToken, + tmpDir: tmpDir, + } +} + +func (h *surfaceHarness) jsonReq(method, path, token string, body interface{}) *httptest.ResponseRecorder { + h.t.Helper() + var reader io.Reader + if body != nil { + b, _ := json.Marshal(body) + reader = bytes.NewReader(b) + } + req := httptest.NewRequest(method, path, reader) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + h.router.ServeHTTP(w, req) + return w +} + +func (h *surfaceHarness) uploadSurface(token string, archive []byte, filename string) *httptest.ResponseRecorder { + h.t.Helper() + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("file", filename) + if err != nil { + h.t.Fatalf("CreateFormFile: %v", err) + } + part.Write(archive) + writer.Close() + + req := httptest.NewRequest("POST", "/api/v1/admin/surfaces/install", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + h.router.ServeHTTP(w, req) + return w +} + +func (h *surfaceHarness) seedCoreSurface(id, title string) { + h.t.Helper() + manifest := map[string]any{"route": "/" + id} + if err := h.stores.Surfaces.Seed(context.Background(), id, title, "core", manifest); err != nil { + h.t.Fatalf("seedCoreSurface(%s): %v", id, err) + } +} + +func (h *surfaceHarness) seedExtensionSurface(id, title string, enabled bool) { + h.t.Helper() + manifest := map[string]any{"route": "/s/" + id} + if err := h.stores.Surfaces.Seed(context.Background(), id, title, "extension", manifest); err != nil { + h.t.Fatalf("seedExtensionSurface(%s): %v", id, err) + } + if !enabled { + if err := h.stores.Surfaces.SetEnabled(context.Background(), id, false); err != nil { + h.t.Fatalf("disable extension %s: %v", id, err) + } + } +} + +// ── Tests: User Endpoint ──────────────────── + +func TestSurface_ListEnabled_Empty(t *testing.T) { + h := setupSurfaceHarness(t) + + w := h.jsonReq("GET", "/api/v1/surfaces", h.userToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String()) + } + var resp struct{ Surfaces []json.RawMessage `json:"surfaces"` } + json.Unmarshal(w.Body.Bytes(), &resp) + if len(resp.Surfaces) != 0 { + t.Errorf("want 0 surfaces, got %d", len(resp.Surfaces)) + } +} + +func TestSurface_ListEnabled_FiltersDisabled(t *testing.T) { + h := setupSurfaceHarness(t) + + h.seedCoreSurface("chat", "Chat") + h.seedExtensionSurface("my-ext", "My Extension", true) + h.seedExtensionSurface("disabled-ext", "Disabled Extension", false) + + w := h.jsonReq("GET", "/api/v1/surfaces", h.userToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + Surfaces []struct { + ID string `json:"id"` + Title string `json:"title"` + Route string `json:"route"` + } `json:"surfaces"` + } + json.Unmarshal(w.Body.Bytes(), &resp) + + if len(resp.Surfaces) != 2 { + t.Fatalf("want 2 enabled surfaces, got %d", len(resp.Surfaces)) + } + + // Response shape: id + title + route only — no source or enabled + raw := w.Body.Bytes() + if bytes.Contains(raw, []byte(`"source"`)) { + t.Error("user endpoint should not expose 'source' field") + } + if bytes.Contains(raw, []byte(`"enabled"`)) { + t.Error("user endpoint should not expose 'enabled' field") + } +} + +// ── Tests: Admin List / Get ───────────────── + +func TestSurface_AdminList_IncludesDisabled(t *testing.T) { + h := setupSurfaceHarness(t) + + h.seedCoreSurface("chat", "Chat") + h.seedExtensionSurface("my-ext", "My Extension", true) + h.seedExtensionSurface("disabled-ext", "Disabled Extension", false) + + w := h.jsonReq("GET", "/api/v1/admin/surfaces", h.adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + Surfaces []struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + Source string `json:"source"` + } `json:"surfaces"` + } + json.Unmarshal(w.Body.Bytes(), &resp) + + if len(resp.Surfaces) != 3 { + t.Fatalf("want 3 surfaces (including disabled), got %d", len(resp.Surfaces)) + } +} + +func TestSurface_AdminList_RequiresAdmin(t *testing.T) { + h := setupSurfaceHarness(t) + + w := h.jsonReq("GET", "/api/v1/admin/surfaces", h.userToken, nil) + if w.Code != http.StatusForbidden { + t.Errorf("non-admin should get 403, got %d", w.Code) + } +} + +func TestSurface_Get(t *testing.T) { + h := setupSurfaceHarness(t) + h.seedCoreSurface("chat", "Chat") + + w := h.jsonReq("GET", "/api/v1/admin/surfaces/chat", h.adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String()) + } + + var sr store.SurfaceRegistration + json.Unmarshal(w.Body.Bytes(), &sr) + if sr.ID != "chat" { + t.Errorf("id: want %q, got %q", "chat", sr.ID) + } + if sr.Source != "core" { + t.Errorf("source: want %q, got %q", "core", sr.Source) + } +} + +func TestSurface_Get_NotFound(t *testing.T) { + h := setupSurfaceHarness(t) + + w := h.jsonReq("GET", "/api/v1/admin/surfaces/nonexistent", h.adminToken, nil) + if w.Code != http.StatusNotFound { + t.Errorf("want 404, got %d", w.Code) + } +} + +// ── Tests: Enable / Disable ───────────────── + +func TestSurface_EnableDisable(t *testing.T) { + h := setupSurfaceHarness(t) + h.seedExtensionSurface("my-ext", "My Extension", true) + + // Disable + w := h.jsonReq("PUT", "/api/v1/admin/surfaces/my-ext/disable", h.adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("disable: want 200, got %d: %s", w.Code, w.Body.String()) + } + sr, _ := h.stores.Surfaces.Get(context.Background(), "my-ext") + if sr.Enabled { + t.Error("surface should be disabled") + } + + // Re-enable + w = h.jsonReq("PUT", "/api/v1/admin/surfaces/my-ext/enable", h.adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("enable: want 200, got %d: %s", w.Code, w.Body.String()) + } + sr, _ = h.stores.Surfaces.Get(context.Background(), "my-ext") + if !sr.Enabled { + t.Error("surface should be enabled") + } +} + +func TestSurface_DisableChat_Rejected(t *testing.T) { + h := setupSurfaceHarness(t) + h.seedCoreSurface("chat", "Chat") + + w := h.jsonReq("PUT", "/api/v1/admin/surfaces/chat/disable", h.adminToken, nil) + if w.Code != http.StatusBadRequest { + t.Errorf("disabling chat: want 400, got %d", w.Code) + } +} + +func TestSurface_DisableAdmin_Rejected(t *testing.T) { + h := setupSurfaceHarness(t) + h.seedCoreSurface("admin", "Admin") + + w := h.jsonReq("PUT", "/api/v1/admin/surfaces/admin/disable", h.adminToken, nil) + if w.Code != http.StatusBadRequest { + t.Errorf("disabling admin: want 400, got %d", w.Code) + } +} + +func TestSurface_DisableNonexistent(t *testing.T) { + h := setupSurfaceHarness(t) + + w := h.jsonReq("PUT", "/api/v1/admin/surfaces/nonexistent/disable", h.adminToken, nil) + if w.Code != http.StatusNotFound { + t.Errorf("disable nonexistent: want 404, got %d", w.Code) + } +} + +// ── Tests: Delete ─────────────────────────── + +func TestSurface_DeleteExtension(t *testing.T) { + h := setupSurfaceHarness(t) + h.seedExtensionSurface("my-ext", "My Extension", true) + + // Create a fake asset dir to verify cleanup + assetDir := filepath.Join(h.tmpDir, "my-ext") + os.MkdirAll(filepath.Join(assetDir, "js"), 0755) + os.WriteFile(filepath.Join(assetDir, "js", "app.js"), []byte("// test"), 0644) + + w := h.jsonReq("DELETE", "/api/v1/admin/surfaces/my-ext", h.adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("delete: want 200, got %d: %s", w.Code, w.Body.String()) + } + + sr, _ := h.stores.Surfaces.Get(context.Background(), "my-ext") + if sr != nil { + t.Error("surface should be deleted from DB") + } + + if _, err := os.Stat(assetDir); !os.IsNotExist(err) { + t.Error("surface asset directory should be removed") + } +} + +func TestSurface_DeleteCore_Rejected(t *testing.T) { + h := setupSurfaceHarness(t) + h.seedCoreSurface("chat", "Chat") + + w := h.jsonReq("DELETE", "/api/v1/admin/surfaces/chat", h.adminToken, nil) + if w.Code != http.StatusBadRequest { + t.Errorf("delete core: want 400, got %d: %s", w.Code, w.Body.String()) + } + + sr, _ := h.stores.Surfaces.Get(context.Background(), "chat") + if sr == nil { + t.Error("core surface should still exist after rejected delete") + } +} + +func TestSurface_DeleteNonexistent(t *testing.T) { + h := setupSurfaceHarness(t) + + w := h.jsonReq("DELETE", "/api/v1/admin/surfaces/nonexistent", h.adminToken, nil) + if w.Code != http.StatusNotFound { + t.Errorf("delete nonexistent: want 404, got %d", w.Code) + } +} + +// ── Tests: Install ────────────────────────── + +func TestSurface_Install_HappyPath(t *testing.T) { + h := setupSurfaceHarness(t) + + archive := buildSurfaceZip(t, "hello-dash", "Hello Dashboard", map[string]string{ + "js/app.js": "console.log('hello');", + "css/style.css": "body { color: red; }", + }) + + w := h.uploadSurface(h.adminToken, archive, "hello-dash.surface") + if w.Code != http.StatusOK { + t.Fatalf("install: want 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + ID string `json:"id"` + Title string `json:"title"` + Source string `json:"source"` + } + json.Unmarshal(w.Body.Bytes(), &resp) + if resp.ID != "hello-dash" { + t.Errorf("id: got %q, want %q", resp.ID, "hello-dash") + } + if resp.Source != "extension" { + t.Errorf("source: got %q, want %q", resp.Source, "extension") + } + + // Verify in DB + sr, err := h.stores.Surfaces.Get(context.Background(), "hello-dash") + if err != nil || sr == nil { + t.Fatal("surface should exist in DB after install") + } + if sr.Title != "Hello Dashboard" { + t.Errorf("title: got %q, want %q", sr.Title, "Hello Dashboard") + } + + // Verify assets extracted + jsPath := filepath.Join(h.tmpDir, "hello-dash", "js", "app.js") + if _, err := os.Stat(jsPath); os.IsNotExist(err) { + t.Error("js/app.js should be extracted") + } + cssPath := filepath.Join(h.tmpDir, "hello-dash", "css", "style.css") + if _, err := os.Stat(cssPath); os.IsNotExist(err) { + t.Error("css/style.css should be extracted") + } +} + +func TestSurface_Install_PrefixedArchive(t *testing.T) { + h := setupSurfaceHarness(t) + + manifest, _ := json.Marshal(map[string]string{ + "id": "hello-dash", "title": "Hello Dashboard", "route": "/s/hello-dash", + }) + archive := buildZipRaw(t, map[string]string{ + "hello-dash/manifest.json": string(manifest), + "hello-dash/js/app.js": "console.log('hello');", + "hello-dash/css/style.css": "body {}", + }) + + w := h.uploadSurface(h.adminToken, archive, "hello-dash.surface") + if w.Code != http.StatusOK { + t.Fatalf("install prefixed: want 200, got %d: %s", w.Code, w.Body.String()) + } + + jsPath := filepath.Join(h.tmpDir, "hello-dash", "js", "app.js") + if _, err := os.Stat(jsPath); os.IsNotExist(err) { + t.Error("js/app.js should be extracted from prefixed archive") + } +} + +func TestSurface_Install_MissingManifest(t *testing.T) { + h := setupSurfaceHarness(t) + + archive := buildZipRaw(t, map[string]string{ + "js/app.js": "console.log('hello');", + }) + + w := h.uploadSurface(h.adminToken, archive, "bad.surface") + if w.Code != http.StatusBadRequest { + t.Errorf("missing manifest: want 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSurface_Install_MissingID(t *testing.T) { + h := setupSurfaceHarness(t) + + archive := buildZipRaw(t, map[string]string{ + "manifest.json": `{"title": "No ID"}`, + }) + + w := h.uploadSurface(h.adminToken, archive, "noid.surface") + if w.Code != http.StatusBadRequest { + t.Errorf("missing id: want 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSurface_Install_CoreConflict(t *testing.T) { + h := setupSurfaceHarness(t) + h.seedCoreSurface("chat", "Chat") + + archive := buildSurfaceZip(t, "chat", "Evil Chat", nil) + w := h.uploadSurface(h.adminToken, archive, "chat.surface") + if w.Code != http.StatusConflict { + t.Errorf("core conflict: want 409, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSurface_Install_NoFile(t *testing.T) { + h := setupSurfaceHarness(t) + + req := httptest.NewRequest("POST", "/api/v1/admin/surfaces/install", nil) + req.Header.Set("Authorization", "Bearer "+h.adminToken) + w := httptest.NewRecorder() + h.router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("no file: want 400, got %d", w.Code) + } +} + +func TestSurface_Install_InvalidSlugID(t *testing.T) { + h := setupSurfaceHarness(t) + + cases := []struct { + id string + desc string + }{ + {"../../../etc", "path traversal"}, + {"hello world", "spaces"}, + {"Hello-Dashboard", "uppercase"}, + {"a", "too short"}, + {"-leading", "leading hyphen"}, + {"trailing-", "trailing hyphen"}, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + archive := buildSurfaceZip(t, tc.id, "Bad Surface", nil) + w := h.uploadSurface(h.adminToken, archive, "bad.surface") + if w.Code != http.StatusBadRequest { + t.Errorf("bad id %q: want 400, got %d: %s", tc.id, w.Code, w.Body.String()) + } + }) + } +} + +func TestSurface_Install_UpdateExistingExtension(t *testing.T) { + h := setupSurfaceHarness(t) + + archive := buildSurfaceZip(t, "my-ext", "My Extension v1", nil) + w := h.uploadSurface(h.adminToken, archive, "my-ext.surface") + if w.Code != http.StatusOK { + t.Fatalf("install v1: want 200, got %d: %s", w.Code, w.Body.String()) + } + + archive = buildSurfaceZip(t, "my-ext", "My Extension v2", nil) + w = h.uploadSurface(h.adminToken, archive, "my-ext.surface") + if w.Code != http.StatusOK { + t.Fatalf("install v2: want 200, got %d: %s", w.Code, w.Body.String()) + } + + sr, _ := h.stores.Surfaces.Get(context.Background(), "my-ext") + if sr == nil { + t.Fatal("surface should exist") + } + if sr.Title != "My Extension v2" { + t.Errorf("title: got %q, want %q", sr.Title, "My Extension v2") + } +} + +func TestSurface_Install_PreservesEnabledOnReseed(t *testing.T) { + h := setupSurfaceHarness(t) + + // Install, then disable + archive := buildSurfaceZip(t, "my-ext", "My Extension", nil) + h.uploadSurface(h.adminToken, archive, "my-ext.surface") + h.jsonReq("PUT", "/api/v1/admin/surfaces/my-ext/disable", h.adminToken, nil) + + // Re-install — Seed's ON CONFLICT skips enabled column + archive = buildSurfaceZip(t, "my-ext", "My Extension Updated", nil) + w := h.uploadSurface(h.adminToken, archive, "my-ext.surface") + if w.Code != http.StatusOK { + t.Fatalf("re-install: want 200, got %d: %s", w.Code, w.Body.String()) + } + + sr, _ := h.stores.Surfaces.Get(context.Background(), "my-ext") + if sr == nil { + t.Fatal("surface should exist") + } + if sr.Enabled { + t.Error("enabled state should be preserved as false after re-seed") + } +} + +// ── Zip Building Helpers ──────────────────── + +func buildSurfaceZip(t *testing.T, id, title string, extraFiles map[string]string) []byte { + t.Helper() + manifest, _ := json.Marshal(map[string]string{ + "id": id, "title": title, "route": "/s/" + id, + }) + files := map[string]string{"manifest.json": string(manifest)} + for k, v := range extraFiles { + files[k] = v + } + return buildZipRaw(t, files) +} + +func buildZipRaw(t *testing.T, files map[string]string) []byte { + t.Helper() + buf := &bytes.Buffer{} + zw := zip.NewWriter(buf) + for name, content := range files { + f, err := zw.Create(name) + if err != nil { + t.Fatalf("zip create %s: %v", name, err) + } + f.Write([]byte(content)) + } + zw.Close() + return buf.Bytes() +} diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html index 598b42a..a772fcb 100644 --- a/server/pages/templates/base.html +++ b/server/pages/templates/base.html @@ -119,6 +119,17 @@ if (typeof Theme !== 'undefined') Theme.init(); // Appearance (zoom + font size) must restore on every surface. if (typeof UI !== 'undefined' && UI.restoreAppearance) UI.restoreAppearance(); + + // v0.28.0: Universal logout — available on every surface. + // Chat/editor/notes load app.js which overrides this with a + // richer version (showConfirm, App.chats cleanup, etc.). + // Extension surfaces get this baseline version. + function handleLogout() { + if (!confirm('Sign out?')) return; + if (typeof Events !== 'undefined') { Events.disconnect(); Events.clear(); } + if (typeof API !== 'undefined' && API.logout) API.logout(); + location.reload(); + } {{if eq .Surface "chat"}}{{template "scripts-chat" .}}{{end}} @@ -131,6 +142,39 @@ {{end}} + {{/* ── v0.28.0: Universal UserMenu hydration ────────────────── + Runs after all surface-specific scripts. Creates and binds the + UserMenu component if no surface script did it already. + + Guard: chat surface excluded — it uses UI.toggleUserMenu() with + richer behavior (team admin refresh). Chat migration to UserMenu + component is tracked as tech debt (Pre-1.0 sweep). + */}} + + {{/* ── Debug Log Modal (all surfaces) ───── */}}