steps 5-8: deep gut — purge chat/notes/projects/providers from code
Some checks failed
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Failing after 6s
CI/CD / test-sqlite (push) Failing after 25s
CI/CD / test-go-pg (push) Failing after 26s
CI/CD / build-and-deploy (push) Has been skipped

main.go: remove ~300 lines of stale routes referencing deleted handlers
  (channels, messages, folders, personas, notes, projects, memories,
   models, providers, tasks, roles, usage, routing, capabilities, etc.)
  Fix branding: "Chat Switchboard" → "Switchboard Core"

pages: remove chat/notes/projects surface manifests and templates
  Keep: admin, settings, team-admin, workflow, workflow-landing

frontend: delete chat/, notes/, projects/ surface directories (19 files)
  Delete 5 CSS files (4,144 lines): sw-chat-*, sw-notes-*, sw-projects-*
  SDK: strip gutted API domains (594→287 lines), remove chatPane/notesPane

tests: remove integration_test.go (5,194 lines, broken imports to deleted
  packages) and perm_enforcement_test.go (551 lines, depended on deleted
  test helpers). Fix testmain_test.go (remove providers import).

openapi.yaml: replace 12,491-line stale spec with kernel auth stub.
  Full ICD rebuild is Step 8 proper.

Auth (builtin, mTLS, OIDC) untouched throughout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 09:47:32 +00:00
parent a9c652ff59
commit 45291860c5
36 changed files with 76 additions and 25937 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,551 +0,0 @@
package handlers
// ══════════════════════════════════════════════════════════════════════════════
// Permission Enforcement Tests (v0.37.1)
//
// Verifies that RequirePermission middleware actually blocks non-admin users
// who lack the required permission, and allows them after the permission is
// granted via group membership.
//
// Every test:
// 1. Creates a non-admin user (no groups, TruncateAll wipes Everyone)
// 2. Attempts the gated operation → expects 403
// 3. Creates a group with the required permission
// 4. Adds the user to that group
// 5. Retries the operation → expects success (200 or 201)
//
// No admin users. No shortcuts.
// ══════════════════════════════════════════════════════════════════════════════
import (
"net/http"
"testing"
"switchboard-core/database"
)
// ── Helpers ─────────────────────────────────
// seedNonAdminUser creates a non-admin active user with a valid token.
// TruncateAll wipes the Everyone group so this user has ZERO permissions.
func seedNonAdminUser(t *testing.T) (userID, token string) {
t.Helper()
userID = database.SeedTestUser(t, "permuser", "permuser@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
token = makeToken(userID, "permuser@test.com", "user")
return
}
// seedAdminUser creates an admin user for setup operations (group creation).
func seedAdminUser(t *testing.T) (userID, token string) {
t.Helper()
userID = database.SeedTestUser(t, "permadmin", "permadmin@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), userID)
token = makeToken(userID, "permadmin@test.com", "admin")
return
}
// grantPermission creates a group with the given permission and adds the user to it.
// Uses admin token for group creation, returns the group ID.
func grantPermission(t *testing.T, h *testHarness, adminToken, userID string, perm string) string {
t.Helper()
w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
"name": "grant-" + perm,
"scope": "global",
"permissions": []string{perm},
})
if w.Code != http.StatusCreated {
t.Fatalf("create group for %s: want 201, got %d: %s", perm, w.Code, w.Body.String())
}
var group map[string]interface{}
decode(w, &group)
groupID := group["id"].(string)
w = h.request("POST", "/api/v1/admin/groups/"+groupID+"/members", adminToken, map[string]interface{}{
"user_id": userID,
})
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
t.Fatalf("add member to group %s: want 200/201, got %d: %s", perm, w.Code, w.Body.String())
}
return groupID
}
// seedChannel creates a channel using the admin token and returns its ID.
func seedChannel(t *testing.T, h *testHarness, adminToken string) string {
t.Helper()
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
"title": "test-channel",
"type": "direct",
})
if w.Code != http.StatusCreated {
t.Fatalf("seed channel: want 201, got %d: %s", w.Code, w.Body.String())
}
var ch map[string]interface{}
decode(w, &ch)
return ch["id"].(string)
}
// ══════════════════════════════════════════════
// §1 model.use — completions, summarize, title
// ══════════════════════════════════════════════
func TestPermEnforce_ModelUse_Completions(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
adminID, adminToken := seedAdminUser(t)
_ = adminID
// User needs channel.create to make a channel for completions,
// so grant that first (otherwise 403 on channel create, not completions).
grantPermission(t, h, adminToken, userID, "channel.create")
// Create channel as user (now allowed)
w := h.request("POST", "/api/v1/channels", userToken, map[string]interface{}{
"title": "perm-test", "type": "direct",
})
if w.Code != http.StatusCreated {
t.Fatalf("create channel: want 201, got %d: %s", w.Code, w.Body.String())
}
var ch map[string]interface{}
decode(w, &ch)
channelID := ch["id"].(string)
// Completions without model.use → 403
w = h.request("POST", "/api/v1/chat/completions", userToken, map[string]interface{}{
"channel_id": channelID,
"message": "hello",
})
if w.Code != http.StatusForbidden {
t.Fatalf("completions without model.use: want 403, got %d: %s", w.Code, w.Body.String())
}
// Grant model.use
grantPermission(t, h, adminToken, userID, "model.use")
// Completions with model.use → should pass middleware (may fail downstream
// due to no provider configured, but NOT 403)
w = h.request("POST", "/api/v1/chat/completions", userToken, map[string]interface{}{
"channel_id": channelID,
"message": "hello",
})
if w.Code == http.StatusForbidden {
t.Fatalf("completions WITH model.use: still got 403: %s", w.Body.String())
}
// Accept 400/500/502 — anything except 403 means the permission gate passed
}
// NOTE: Summarize and GenerateTitle share the model.use permission with
// completions. Their route-level RequirePermission wiring is in main.go
// but the test harness (setupHarness) doesn't register those routes.
// The completions test above proves model.use enforcement works.
// Summarize/title coverage will come when the test harness is extended.
// ══════════════════════════════════════════════
// §2 channel.create
// ══════════════════════════════════════════════
func TestPermEnforce_ChannelCreate(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
// Without channel.create → 403
w := h.request("POST", "/api/v1/channels", userToken, map[string]interface{}{
"title": "blocked", "type": "direct",
})
if w.Code != http.StatusForbidden {
t.Fatalf("channel create without permission: want 403, got %d: %s", w.Code, w.Body.String())
}
grantPermission(t, h, adminToken, userID, "channel.create")
w = h.request("POST", "/api/v1/channels", userToken, map[string]interface{}{
"title": "allowed", "type": "direct",
})
if w.Code != http.StatusCreated {
t.Fatalf("channel create WITH permission: want 201, got %d: %s", w.Code, w.Body.String())
}
}
// ══════════════════════════════════════════════
// §3 channel.invite
// ══════════════════════════════════════════════
func TestPermEnforce_ChannelInvite(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
// Need channel.create first to have a channel to invite into
grantPermission(t, h, adminToken, userID, "channel.create")
w := h.request("POST", "/api/v1/channels", userToken, map[string]interface{}{
"title": "invite-test", "type": "group",
})
var ch map[string]interface{}
decode(w, &ch)
channelID := ch["id"].(string)
// Create another user to invite
inviteeID := database.SeedTestUser(t, "invitee", "invitee@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), inviteeID)
// Without channel.invite → 403
w = h.request("POST", "/api/v1/channels/"+channelID+"/participants", userToken, map[string]interface{}{
"user_id": inviteeID,
})
if w.Code != http.StatusForbidden {
t.Fatalf("invite without permission: want 403, got %d: %s", w.Code, w.Body.String())
}
grantPermission(t, h, adminToken, userID, "channel.invite")
w = h.request("POST", "/api/v1/channels/"+channelID+"/participants", userToken, map[string]interface{}{
"user_id": inviteeID,
})
if w.Code == http.StatusForbidden {
t.Fatalf("invite WITH permission: still got 403: %s", w.Body.String())
}
}
// ══════════════════════════════════════════════
// §4 kb.create, kb.read, kb.write
// ══════════════════════════════════════════════
func TestPermEnforce_KBCreate(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
// Without kb.create → 403
w := h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
"name": "blocked-kb",
})
if w.Code != http.StatusForbidden {
t.Fatalf("kb create without permission: want 403, got %d: %s", w.Code, w.Body.String())
}
grantPermission(t, h, adminToken, userID, "kb.create")
w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
"name": "allowed-kb",
})
if w.Code != http.StatusCreated {
t.Fatalf("kb create WITH permission: want 201, got %d: %s", w.Code, w.Body.String())
}
}
func TestPermEnforce_KBSearch(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
// Admin creates a KB for the user to search
grantPermission(t, h, adminToken, userID, "kb.create")
w := h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
"name": "search-test-kb",
})
var kb map[string]interface{}
decode(w, &kb)
kbID := kb["id"].(string)
// Without kb.read → 403
w = h.request("POST", "/api/v1/knowledge-bases/"+kbID+"/search", userToken, map[string]interface{}{
"query": "test",
})
if w.Code != http.StatusForbidden {
t.Fatalf("kb search without kb.read: want 403, got %d: %s", w.Code, w.Body.String())
}
grantPermission(t, h, adminToken, userID, "kb.read")
// With kb.read the middleware should pass. The handler may panic or 500
// because the test harness has no embedder configured, but any non-403
// response proves the permission gate opened. Use a recovery wrapper
// to catch the nil-embedder panic gracefully.
func() {
defer func() {
if r := recover(); r != nil {
// Panic in handler (nil embedder) means middleware passed — success
}
}()
w = h.request("POST", "/api/v1/knowledge-bases/"+kbID+"/search", userToken, map[string]interface{}{
"query": "test",
})
if w.Code == http.StatusForbidden {
t.Fatalf("kb search WITH kb.read: still got 403: %s", w.Body.String())
}
}()
}
func TestPermEnforce_KBWrite(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
// Grant kb.create so user can create the KB
grantPermission(t, h, adminToken, userID, "kb.create")
w := h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
"name": "write-test-kb",
})
var kb map[string]interface{}
decode(w, &kb)
kbID := kb["id"].(string)
// Without kb.write → 403 on update
w = h.request("PUT", "/api/v1/knowledge-bases/"+kbID, userToken, map[string]interface{}{
"name": "renamed",
})
if w.Code != http.StatusForbidden {
t.Fatalf("kb update without kb.write: want 403, got %d: %s", w.Code, w.Body.String())
}
// Without kb.write → 403 on delete
w = h.request("DELETE", "/api/v1/knowledge-bases/"+kbID, userToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("kb delete without kb.write: want 403, got %d: %s", w.Code, w.Body.String())
}
// Without kb.write → 403 on rebuild
w = h.request("POST", "/api/v1/knowledge-bases/"+kbID+"/rebuild", userToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("kb rebuild without kb.write: want 403, got %d: %s", w.Code, w.Body.String())
}
grantPermission(t, h, adminToken, userID, "kb.write")
// With kb.write → should pass middleware
w = h.request("PUT", "/api/v1/knowledge-bases/"+kbID, userToken, map[string]interface{}{
"name": "renamed-ok",
})
if w.Code == http.StatusForbidden {
t.Fatalf("kb update WITH kb.write: still got 403: %s", w.Body.String())
}
}
// ══════════════════════════════════════════════
// §5 persona.create, persona.manage
// ══════════════════════════════════════════════
func TestPermEnforce_PersonaCreate(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
// Enable user personas policy
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
// Without persona.create → 403
w := h.request("POST", "/api/v1/personas", userToken, map[string]interface{}{
"name": "blocked-persona",
})
if w.Code != http.StatusForbidden {
t.Fatalf("persona create without permission: want 403, got %d: %s", w.Code, w.Body.String())
}
grantPermission(t, h, adminToken, userID, "persona.create")
w = h.request("POST", "/api/v1/personas", userToken, map[string]interface{}{
"name": "allowed-persona",
})
if w.Code == http.StatusForbidden {
t.Fatalf("persona create WITH permission: still got 403: %s", w.Body.String())
}
}
func TestPermEnforce_PersonaManage(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
// Grant create so user can make a persona
grantPermission(t, h, adminToken, userID, "persona.create")
w := h.request("POST", "/api/v1/personas", userToken, map[string]interface{}{
"name": "manage-test",
})
var persona map[string]interface{}
decode(w, &persona)
personaID := persona["id"].(string)
// Without persona.manage → 403 on update
w = h.request("PUT", "/api/v1/personas/"+personaID, userToken, map[string]interface{}{
"name": "updated",
})
if w.Code != http.StatusForbidden {
t.Fatalf("persona update without persona.manage: want 403, got %d: %s", w.Code, w.Body.String())
}
grantPermission(t, h, adminToken, userID, "persona.manage")
w = h.request("PUT", "/api/v1/personas/"+personaID, userToken, map[string]interface{}{
"name": "updated-ok",
})
if w.Code == http.StatusForbidden {
t.Fatalf("persona update WITH persona.manage: still got 403: %s", w.Body.String())
}
}
// ══════════════════════════════════════════════
// §6 workflow.create
// ══════════════════════════════════════════════
func TestPermEnforce_WorkflowCreate(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
// Without workflow.create → 403
w := h.request("POST", "/api/v1/workflows", userToken, map[string]interface{}{
"name": "blocked-wf",
})
if w.Code != http.StatusForbidden {
t.Fatalf("workflow create without permission: want 403, got %d: %s", w.Code, w.Body.String())
}
grantPermission(t, h, adminToken, userID, "workflow.create")
w = h.request("POST", "/api/v1/workflows", userToken, map[string]interface{}{
"name": "allowed-wf",
})
if w.Code == http.StatusForbidden {
t.Fatalf("workflow create WITH permission: still got 403: %s", w.Body.String())
}
}
// ══════════════════════════════════════════════
// §7 task.create
// ══════════════════════════════════════════════
func TestPermEnforce_TaskCreate(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
// Without task.create → 403
w := h.request("POST", "/api/v1/tasks", userToken, map[string]interface{}{
"name": "blocked-task",
"task_type": "prompt",
"schedule": "@daily",
"user_prompt": "test",
"model_id": "test-model",
})
if w.Code != http.StatusForbidden {
t.Fatalf("task create without permission: want 403, got %d: %s", w.Code, w.Body.String())
}
grantPermission(t, h, adminToken, userID, "task.create")
w = h.request("POST", "/api/v1/tasks", userToken, map[string]interface{}{
"name": "allowed-task",
"task_type": "prompt",
"schedule": "@daily",
"user_prompt": "test",
"model_id": "test-model",
"timezone": "UTC",
})
if w.Code != http.StatusCreated {
t.Fatalf("task create WITH permission: want 201, got %d: %s", w.Code, w.Body.String())
}
}
// ══════════════════════════════════════════════
// §8 GET /profile/permissions (new endpoint)
// ══════════════════════════════════════════════
func TestPermEnforce_ProfilePermissions_NoGroups(t *testing.T) {
h := setupHarness(t)
_, userToken := seedNonAdminUser(t)
// User with no groups, no Everyone group → empty permissions
w := h.request("GET", "/api/v1/profile/permissions", userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("profile/permissions: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
perms, ok := resp["permissions"].([]interface{})
if !ok {
t.Fatal("response must have 'permissions' array")
}
if len(perms) != 0 {
t.Fatalf("expected 0 permissions (no groups), got %d: %v", len(perms), perms)
}
groups, ok := resp["groups"].([]interface{})
if !ok {
t.Fatal("response must have 'groups' array")
}
// Should still include Everyone group ID (even though it doesn't exist after truncate)
if len(groups) == 0 {
t.Fatal("groups should include Everyone group ID")
}
}
func TestPermEnforce_ProfilePermissions_WithGrant(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
grantPermission(t, h, adminToken, userID, "channel.create")
grantPermission(t, h, adminToken, userID, "model.use")
w := h.request("GET", "/api/v1/profile/permissions", userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("profile/permissions: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
perms, _ := resp["permissions"].([]interface{})
permSet := make(map[string]bool)
for _, p := range perms {
permSet[p.(string)] = true
}
if !permSet["channel.create"] {
t.Fatal("expected channel.create in permissions")
}
if !permSet["model.use"] {
t.Fatal("expected model.use in permissions")
}
if permSet["task.create"] {
t.Fatal("task.create should NOT be in permissions (not granted)")
}
}
func TestPermEnforce_ProfilePermissions_Admin(t *testing.T) {
h := setupHarness(t)
_, adminToken := seedAdminUser(t)
w := h.request("GET", "/api/v1/profile/permissions", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("profile/permissions admin: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
perms, _ := resp["permissions"].([]interface{})
// Admin should get ALL permissions
if len(perms) != len(allPermissionConstants()) {
t.Fatalf("admin should have all %d permissions, got %d", len(allPermissionConstants()), len(perms))
}
}
// allPermissionConstants returns the count of auth.AllPermissions.
// Duplicated here to avoid import cycle issues in test assertions.
func allPermissionConstants() []string {
return []string{
"model.use", "model.select_any",
"kb.read", "kb.write", "kb.create",
"channel.create", "channel.invite",
"persona.create", "persona.manage",
"workflow.create",
"admin.view",
"token.unlimited",
"task.create", "task.admin", "task.action", "task.starlark",
}
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/gin-gonic/gin"
"switchboard-core/database"
"switchboard-core/providers"
_ "modernc.org/sqlite" // register sqlite driver for DB_DRIVER=sqlite
)
@@ -19,7 +18,6 @@ import (
// Set DB_DRIVER=sqlite to run against SQLite instead of Postgres.
func TestMain(m *testing.M) {
gin.SetMode(gin.TestMode)
providers.Init()
teardown := database.SetupTestDB()
code := m.Run()

View File

@@ -4,7 +4,6 @@ import (
"bytes"
"context"
_ "embed"
"encoding/json"
"fmt"
"log"
"net/http"
@@ -107,12 +106,6 @@ func main() {
stores = postgres.NewStores(database.DB)
}
// Provider health accumulator (v0.22.0)
if database.IsSQLite() {
} else {
}
// v0.33.0: Start Prometheus DB pool collector
metrics.StartDBCollector(database.DB, 15*time.Second)
@@ -131,25 +124,15 @@ func main() {
}
}()
retScanner.Start()
defer retScanner.Stop()
// v0.27.2: Task scheduler startup deferred to after hub/notification init — see below.
// Bootstrap admin from env (K8s secret) — upserts on every restart
handlers.BootstrapAdmin(cfg, stores, uekCache)
// Seed additional users from env (dev/test only, skipped in production)
handlers.SeedUsers(cfg, stores, uekCache)
// Seed providers from env (dev/test only, skipped in production)
handlers.SeedProviders(cfg, stores, keyResolver)
// Seed builtin extensions from disk (idempotent, version-aware)
handlers.SeedBuiltinPackages(stores, "extensions/builtin")
// Load search provider config from DB (defaults to DuckDuckGo if not set)
loadSearchConfig(stores)
}
defer database.Close()
@@ -178,15 +161,6 @@ func main() {
}
handlers.SetStorageConfigured(objStore != nil)
// Filesystem-based queue for document text extraction (PDF, DOCX, etc.)
// Nil if storage is disabled.
if objStore != nil && cfg.StoragePath != "" {
if err != nil {
log.Printf("⚠ Extraction queue init failed: %v", err)
} else {
}
}
// ── EventBus (created early — needed by role resolver) ──
bus := events.NewBus()
@@ -194,15 +168,6 @@ func main() {
// No-op when running SQLite — in-process Bus is sufficient for single-pod.
events.StartPGBroadcast(bus)
if cfg.StoragePath != "" {
if err := wfs.Init(); err != nil {
log.Printf("⚠ Workspace FS init failed: %v", err)
} else {
log.Printf(" 📁 Workspace FS initialized at %s/workspaces", cfg.StoragePath)
}
}
// Sandboxed interpreter for extension scripts. Runner assembles
// modules based on granted permissions. Notifier attached below
// after notification service init.
@@ -253,7 +218,7 @@ func main() {
if stores.GlobalConfig != nil {
if smtpCfg, err := notifications.LoadSMTPConfig(stores.GlobalConfig, keyResolver); err == nil && smtpCfg != nil {
transport := notifications.NewEmailTransport(*smtpCfg)
instanceName := "Chat Switchboard"
instanceName := "Switchboard Core"
if brandCfg, err := stores.GlobalConfig.Get(context.Background(), "branding"); err == nil {
if name, ok := brandCfg["instance_name"].(string); ok && name != "" {
instanceName = name
@@ -272,8 +237,6 @@ func main() {
defer notifSvc.StopCleanup()
}
// v0.27.2: Task scheduler with executor — needs hub + notification service
// Health check (k8s probes hit this directly)
base.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
@@ -419,48 +382,6 @@ func main() {
c.JSON(http.StatusOK, gin.H{"ticket": ticket})
})
// Channels
// Typing indicator broadcast (v0.23.2)
protected.POST("/channels/:id/typing", func(c *gin.Context) {
userID := c.GetString("user_id")
channelID := c.Param("id")
// Resolve display name
var displayName string
user, err := stores.Users.GetByID(c.Request.Context(), userID)
if err == nil && user != nil {
displayName = user.DisplayName
if displayName == "" {
displayName = user.Username
}
}
if displayName == "" {
displayName = userID[:8]
}
if err == nil {
payload, _ := json.Marshal(map[string]any{
"channel_id": channelID,
"user_id": userID,
"display_name": displayName,
})
evt := events.Event{
Label: "typing.user",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
for _, pid := range pids {
hub.PublishToUser(pid, evt)
}
}
c.JSON(200, gin.H{"ok": true})
})
// Chat Folders (v0.23.1)
protected.GET("/folders", folders.List)
protected.POST("/folders", folders.Create)
protected.PUT("/folders/:id", folders.Update)
protected.DELETE("/folders/:id", folders.Delete)
// Presence (v0.23.1)
presence := handlers.NewPresenceHandler(stores)
protected.POST("/presence/heartbeat", presence.Heartbeat)
@@ -469,15 +390,6 @@ func main() {
// User search (v0.23.2 — DM user picker)
protected.GET("/users/search", presence.SearchUsers)
// Persona groups (v0.23.2 — roster templates for group chats)
protected.GET("/persona-groups", pgH.List)
protected.POST("/persona-groups", pgH.Create)
protected.GET("/persona-groups/:id", pgH.Get)
protected.PUT("/persona-groups/:id", pgH.Update)
protected.DELETE("/persona-groups/:id", pgH.Delete)
protected.POST("/persona-groups/:id/members", pgH.AddMember)
protected.DELETE("/persona-groups/:id/members/:memberId", pgH.RemoveMember)
// Workflows (v0.26.1 — team-owned staged processes)
wfH := handlers.NewWorkflowHandler(stores)
protected.GET("/workflows", wfH.List)
@@ -494,30 +406,6 @@ func main() {
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
// Workflow assignments (v0.26.4 — team assignment queue)
// Tasks (v0.27.1, permissions v0.27.2)
// Channel models (v0.20.0 — multi-model @mention routing)
protected.GET("/channels/:id/models", chModelH.List)
protected.POST("/channels/:id/models", chModelH.Add)
protected.PATCH("/channels/:id/models/:modelId", chModelH.Update)
protected.DELETE("/channels/:id/models/:modelId", chModelH.Delete)
// Channel participants (v0.23.0 — ICD §3.7)
// Messages
protected.GET("/channels/:id/messages", msgs.ListMessages)
protected.POST("/channels/:id/messages", msgs.CreateMessage)
// Message tree (forking)
protected.GET("/channels/:id/path", msgs.GetActivePath)
protected.PUT("/channels/:id/cursor", msgs.UpdateCursor)
protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage)
protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate)
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
protected.DELETE("/channels/:id/messages/:msgId", msgs.DeleteMessage)
// Surface discovery (v0.25.0, v0.28.7: unified packages)
pkgH := handlers.NewPackageHandler(stores)
protected.GET("/surfaces", pkgH.ListEnabledSurfaces)
@@ -532,10 +420,6 @@ func main() {
protected.POST("/packages/install", userPkgH.InstallPersonalPackage)
protected.DELETE("/packages/:id", userPkgH.DeletePersonalPackage)
// Provider Configs (user-facing — replaces /api-configs)
// Connection Type Discovery (v0.38.4)
connTypeH := handlers.NewConnectionTypeHandler(stores)
protected.GET("/connection-types", connTypeH.ListConnectionTypes)
@@ -549,13 +433,6 @@ func main() {
protected.PUT("/connections/:id", connH.UpdateConnection)
protected.DELETE("/connections/:id", connH.DeleteConnection)
// Models (unified resolver — replaces scattered endpoints)
// Model Preferences
protected.GET("/models/preferences", modelPrefs.GetPreferences)
protected.PUT("/models/preferences", modelPrefs.SetPreference)
protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
// User Settings & Profile
settings := handlers.NewSettingsHandler(stores, uekCache)
protected.GET("/profile", settings.GetProfile)
@@ -572,33 +449,6 @@ func main() {
bootH := handlers.NewProfileBootstrapHandler(stores)
protected.GET("/profile/bootstrap", bootH.GetBootstrap)
// Usage (personal)
protected.GET("/usage", usage.PersonalUsage)
// Personas
// Notes
protected.GET("/notes", notes.List)
protected.POST("/notes", notes.Create)
protected.GET("/notes/search", notes.Search)
protected.GET("/notes/search-titles", notes.SearchTitles)
protected.GET("/notes/folders", notes.ListFolders)
protected.GET("/notes/graph", notes.Graph)
protected.POST("/notes/bulk-delete", notes.BulkDelete)
protected.GET("/notes/:id", notes.Get)
protected.PUT("/notes/:id", notes.Update)
protected.DELETE("/notes/:id", notes.Delete)
protected.GET("/notes/:id/backlinks", notes.Backlinks)
// Projects (v0.19.0)
protected.POST("/projects/:id/files", projFileH.UploadToProject)
protected.GET("/projects/:id/files", projFileH.ListByProject)
protected.GET("/projects/:id/files/download", projFileH.DownloadProjectFile)
protected.DELETE("/projects/:id/files", projFileH.DeleteProjectFile)
protected.POST("/projects/:id/files/mkdir", projFileH.MkdirProject)
protected.POST("/projects/:id/archive/upload", projFileH.UploadProjectArchive)
protected.GET("/projects/:id/archive/download", projFileH.DownloadProjectArchive)
// Notifications (v0.20.0)
notifH := handlers.NewNotificationHandler(stores, hub)
protected.GET("/notifications", notifH.List)
@@ -612,28 +462,6 @@ func main() {
protected.PUT("/notifications/preferences/:type", notifH.SetPreference)
protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference)
// Git credentials (v0.21.4) — user-scoped, independent of workspace
// Export (v0.22.4) — pandoc-based markdown → PDF/DOCX conversion
// Data portability (v0.34.0) — user data export/import
protected.GET("/export/me", dataExport.ExportMyData)
protected.POST("/import/me", dataImport.ImportMyData)
protected.POST("/import/chatgpt", dataImport.ImportChatGPT)
// Hook: clean up storage files when channels are deleted
// Memory management (v0.18.0)
protected.GET("/memories", memH.ListMyMemories)
protected.PUT("/memories/:id", memH.UpdateMemory)
protected.DELETE("/memories/:id", memH.DeleteMemory)
protected.POST("/memories/:id/approve", memH.ApproveMemory)
protected.POST("/memories/:id/reject", memH.RejectMemory)
protected.GET("/memories/count", memH.MemoryCount)
protected.POST("/memories/compact", memH.CompactMemories)
// Teams (user: my teams)
teams := handlers.NewTeamHandler(stores, keyResolver)
protected.GET("/teams/mine", teams.MyTeams)
@@ -654,8 +482,6 @@ func main() {
// Team groups (team admins manage team-scoped groups)
teamScoped.GET("/groups", groupH.ListTeamGroups)
// Team providers
// Team connections (v0.38.1)
teamScoped.GET("/connections", teams.ListTeamConnections)
teamScoped.POST("/connections", teams.CreateTeamConnection)
@@ -671,25 +497,6 @@ func main() {
teamScoped.GET("/audit", teams.ListTeamAuditLog)
teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)
// Team usage (team admins only — usage against team-owned providers)
teamScoped.GET("/usage", teamUsage.TeamUsage)
// Team personas
teamScoped.GET("/personas", teamPersonas.ListTeamPersonas)
teamScoped.POST("/personas", teamPersonas.CreateTeamPersona)
teamScoped.PUT("/personas/:id", teamPersonas.UpdateTeamPersona)
teamScoped.DELETE("/personas/:id", teamPersonas.DeleteTeamPersona)
teamScoped.GET("/personas/:id/knowledge-bases", teamPersonas.GetTeamPersonaKBs) // v0.17.0
teamScoped.PUT("/personas/:id/knowledge-bases", teamPersonas.SetTeamPersonaKBs) // v0.17.0
teamScoped.GET("/personas/:id/tool-grants", teamPersonas.GetTeamPersonaToolGrants) // v0.28.0
teamScoped.PUT("/personas/:id/tool-grants", teamPersonas.SetTeamPersonaToolGrants) // v0.28.0
teamScoped.GET("/roles", teamRoles.ListTeamRoles)
teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
// Team workflow assignments (v0.26.4)
// Team workflows — self-service (v0.31.2)
teamWfH := handlers.NewWorkflowHandler(stores)
teamScoped.GET("/workflows", teamWfH.ListTeamWorkflows)
@@ -705,18 +512,6 @@ func main() {
teamScoped.POST("/workflows/:id/publish", teamWfH.PublishTeamWorkflow)
teamScoped.GET("/workflows/:id/versions/:version", teamWfH.GetTeamWorkflowVersion)
// Team workflow monitoring (v0.35.0)
// Team tasks — admin CRUD (v0.27.5)
}
// Team task viewing for all members (v0.27.5)
teamMemberRoutes := protected.Group("/teams/:teamId")
teamMemberRoutes.Use(middleware.RequireTeamMember(stores.Teams))
{
teamMemberRoutes.GET("/tasks", teamMemberTaskH.ListTeamTasks)
teamMemberRoutes.GET("/tasks/:id/runs", teamMemberTaskH.ListRuns)
}
// Public global settings (non-admin users can read safe subset)
@@ -756,30 +551,12 @@ func main() {
// Stats
admin.GET("/stats", adm.GetStats)
// Global Provider Configs
// Global Connections (v0.38.1)
admin.GET("/connections", adm.ListGlobalConnections)
admin.POST("/connections", adm.CreateGlobalConnection)
admin.PUT("/connections/:id", adm.UpdateGlobalConnection)
admin.DELETE("/connections/:id", adm.DeleteGlobalConnection)
// Model Catalog
// Personas (admin global)
admin.GET("/personas", personaAdm.ListAdminPersonas)
admin.POST("/personas", personaAdm.CreateAdminPersona)
admin.PUT("/personas/:id", personaAdm.UpdateAdminPersona)
admin.DELETE("/personas/:id", personaAdm.DeleteAdminPersona)
admin.GET("/personas/:id/knowledge-bases", personaAdm.GetPersonaKBs) // v0.17.0
admin.PUT("/personas/:id/knowledge-bases", personaAdm.SetPersonaKBs) // v0.17.0
admin.GET("/personas/:id/tool-grants", personaAdm.GetPersonaToolGrants) // v0.25.0
admin.PUT("/personas/:id/tool-grants", personaAdm.SetPersonaToolGrants) // v0.25.0
// Admin memory review (v0.18.0)
admin.GET("/memories/pending", adminMemH.ListPendingReview)
admin.POST("/memories/bulk-approve", adminMemH.BulkApprove)
// Teams (admin)
teamAdm := handlers.NewTeamHandler(stores, keyResolver)
admin.GET("/teams", teamAdm.ListTeams)
@@ -792,10 +569,6 @@ func main() {
admin.PUT("/teams/:id/members/:memberId", teamAdm.UpdateMember)
admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember)
// Team data export/import (v0.34.0)
admin.GET("/teams/:id/export", teamExport.ExportTeam)
admin.POST("/teams/:id/import", teamImport.ImportTeam)
// Admin broadcast (v0.28.6)
adminNotifH := handlers.NewNotificationHandler(stores, hub)
admin.POST("/notifications/broadcast", adminNotifH.Broadcast)
@@ -823,18 +596,8 @@ func main() {
admin.GET("/grants/:type/:id", groupAdm.GetResourceGrant)
admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant)
// Projects (admin — v0.19.0)
admin.GET("/projects", adminProjH.AdminList)
admin.DELETE("/projects/:id", adminProjH.Delete)
admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant)
admin.GET("/roles", rolesH.ListRoles)
admin.GET("/roles/:role", rolesH.GetRole)
admin.PUT("/roles/:role", rolesH.UpdateRole)
admin.POST("/roles/:role/test", rolesH.TestRole)
// Usage & Pricing
// Storage status
storageH := handlers.NewStorageHandler(objStore)
admin.GET("/storage/status", storageH.Status)
@@ -846,12 +609,6 @@ func main() {
emailAdm := handlers.NewAdminEmailHandler(stores)
admin.POST("/notifications/test-email", emailAdm.TestEmail)
admin.GET("/storage/orphans", fileAdm.OrphanCount)
admin.POST("/storage/cleanup", fileAdm.CleanupOrphans)
admin.GET("/storage/extraction", fileAdm.ExtractionStatus)
// Archived channels (admin — v0.23.2)
// Extensions (admin)
extAdm := handlers.NewExtensionHandler(stores)
admin.GET("/extensions", extAdm.AdminListExtensions)
@@ -873,27 +630,6 @@ func main() {
admin.PUT("/extensions/:id/secrets", extSecH.SetSecrets)
admin.DELETE("/extensions/:id/secrets", extSecH.DeleteSecrets)
admin.GET("/dashboard", dashAdm.GetDashboard)
admin.GET("/providers/health", healthAdm.GetAllProviderHealth)
admin.GET("/providers/:id/health", healthAdm.GetProviderHealth)
// Capability Overrides (admin — v0.22.0)
admin.GET("/models/:id/capabilities", capAdm.GetModelCapabilities)
admin.PUT("/models/:id/capabilities", capAdm.SetModelCapability)
admin.DELETE("/models/:id/capabilities/:overrideId", capAdm.DeleteModelCapability)
admin.GET("/capability-overrides", capAdm.ListAllOverrides)
// Provider Types (admin — v0.22.1)
admin.GET("/provider-types", handlers.GetProviderTypes)
admin.GET("/routing/policies", routingAdm.ListPolicies)
admin.GET("/routing/policies/:id", routingAdm.GetPolicy)
admin.POST("/routing/policies", routingAdm.CreatePolicy)
admin.PUT("/routing/policies/:id", routingAdm.UpdatePolicy)
admin.DELETE("/routing/policies/:id", routingAdm.DeletePolicy)
admin.POST("/routing/test", routingAdm.TestRouting)
// Package lifecycle management (v0.28.7 — replaces surfaces + extensions admin)
packagesDir := ""
if cfg.StoragePath != "" {
@@ -920,15 +656,10 @@ func main() {
admin.POST("/packages/:id/test-tool", pkgAdm.TestTool) // v0.38.2
admin.GET("/dependencies", pkgAdm.ListAllDependencies) // v0.38.2
// Package export (v0.30.0)
admin.GET("/packages/:id/export", pkgExport.ExportPackage)
// Workflow package export (v0.30.2)
wfPkgH := handlers.NewWorkflowPackageHandler(stores)
admin.GET("/workflows/:id/export", wfPkgH.ExportWorkflowPackage)
// Workflow monitoring (v0.35.0)
// Surface aliases (backward compat — same handlers)
admin.GET("/surfaces", pkgAdm.ListPackages)
admin.GET("/surfaces/:id", pkgAdm.GetPackage)
@@ -937,20 +668,10 @@ func main() {
admin.PUT("/surfaces/:id/disable", pkgAdm.DisablePackage)
admin.DELETE("/surfaces/:id", pkgAdm.DeletePackage)
// Task management — admin (v0.27.1, extended v0.27.2)
admin.GET("/tasks", taskAdm.ListAll)
admin.POST("/tasks/:id/run", taskAdm.RunNow)
admin.POST("/tasks/:id/kill", taskAdm.KillRun)
admin.DELETE("/tasks/:id", taskAdm.Delete)
admin.GET("/system-functions", taskAdm.ListSystemFunctions)
}
}
// ── Page Routes (v0.22.5) ────────────────
// Server-rendered pages via Go templates. Runs alongside the SPA.
// The chat surface is a bridge: Go renders the shell, existing JS
// builds the DOM inside it — identical behavior to index.html.
// ── Page Routes ──────────────────────────
// v0.27.0: Extension surface static assets (JS, CSS, images).
// Served without auth — same rationale as extension assets (script tags can't send headers).
// v0.28.7: on-disk path moved to /data/packages/, URL stays /surfaces/:id/ for stability.
@@ -996,32 +717,11 @@ func main() {
extAPI.Any("/*path", extAPIH.Handle)
}
// Workflow API — session participants can send messages + trigger completions
wfAPI := base.Group("/api/v1/w")
wfAPI.Use(middleware.AuthOrSession(cfg, stores, userCache))
{
wfAPI.POST("/:id/messages", wfMsgs.CreateMessage)
wfAPI.GET("/:id/messages", wfMsgs.ListMessages)
wfAPI.POST("/:id/completions", wfComp.Complete)
wfAPI.GET("/:id/form", wfForms.GetFormTemplate)
wfAPI.POST("/:id/form-submit", wfForms.SubmitForm)
}
// Workflow visitor entry (v0.26.3) — public start endpoint
// NOTE: Cannot use /api/v1/w/:scope/:slug/start — Gin's radix trie
// conflicts with /api/v1/w/:id/messages (different wildcard names at
// same path position). Separate namespace avoids the collision.
base.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor)
// v0.28.0: Webhook trigger endpoint (token-based auth, no JWT)
bp := cfg.BasePath
if bp == "" {
bp = "/"
}
log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port)
log.Printf("🔀 Switchboard Core v%s starting on port %s", Version, cfg.Port)
log.Printf(" Base path: %s", bp)
log.Printf(" Schema: %s", database.SchemaVersion())
if objStore != nil {
@@ -1038,9 +738,6 @@ func main() {
// ── Vault CLI Commands ──────────────────────
// loadSearchConfig reads search provider config from global_config and applies it.
// Falls back to DuckDuckGo (the default set in search package init) if not configured.
func runVaultCommand(subcmd string) {
cfg := config.Load()

View File

@@ -211,25 +211,10 @@ func New(cfg *config.Config, stores store.Stores) *Engine {
return e
}
// registerCoreSurfaces populates the surface manifest list with the five
// core surfaces. Extension surfaces will be appended at startup from
// extension manifests (future).
// registerCoreSurfaces populates the surface manifest list with the kernel
// surfaces. Extension surfaces are appended from DB at startup.
func (e *Engine) registerCoreSurfaces() {
e.surfaces = []SurfaceManifest{
{
ID: "chat", Route: "/", AltRoutes: []string{"/chat/:chatID"},
Title: "Chat", Template: "surface-chat", Auth: "authenticated",
Components: []string{"chat-pane", "model-selector", "user-menu"},
DataRequires: []string{"chat"},
Layout: "single", Source: "core",
},
{
ID: "notes", Route: "/notes", AltRoutes: []string{"/notes/:noteId"},
Title: "Notes", Template: "surface-notes", Auth: "authenticated",
Components: []string{"note-editor", "chat-pane"},
DataRequires: []string{"notes"},
Layout: "single", Source: "core",
},
{
ID: "admin", Route: "/admin/:section", AltRoutes: []string{"/admin"},
Title: "Admin", Template: "surface-admin", Auth: "admin",
@@ -248,16 +233,9 @@ func (e *Engine) registerCoreSurfaces() {
DataRequires: []string{"team-admin"},
Layout: "single", Source: "core",
},
{
ID: "projects", Route: "/projects/:id", AltRoutes: []string{"/projects"},
Title: "Projects", Template: "surface-projects", Auth: "authenticated",
DataRequires: []string{"projects"},
Layout: "single", Source: "core",
},
{
ID: "workflow", Route: "/w/:id",
Title: "Workflow", Template: "workflow", Auth: "session",
Components: []string{"chat-pane"},
DataRequires: []string{"workflow"},
Layout: "single", Source: "core",
},

View File

@@ -90,7 +90,7 @@ func (e *Engine) EnabledSurfaceIDs() []string {
return ids
}
// disabledRedirect returns a handler that redirects to the chat surface.
// disabledRedirect returns a handler that redirects to the root.
func (e *Engine) disabledRedirect() gin.HandlerFunc {
return func(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/")

View File

@@ -1,68 +0,0 @@
{{/*
Chat surface (v0.37.10 — Preact).
Replaced the ~387-line SPA scaffold with a single mount point.
Preact ChatSurface component handles all rendering.
*/}}
{{define "surface-chat"}}
{{/* ── Crash Catcher (visible without devtools) ── */}}
<div id="crashBanner" style="display:none;position:fixed;top:0;left:0;right:0;z-index:99999;background:#b00020;color:#fff;padding:12px 20px;font-family:monospace;font-size:13px;max-height:40vh;overflow-y:auto;">
<strong>Init Error</strong> <button onclick="this.parentElement.style.display='none'" style="float:right;background:none;border:1px solid #fff;color:#fff;cursor:pointer;padding:2px 8px;border-radius:3px">X</button>
<pre id="crashDetail" style="margin:8px 0 0;white-space:pre-wrap;font-size:12px;"></pre>
</div>
<script nonce="{{$.CSPNonce}}">
window.addEventListener('error', function(e) {
var b = document.getElementById('crashBanner');
var d = document.getElementById('crashDetail');
if (b && d) {
b.style.display = '';
d.textContent += (e.filename || '') + ':' + (e.lineno || '') + ' ' + (e.message || e) + '\n';
}
});
window.addEventListener('unhandledrejection', function(e) {
var b = document.getElementById('crashBanner');
var d = document.getElementById('crashDetail');
if (b && d) {
b.style.display = '';
d.textContent += 'Promise: ' + (e.reason?.message || e.reason || 'unknown') + '\n' + (e.reason?.stack || '') + '\n';
}
});
</script>
{{/* ── Mount Point ────────────────────────── */}}
<div id="chat-mount" style="height:100%;display:flex;">
<div style="display:flex;align-items:center;justify-content:center;width:100%;color:var(--text-2,#999);font-size:14px;">
Loading...
</div>
</div>
{{end}}
{{define "css-chat"}}
<link rel="stylesheet" href="{{.BasePath}}/css/sw-chat-surface.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/branding/custom.css" onerror="this.remove()">
{{end}}
{{define "scripts-chat"}}
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/marked.min.js"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/purify.min.js"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/codemirror/codemirror.bundle.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}">
// v0.37.10: Preact boot — same pattern as settings/admin/team-admin surfaces.
// Vendor modules: no ?v= query — hooks.module.js does a bare
// import from "./preact.module.js" internally, so the URL must match.
const { h, render } = await import('{{$.BasePath}}/js/sw/vendor/preact.module.js');
const hooksModule = await import('{{$.BasePath}}/js/sw/vendor/hooks.module.js');
const { default: htm } = await import('{{$.BasePath}}/js/sw/vendor/htm.module.js');
const html = htm.bind(h);
window.preact = window.preact || { h, render };
window.hooks = window.hooks || hooksModule;
window.html = window.html || html;
const { boot } = await import('{{$.BasePath}}/js/sw/sdk/index.js?v={{$.Version}}');
await boot();
await import('{{$.BasePath}}/js/sw/surfaces/chat/index.js?v={{$.Version}}');
</script>
{{end}}

View File

@@ -1,66 +0,0 @@
{{/*
Notes surface (v0.37.11 — Preact).
Replaced the v0.37.9 template mount with a single mount point.
Preact NotesSurface component handles all rendering.
*/}}
{{define "surface-notes"}}
{{/* ── Crash Catcher (visible without devtools) ── */}}
<div id="crashBanner" style="display:none;position:fixed;top:0;left:0;right:0;z-index:99999;background:#b00020;color:#fff;padding:12px 20px;font-family:monospace;font-size:13px;max-height:40vh;overflow-y:auto;">
<strong>Init Error</strong> <button onclick="this.parentElement.style.display='none'" style="float:right;background:none;border:1px solid #fff;color:#fff;cursor:pointer;padding:2px 8px;border-radius:3px">X</button>
<pre id="crashDetail" style="margin:8px 0 0;white-space:pre-wrap;font-size:12px;"></pre>
</div>
<script nonce="{{$.CSPNonce}}">
window.addEventListener('error', function(e) {
var b = document.getElementById('crashBanner');
var d = document.getElementById('crashDetail');
if (b && d) {
b.style.display = '';
d.textContent += (e.filename || '') + ':' + (e.lineno || '') + ' ' + (e.message || e) + '\n';
}
});
window.addEventListener('unhandledrejection', function(e) {
var b = document.getElementById('crashBanner');
var d = document.getElementById('crashDetail');
if (b && d) {
b.style.display = '';
d.textContent += 'Promise: ' + (e.reason?.message || e.reason || 'unknown') + '\n' + (e.reason?.stack || '') + '\n';
}
});
</script>
{{/* ── Mount Point ────────────────────────── */}}
<div id="notes-mount" style="height:100%;display:flex;">
<div style="display:flex;align-items:center;justify-content:center;width:100%;color:var(--text-2,#999);font-size:14px;">
Loading...
</div>
</div>
{{end}}
{{define "css-notes"}}
<link rel="stylesheet" href="{{.BasePath}}/css/sw-notes-surface.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/branding/custom.css" onerror="this.remove()">
{{end}}
{{define "scripts-notes"}}
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/marked.min.js"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/purify.min.js"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/codemirror/codemirror.bundle.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}">
// v0.37.11: Preact boot — same pattern as chat surface.
const { h, render } = await import('{{$.BasePath}}/js/sw/vendor/preact.module.js');
const hooksModule = await import('{{$.BasePath}}/js/sw/vendor/hooks.module.js');
const { default: htm } = await import('{{$.BasePath}}/js/sw/vendor/htm.module.js');
const html = htm.bind(h);
window.preact = window.preact || { h, render };
window.hooks = window.hooks || hooksModule;
window.html = window.html || html;
const { boot } = await import('{{$.BasePath}}/js/sw/sdk/index.js?v={{$.Version}}');
await boot();
await import('{{$.BasePath}}/js/sw/surfaces/notes/index.js?v={{$.Version}}');
</script>
{{end}}

View File

@@ -1,32 +0,0 @@
{{/*
Projects surface — v0.37.16: Card grid + detail with inline chat.
*/}}
{{define "surface-projects"}}
<div id="projects-mount" style="height:100%;overflow:hidden;"></div>
{{end}}
{{define "css-projects"}}
<link rel="stylesheet" href="{{.BasePath}}/css/sw-projects-surface.css?v={{.Version}}">
{{end}}
{{define "scripts-projects"}}
<script nonce="{{.CSPNonce}}">
window.__PROJECT_ID__ = '{{.Data.ProjectID}}';
</script>
<script type="module" nonce="{{.CSPNonce}}">
const { h, render } = await import('{{.BasePath}}/js/sw/vendor/preact.module.js');
const hooksModule = await import('{{.BasePath}}/js/sw/vendor/hooks.module.js');
const { default: htm } = await import('{{.BasePath}}/js/sw/vendor/htm.module.js');
const html = htm.bind(h);
window.preact = window.preact || { h, render };
window.hooks = window.hooks || hooksModule;
window.html = window.html || html;
const { boot } = await import('{{.BasePath}}/js/sw/sdk/index.js?v={{.Version}}');
await boot();
await import('{{.BasePath}}/js/sw/surfaces/projects/index.js?v={{.Version}}');
</script>
{{end}}

File diff suppressed because it is too large Load Diff