Changeset 0.28.0.10 (#182)

This commit is contained in:
2026-03-13 09:29:04 +00:00
parent 33d76e59ab
commit 9b9e2eb756
9 changed files with 1412 additions and 1128 deletions

View File

@@ -351,7 +351,7 @@ jobs:
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }} PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
run: | run: |
echo "━━━ Postgres Integration Tests ━━━" 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/... \ ./store/postgres/... \
./handlers/... ./handlers/...
echo "✓ Postgres integration tests complete" echo "✓ Postgres integration tests complete"

View File

@@ -12,14 +12,15 @@ Extension surfaces are uploaded by admins and served from the
GET /surfaces 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 ```json
{ {
"surfaces": [ "surfaces": [
{ "id": "chat", "title": "Chat", "source": "core", "enabled": true }, { "id": "chat", "title": "Chat", "route": "/" },
{ "id": "editor", "title": "Editor", "source": "core", "enabled": true }, { "id": "editor", "title": "Editor", "route": "/editor" },
{ "id": "hello-dashboard", "title": "Hello Dashboard", "source": "extension", "enabled": true } { "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 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. **Auth:** Platform admin.
@@ -46,6 +57,13 @@ GET /admin/surfaces/:id
Returns full surface details including manifest. 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 ### Install Surface
``` ```
@@ -53,9 +71,18 @@ POST /admin/surfaces/install
Content-Type: multipart/form-data Content-Type: multipart/form-data
``` ```
Field: `archive` — a `.surface` archive (tar.gz containing `manifest.json` Field: `file` — a `.surface` or `.zip` archive (zip containing
+ static assets). The manifest declares `id`, `title`, `route`, required `manifest.json` + static assets). The manifest declares `id`, `title`,
data loaders, scripts, and CSS. `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. **Auth:** Platform admin.
@@ -68,6 +95,13 @@ PUT /admin/surfaces/:id/disable
Disabled surfaces redirect to `/` and hide from navigation. 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. **Auth:** Platform admin.
### Delete Surface ### Delete Surface
@@ -76,10 +110,19 @@ Disabled surfaces redirect to `/` and hide from navigation.
DELETE /admin/surfaces/:id 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. **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 ## Surface Registry Object
```json ```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 ## Page Routes
Extension surfaces are served at `/s/:slug` via `RenderExtensionSurface()`. Extension surfaces are served at `/s/:slug` via `RenderExtensionSurface()`.
The page engine does a runtime DB lookup — no server restart needed after install. 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 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 ## Surface Archive Format
A `.surface` file is a tar.gz containing: A `.surface` file is a **zip** archive containing:
``` ```
manifest.json — required manifest.json — required (must have "id" and "title")
script.js — optional js/ — optional (extracted to /data/surfaces/{id}/js/)
style.css — optional css/ — optional (extracted to /data/surfaces/{id}/css/)
assets/ — optional (icons, images, etc.) 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 See [EXTENSION-SURFACES.md](../EXTENSION-SURFACES.md) for the full
authoring guide including manifest schema, platform API, and CSS authoring guide including manifest schema, platform API, and CSS
custom properties. custom properties.

File diff suppressed because it is too large Load Diff

View File

@@ -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)
}
}
}

View File

@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -15,6 +16,10 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/store" "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). // SurfaceHandler manages surface lifecycle (admin-only).
type SurfaceHandler struct { type SurfaceHandler struct {
stores store.Stores stores store.Stores
@@ -197,6 +202,13 @@ func (h *SurfaceHandler) InstallSurface(c *gin.Context) {
return 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 // Check for conflicts with core surfaces
existing, _ := h.stores.Surfaces.Get(c.Request.Context(), surfaceID) existing, _ := h.stores.Surfaces.Get(c.Request.Context(), surfaceID)
if existing != nil && existing.Source == "core" { if existing != nil && existing.Source == "core" {
@@ -213,21 +225,15 @@ func (h *SurfaceHandler) InstallSurface(c *gin.Context) {
if f.FileInfo().IsDir() { if f.FileInfo().IsDir() {
continue continue
} }
// Only extract js/, css/, assets/ directories
name := f.Name // Resolve the relative path, stripping an optional single
// Strip leading directory if archive has one (e.g., "my-surface/js/foo.js" → "js/foo.js") // top-level directory prefix from the archive.
if idx := strings.Index(name, "/"); idx > 0 { relPath := extractableRelPath(f.Name)
rest := name[idx+1:] if relPath == "" {
if strings.HasPrefix(rest, "js/") || strings.HasPrefix(rest, "css/") || strings.HasPrefix(rest, "assets/") { continue // Not an extractable static file
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)
}
} }
destPath := filepath.Join(destDir, name) destPath := filepath.Join(destDir, relPath)
// Security: prevent path traversal // Security: prevent path traversal
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) { 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). // ListEnabledSurfaces returns enabled surface IDs (non-admin, for nav).
// GET /api/v1/surfaces // GET /api/v1/surfaces
func (h *SurfaceHandler) ListEnabledSurfaces(c *gin.Context) { func (h *SurfaceHandler) ListEnabledSurfaces(c *gin.Context) {

View File

@@ -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()
}

View File

@@ -119,6 +119,17 @@
if (typeof Theme !== 'undefined') Theme.init(); if (typeof Theme !== 'undefined') Theme.init();
// Appearance (zoom + font size) must restore on every surface. // Appearance (zoom + font size) must restore on every surface.
if (typeof UI !== 'undefined' && UI.restoreAppearance) UI.restoreAppearance(); 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();
}
</script> </script>
{{if eq .Surface "chat"}}{{template "scripts-chat" .}}{{end}} {{if eq .Surface "chat"}}{{template "scripts-chat" .}}{{end}}
@@ -131,6 +142,39 @@
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script> <script nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>
{{end}} {{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).
*/}}
<script nonce="{{.CSPNonce}}">
(function () {
if (typeof UserMenu === 'undefined') return;
if (UserMenu.primary) return;
if ((window.__SURFACE__ || '') === 'chat') return;
var btn = document.getElementById('userMenuBtn');
if (!btn) return;
var menu = UserMenu.create({ id: '' });
UserMenu.primary = menu;
menu.setUser(window.__USER__ || {});
var user = window.__USER__ || {};
menu.showAdmin(user.role === 'admin');
menu.bind({
onSettings: function () { window.location.href = (window.__BASE__ || '') + '/settings'; },
onAdmin: function () { window.location.href = (window.__BASE__ || '') + '/admin'; },
onDebug: function () { if (typeof openDebugModal === 'function') openDebugModal(); },
onSignout: function () { handleLogout(); },
});
})();
</script>
{{/* ── Debug Log Modal (all surfaces) ───── */}} {{/* ── Debug Log Modal (all surfaces) ───── */}}
<div id="debugModal" class="modal-overlay"> <div id="debugModal" class="modal-overlay">
<div class="modal modal-wide"> <div class="modal modal-wide">

View File

@@ -13,8 +13,8 @@
{{define "surface-extension"}} {{define "surface-extension"}}
<div id="extension-surface" class="extension-surface" <div id="extension-surface" class="extension-surface"
data-surface-id="{{.Surface}}"> data-surface-id="{{.Surface}}">
{{/* User menu — must pass dict with ID field, not raw PageData */}} {{/* User menu — standard empty prefix, hydrated by base.html universal init */}}
{{template "user-menu" dict "ID" "ext"}} {{template "user-menu" dict "ID" ""}}
<div id="extension-mount" class="extension-mount"></div> <div id="extension-mount" class="extension-mount"></div>
</div> </div>

View File

@@ -17,8 +17,7 @@
const wsId = pageData.WorkspaceID; const wsId = pageData.WorkspaceID;
const wsName = pageData.WorkspaceName || 'Workspace'; const wsName = pageData.WorkspaceName || 'Workspace';
// Wire user menu // v0.28.0: UserMenu hydration moved to base.html universal init.
_initUserMenu();
// Wire workspace selector // Wire workspace selector
_initWsSelector(wsId); _initWsSelector(wsId);
@@ -32,22 +31,6 @@
_mountEditor(wsId, wsName); _mountEditor(wsId, wsName);
}); });
// ── User Menu ───────────────────────────
function _initUserMenu() {
if (typeof UserMenu === 'undefined') return;
const menu = UserMenu.create({ id: '' });
UserMenu.primary = menu;
menu.setUser(API.user || window.__USER__);
menu.bind({
onSettings: () => { window.location.href = (window.__BASE__ || '') + '/settings'; },
onAdmin: () => { window.location.href = (window.__BASE__ || '') + '/admin'; },
onDebug: () => { if (typeof openDebugModal === 'function') openDebugModal(); },
onSignout: () => { if (typeof handleLogout === 'function') handleLogout(); },
});
menu.showAdmin(API.isAdmin);
}
// ── Workspace Selector ────────────────── // ── Workspace Selector ──────────────────
function _initWsSelector(currentWsId) { function _initWsSelector(currentWsId) {