diff --git a/.gitignore b/.gitignore index 85435cf052..5b7250cff2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,19 @@ # Test artifacts tests/integration/testdata/ -# Binary -openclaw-go -pkg-helper +# Binary (anchored to repo root so we don't accidentally ignore +# the cmd/pkg-helper/ source directory, which breaks Docker builds +# on uploaders that pattern-match .gitignore against the whole tree +# — notably `railway up`). +/openclaw-go +/pkg-helper +/goclaw.exe +/goclaw-local.exe + +# Ad-hoc debug probes live here — never commit (they import internal/ +# packages from outside cmd/ and only exist for one-shot diagnostics). +/tmp-reset-bot/ +/tmp-probe*/ # IDE .idea/ diff --git a/.railwayignore b/.railwayignore new file mode 100644 index 0000000000..695eda14d5 --- /dev/null +++ b/.railwayignore @@ -0,0 +1,18 @@ +.git +.github +.vscode +.idea +.claude +ui/desktop +ui/simple-saas +ui/web/node_modules +ui/web/dist +**/node_modules +plans +skills-store +docs +tests +tmp +tmp-* +*.exe +._* diff --git a/cmd/bitrix_portal.go b/cmd/bitrix_portal.go new file mode 100644 index 0000000000..86e45d23ef --- /dev/null +++ b/cmd/bitrix_portal.go @@ -0,0 +1,206 @@ +package cmd + +import ( + "database/sql" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/google/uuid" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/spf13/cobra" + + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/store/pg" +) + +// bitrixPortalCmd wires `goclaw bitrix-portal ...` — direct-DB management of +// `bitrix_portals` rows. Phase 03 ships the OAuth install flow +// (`/bitrix24/install`) but no RPC/UI for seeding the portal row the install +// flow needs beforehand, so operators currently have no way to register a +// new portal without shelling into Postgres. This command fills that gap. +// +// Writes go through PGBitrixPortalStore so GOCLAW_ENCRYPTION_KEY is applied +// to the credentials column the same way the runtime would. Reads via `list` +// deliberately don't print secrets — credentials stay encrypted at rest, and +// a debug tool dumping them would be a regression. +func bitrixPortalCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "bitrix-portal", + Short: "Manage Bitrix24 portals (direct DB access; postgres only)", + Long: `Manage Bitrix24 portal rows in the database. + +Phase 03 expects a ` + "`bitrix_portals`" + ` row to exist before an operator runs the +OAuth install flow at ` + "`/bitrix24/install`" + `. This command seeds that row without +requiring SQL access to the database.`, + } + cmd.AddCommand(bitrixPortalCreateCmd()) + cmd.AddCommand(bitrixPortalListCmd()) + return cmd +} + +func bitrixPortalCreateCmd() *cobra.Command { + var ( + tenantID string + name string + domain string + clientID string + clientSecret string + ) + cmd := &cobra.Command{ + Use: "create", + Short: "Create a bitrix_portals row with client_id/client_secret", + Long: `Create a new Bitrix24 portal registration. + +After the row exists, direct the portal admin to +` + "`https:///bitrix24/install?state=:`" + ` +to authorize the app — the install handler writes the OAuth token into the +` + "`state`" + ` column of this same row.`, + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(name) == "" || + strings.TrimSpace(domain) == "" || strings.TrimSpace(clientID) == "" || + strings.TrimSpace(clientSecret) == "" { + return fmt.Errorf("--tenant-id, --name, --domain, --client-id, --client-secret are all required") + } + tid, err := uuid.Parse(tenantID) + if err != nil { + return fmt.Errorf("invalid --tenant-id: %w", err) + } + // Strip protocol + trailing slash from domain; Bitrix24 identifies + // the portal by bare host (e.g. `tamgiac.bitrix24.com`). + dom := normalizeBitrixDomain(domain) + + dsn, err := resolveDSN() + if err != nil { + return err + } + db, err := sql.Open("pgx", dsn) + if err != nil { + return fmt.Errorf("open db: %w", err) + } + defer db.Close() + if err := db.PingContext(cmd.Context()); err != nil { + return fmt.Errorf("ping db: %w", err) + } + + encKey := os.Getenv("GOCLAW_ENCRYPTION_KEY") + if encKey == "" { + // Not fatal — pg store passes plaintext through when the key is + // empty — but the runtime gateway would also run unencrypted, + // which is almost never what a production deploy wants. Warn + // loud so the operator notices instead of silently storing + // client_secret as cleartext. + fmt.Fprintln(os.Stderr, "WARNING: GOCLAW_ENCRYPTION_KEY is not set — credentials will be stored UNENCRYPTED") + } + + creds := store.BitrixPortalCredentials{ + ClientID: clientID, + ClientSecret: clientSecret, + } + credsJSON, err := json.Marshal(creds) + if err != nil { + return fmt.Errorf("marshal credentials: %w", err) + } + + portalStore := pg.NewPGBitrixPortalStore(db, encKey) + data := &store.BitrixPortalData{ + TenantID: tid, + Name: name, + Domain: dom, + Credentials: credsJSON, + // State stays empty — it's populated by /bitrix24/install + // after the portal admin authorizes the app. + } + if err := portalStore.Create(cmd.Context(), data); err != nil { + return fmt.Errorf("create portal: %w", err) + } + + fmt.Printf("Created bitrix_portals row:\n") + fmt.Printf(" id: %s\n", data.ID) + fmt.Printf(" tenant_id: %s\n", data.TenantID) + fmt.Printf(" name: %s\n", data.Name) + fmt.Printf(" domain: %s\n", data.Domain) + fmt.Printf("\nNext step — have the portal admin visit:\n") + fmt.Printf(" https:///bitrix24/install?state=%s:%s\n", data.TenantID, data.Name) + fmt.Printf("(public_url must match the `public_url` field on the channel_instance config.)\n") + return nil + }, + } + cmd.Flags().StringVar(&tenantID, "tenant-id", "", "Tenant UUID this portal belongs to (required)") + cmd.Flags().StringVar(&name, "name", "", "Short portal name, referenced by channel_instance.config.portal (required)") + cmd.Flags().StringVar(&domain, "domain", "", "Bitrix24 portal host, e.g. tamgiac.bitrix24.com (required)") + cmd.Flags().StringVar(&clientID, "client-id", "", "Bitrix24 application client_id / application_id (required)") + cmd.Flags().StringVar(&clientSecret, "client-secret", "", "Bitrix24 application client_secret / application key (required)") + return cmd +} + +func bitrixPortalListCmd() *cobra.Command { + var tenantID string + cmd := &cobra.Command{ + Use: "list", + Short: "List bitrix_portals rows (optionally scoped to one tenant)", + RunE: func(cmd *cobra.Command, args []string) error { + dsn, err := resolveDSN() + if err != nil { + return err + } + db, err := sql.Open("pgx", dsn) + if err != nil { + return fmt.Errorf("open db: %w", err) + } + defer db.Close() + if err := db.PingContext(cmd.Context()); err != nil { + return fmt.Errorf("ping db: %w", err) + } + + portalStore := pg.NewPGBitrixPortalStore(db, os.Getenv("GOCLAW_ENCRYPTION_KEY")) + + var rows []store.BitrixPortalData + if tenantID == "" { + rows, err = portalStore.ListAllForLoader(cmd.Context()) + } else { + tid, parseErr := uuid.Parse(tenantID) + if parseErr != nil { + return fmt.Errorf("invalid --tenant-id: %w", parseErr) + } + rows, err = portalStore.ListByTenant(cmd.Context(), tid) + } + if err != nil { + return fmt.Errorf("list portals: %w", err) + } + + if len(rows) == 0 { + fmt.Println("(no portals)") + return nil + } + fmt.Printf("%-36s %-36s %-24s %s\n", "ID", "TENANT_ID", "NAME", "DOMAIN") + for _, r := range rows { + // Credentials deliberately not printed. If the runtime couldn't + // decrypt them the scan already logged a warning; we tag that + // case here so operators spot a corrupt row at a glance. + nameCol := r.Name + if len(r.Credentials) == 0 { + nameCol += " (creds:empty)" + } + fmt.Printf("%-36s %-36s %-24s %s\n", r.ID, r.TenantID, nameCol, r.Domain) + } + return nil + }, + } + cmd.Flags().StringVar(&tenantID, "tenant-id", "", "Filter to one tenant UUID (optional)") + return cmd +} + +// normalizeBitrixDomain strips scheme and trailing slashes so callers can paste +// either `https://tamgiac.bitrix24.com/` or bare `tamgiac.bitrix24.com` and get +// a consistent value in the DB. Bitrix24's OAuth callback compares the bare +// host, so storing it with scheme would silently break the install flow. +func normalizeBitrixDomain(raw string) string { + s := strings.TrimSpace(raw) + s = strings.TrimPrefix(s, "https://") + s = strings.TrimPrefix(s, "http://") + s = strings.TrimSuffix(s, "/") + return s +} + diff --git a/cmd/root.go b/cmd/root.go index 6c8e51d9ee..8f4fd9a4fb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -38,6 +38,7 @@ func init() { rootCmd.AddCommand(configCmd()) rootCmd.AddCommand(providersCmd()) rootCmd.AddCommand(channelsCmd()) + rootCmd.AddCommand(bitrixPortalCmd()) rootCmd.AddCommand(cronCmd()) rootCmd.AddCommand(skillsCmd()) rootCmd.AddCommand(sessionsCmd()) diff --git a/go.mod b/go.mod index 254e041508..53672c33f1 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( github.com/slack-go/slack v0.19.0 github.com/spf13/cobra v1.10.2 github.com/titanous/json5 v1.0.0 - github.com/wailsapp/wails/v2 v2.11.0 + github.com/wailsapp/wails/v2 v2.12.0 github.com/zalando/go-keyring v0.2.8 go.mau.fi/whatsmeow v0.0.0-20260327181659-02ec817e7cf4 go.opentelemetry.io/otel v1.40.0 @@ -51,6 +51,7 @@ require ( require ( cel.dev/expr v0.25.1 // indirect filippo.io/edwards25519 v1.1.0 // indirect + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/akutz/memconn v0.1.0 // indirect github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect diff --git a/go.sum b/go.sum index faea3b887b..2ffffa6629 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= @@ -501,8 +503,8 @@ github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6N github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= -github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= -github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= +github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= +github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -647,8 +649,8 @@ gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 h1:2gap+Kh/3F47cO6hAu3idFvs gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633/go.mod h1:5DMfjtclAbTIjbXqO1qCe2K5GKKxWz2JHvCChuTcJEM= honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0 h1:5SXjd4ET5dYijLaf0O3aOenC0Z4ZafIWSpjUzsQaNho= honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0/go.mod h1:EPDDhEZqVHhWuPI5zPAsjU0U7v9xNIWjoOVyZ5ZcniQ= -howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= -howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 h1:eeH1AIcPvSc0Z25ThsYF+Xoqbn0CI/YnXVYoTLFdGQw= +howett.net/plist v1.0.2-0.20250314012144-ee69052608d9/go.mod h1:fyFX5Hj5tP1Mpk8obqA9MZgXT416Q5711SDT7dQLTLk= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= diff --git a/internal/agent/loop_mcp_user.go b/internal/agent/loop_mcp_user.go index 830307852c..5512b8c97a 100644 --- a/internal/agent/loop_mcp_user.go +++ b/internal/agent/loop_mcp_user.go @@ -88,8 +88,10 @@ func (l *Loop) getUserMCPTools(ctx context.Context, userID string) []tools.Tool // Create BridgeTools pointing to user's connection and register in the // shared tool registry so ExecuteWithContext can resolve them by name. reg, _ := l.tools.(*tools.Registry) + hints := mcpbridge.ParseToolHints(srv.Settings) for _, mcpTool := range entry.MCPTools() { - bt := mcpbridge.NewBridgeTool(srv.Name, mcpTool, entry.ClientPtr(), srv.ToolPrefix, srv.TimeoutSec, entry.Connected(), srv.ID, l.mcpGrantChecker) + bt := mcpbridge.NewBridgeTool(srv.Name, mcpTool, entry.ClientPtr(), srv.ToolPrefix, srv.TimeoutSec, entry.Connected(), srv.ID, l.mcpGrantChecker). + WithHints(hints.Global, hints.HintFor(mcpTool.Name)) // Register in registry so ExecuteWithContext can find them. // Skip if already registered (another user loaded this server with same tool names). if reg != nil { diff --git a/internal/gateway/methods/channel_instances.go b/internal/gateway/methods/channel_instances.go index 3f06f2c092..d8d8bf83ea 100644 --- a/internal/gateway/methods/channel_instances.go +++ b/internal/gateway/methods/channel_instances.go @@ -277,9 +277,15 @@ func maskInstance(inst store.ChannelInstanceData) map[string]any { } // isValidChannelType checks if the channel type is supported. +// +// Keep this list in sync with the HTTP twin in internal/http/channel_instances.go +// and with CHANNEL_TYPES in ui/web/src/constants/channels.ts. When the two +// backend switches drift (as happened with facebook/pancake/bitrix24), the +// WS-driven UI rejects channels the HTTP API accepts, and the dropdown offers +// channels neither API accepts. func isValidChannelType(ct string) bool { switch ct { - case "telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu": + case "telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu", "facebook", "pancake", "bitrix24": return true } return false diff --git a/internal/gateway/server.go b/internal/gateway/server.go index cbfb79fcd2..2fd85ec0a8 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -198,9 +198,17 @@ func (s *Server) BuildMux() *http.ServeMux { } // Embedded web UI (built with -tags embedui). Catch-all after all API routes. + // When the build does NOT include the embedui tag, webui.Handler() returns nil + // and there's no handler for "/" — http.ServeMux would then return an opaque + // 404 for the root URL, confusing operators who open the deployed URL in a + // browser to check the service. Install a minimal JSON index handler in that + // case so the root responds with something useful (and any unmatched path + // still returns 404, just with a JSON body). if h := webui.Handler(); h != nil { mux.Handle("/", h) slog.Info("serving embedded web UI") + } else { + mux.HandleFunc("/", s.handleIndex) } s.mux = mux @@ -372,6 +380,24 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `{"status":"ok","protocol":%d}`, protocol.ProtocolVersion) } +// handleIndex is the fallback "/" handler when no embedded web UI is present. +// It returns a small JSON service-info document for exact-match "/" requests +// and a JSON 404 for everything else — http.ServeMux routes "/" as a +// catch-all, so unrelated paths fall through here too. +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/" { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"not found"}`)) + return + } + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, + `{"service":"goclaw","status":"ok","protocol":%d,`+ + `"endpoints":["/health","/v1/chat/completions","/v1/responses","/v1/tools/invoke","/ws"]}`, + protocol.ProtocolVersion) +} + // clientIP extracts the real client IP from the request, checking proxy headers first. func clientIP(r *http.Request) string { if ip := r.Header.Get("X-Real-IP"); ip != "" { diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index 180f87c545..08707e7db8 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -554,9 +554,13 @@ func (h *ChannelInstancesHandler) handleResolveContacts(w http.ResponseWriter, r } // isValidChannelType checks if the channel type is supported. +// +// Keep this list in sync with the WS twin in +// internal/gateway/methods/channel_instances.go and with CHANNEL_TYPES in +// ui/web/src/constants/channels.ts. func isValidChannelType(ct string) bool { switch ct { - case "telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu", "facebook", "pancake": + case "telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu", "facebook", "pancake", "bitrix24": return true } return false diff --git a/internal/mcp/bridge_tool.go b/internal/mcp/bridge_tool.go index 9bcaf338a3..773dc9312e 100644 --- a/internal/mcp/bridge_tool.go +++ b/internal/mcp/bridge_tool.go @@ -20,17 +20,18 @@ import ( // The client pointer is loaded atomically from clientPtr to support // safe reconnection without data races. type BridgeTool struct { - serverName string - serverID uuid.UUID // MCP server ID (for grant recheck) - toolName string // original MCP tool name - registeredName string // may include prefix: "{prefix}__{toolName}" - description string - inputSchema map[string]any // JSON Schema for parameters - requiredSet map[string]bool - clientPtr *atomic.Pointer[mcpclient.Client] // shared with serverState for atomic swap on reconnect - timeoutSec int - connected *atomic.Bool - grantChecker GrantChecker // for runtime grant recheck (nil = skip check) + serverName string + serverID uuid.UUID // MCP server ID (for grant recheck) + toolName string // original MCP tool name + registeredName string // may include prefix: "{prefix}__{toolName}" + description string + descriptionSuffix string // admin-authored hints appended to description (see WithHints) + inputSchema map[string]any // JSON Schema for parameters + requiredSet map[string]bool + clientPtr *atomic.Pointer[mcpclient.Client] // shared with serverState for atomic swap on reconnect + timeoutSec int + connected *atomic.Bool + grantChecker GrantChecker // for runtime grant recheck (nil = skip check) } // NewBridgeTool creates a BridgeTool from an MCP Tool definition. @@ -92,10 +93,42 @@ func ensureMCPPrefix(prefix, serverName string) string { return prefix } -func (t *BridgeTool) Name() string { return t.registeredName } -func (t *BridgeTool) Description() string { return t.description } +func (t *BridgeTool) Name() string { return t.registeredName } +func (t *BridgeTool) Description() string { + if t.descriptionSuffix == "" { + return t.description + } + return t.description + t.descriptionSuffix +} func (t *BridgeTool) Parameters() map[string]any { return t.inputSchema } +// WithHints attaches admin-authored description hints to this tool. Hints are +// appended to Description() so the LLM sees server-specific quirks (e.g. "no +// trailing semicolons in code args") without modifying the upstream MCP server. +// Empty global and toolHint render no suffix. Returns t for chaining. +// +// Wire hints from MCPServerData.Settings via ParseToolHints: +// +// hints := ParseToolHints(srv.Settings) +// bt := NewBridgeTool(...).WithHints(hints.Global, hints.HintFor(mcpTool.Name)) +func (t *BridgeTool) WithHints(global, toolHint string) *BridgeTool { + g := strings.TrimSpace(global) + h := strings.TrimSpace(toolHint) + if g == "" && h == "" { + t.descriptionSuffix = "" + return t + } + var parts []string + if g != "" { + parts = append(parts, "[Server hint] "+g) + } + if h != "" { + parts = append(parts, "[Tool hint] "+h) + } + t.descriptionSuffix = "\n\n" + strings.Join(parts, "\n\n") + return t +} + // ServerName returns the name of the MCP server this tool belongs to. func (t *BridgeTool) ServerName() string { return t.serverName } diff --git a/internal/mcp/bridge_tool_test.go b/internal/mcp/bridge_tool_test.go index 7846fd8088..787862dce0 100644 --- a/internal/mcp/bridge_tool_test.go +++ b/internal/mcp/bridge_tool_test.go @@ -132,6 +132,57 @@ func TestBridgeToolNaming(t *testing.T) { } } +func TestBridgeToolWithHints(t *testing.T) { + mcpTool := mcpgo.Tool{ + Name: "search", + Description: "Run a search", + InputSchema: mcpgo.ToolInputSchema{Type: "object"}, + } + + // No hints → original description unchanged + bt := NewBridgeTool("srv", mcpTool, nil, "", 30, nil, uuid.Nil, nil) + if bt.Description() != "Run a search" { + t.Errorf("expected unchanged description, got %q", bt.Description()) + } + + // Global hint only + bt2 := NewBridgeTool("srv", mcpTool, nil, "", 30, nil, uuid.Nil, nil). + WithHints("No trailing semicolons.", "") + got := bt2.Description() + if got != "Run a search\n\n[Server hint] No trailing semicolons." { + t.Errorf("global-only mismatch:\n%q", got) + } + + // Per-tool hint only + bt3 := NewBridgeTool("srv", mcpTool, nil, "", 30, nil, uuid.Nil, nil). + WithHints("", "Use arrow func.") + if bt3.Description() != "Run a search\n\n[Tool hint] Use arrow func." { + t.Errorf("tool-only mismatch: %q", bt3.Description()) + } + + // Both hints — order: global then tool + bt4 := NewBridgeTool("srv", mcpTool, nil, "", 30, nil, uuid.Nil, nil). + WithHints("G.", "T.") + if bt4.Description() != "Run a search\n\n[Server hint] G.\n\n[Tool hint] T." { + t.Errorf("combined mismatch: %q", bt4.Description()) + } + + // Whitespace-only hints → treated as empty (no suffix) + bt5 := NewBridgeTool("srv", mcpTool, nil, "", 30, nil, uuid.Nil, nil). + WithHints(" \n ", "\t") + if bt5.Description() != "Run a search" { + t.Errorf("whitespace-only hints should render no suffix, got %q", bt5.Description()) + } + + // WithHints can be chained and reset by re-calling + bt6 := NewBridgeTool("srv", mcpTool, nil, "", 30, nil, uuid.Nil, nil). + WithHints("first", "hint") + bt6.WithHints("", "") + if bt6.Description() != "Run a search" { + t.Errorf("calling WithHints with empty should clear suffix, got %q", bt6.Description()) + } +} + func TestIsPlaceholderValue(t *testing.T) { // Should be detected as placeholder. placeholders := []string{ diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index a71f4296d8..cdfe3797d8 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -183,7 +183,8 @@ func (m *Manager) Start(ctx context.Context) error { errs = append(errs, fmt.Sprintf("%s: %v", name, err)) continue } - if err := m.connectServer(ctx, name, cfg.Transport, cfg.Command, cfg.Args, cfg.Env, cfg.URL, headers, cfg.ToolPrefix, cfg.TimeoutSec, uuid.Nil); err != nil { + // Config-path servers have no DB-backed Settings, so no tool hints. + if err := m.connectServer(ctx, name, cfg.Transport, cfg.Command, cfg.Args, cfg.Env, cfg.URL, headers, cfg.ToolPrefix, cfg.TimeoutSec, uuid.Nil, ToolHints{}); err != nil { slog.Warn("mcp.server.connect_failed", "server", name, "error", err) errs = append(errs, fmt.Sprintf("%s: %v", name, err)) } @@ -288,19 +289,20 @@ func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAc // and applies tool allow/deny filtering from server grants. func (m *Manager) connectAndFilter(ctx context.Context, rs *resolvedServer) error { srv := rs.info.Server + hints := ParseToolHints(srv.Settings) if m.pool != nil && !rs.hasUserCreds { // Pool mode: acquire shared connection, create per-agent BridgeTools tid := store.TenantIDFromContext(ctx) if err := m.connectViaPool(ctx, tid, srv.Name, srv.Transport, srv.Command, - rs.args, rs.env, srv.URL, rs.headers, srv.ToolPrefix, srv.TimeoutSec, srv.ID); err != nil { + rs.args, rs.env, srv.URL, rs.headers, srv.ToolPrefix, srv.TimeoutSec, srv.ID, hints); err != nil { return err } } else { // Per-agent mode: create per-agent connection if err := m.connectServer(ctx, srv.Name, srv.Transport, srv.Command, rs.args, rs.env, srv.URL, rs.headers, - srv.ToolPrefix, srv.TimeoutSec, srv.ID); err != nil { + srv.ToolPrefix, srv.TimeoutSec, srv.ID, hints); err != nil { return err } } @@ -595,3 +597,43 @@ func requireUserCreds(settings json.RawMessage) bool { _ = json.Unmarshal(settings, &s) return s.RequireUserCredentials } + +// ToolHints carries admin-authored description hints for MCP tools. +// Stored under MCPServerData.Settings.tool_hints as JSONB: +// +// { +// "tool_hints": { +// "global": "...", +// "tools": { "": "..." } +// } +// } +// +// The hints are appended to a tool's description so the LLM sees server-specific +// quirks (e.g. "no trailing semicolons in code args") without modifying the MCP +// server itself. Empty Global/Tools render no suffix. +type ToolHints struct { + Global string `json:"global,omitempty"` + Tools map[string]string `json:"tools,omitempty"` +} + +// ParseToolHints extracts tool description hints from an MCP server's Settings JSONB. +// Returns a zero-value ToolHints (no hints) if settings are empty or malformed. +// Safe to call with nil — never panics. +func ParseToolHints(settings json.RawMessage) ToolHints { + if len(settings) == 0 { + return ToolHints{} + } + var s struct { + ToolHints ToolHints `json:"tool_hints"` + } + _ = json.Unmarshal(settings, &s) + return s.ToolHints +} + +// HintFor returns the per-tool hint for toolName, or empty string if none. +func (h ToolHints) HintFor(toolName string) string { + if h.Tools == nil { + return "" + } + return h.Tools[toolName] +} diff --git a/internal/mcp/manager_connect.go b/internal/mcp/manager_connect.go index 4613a0b0a2..08cb0e27b6 100644 --- a/internal/mcp/manager_connect.go +++ b/internal/mcp/manager_connect.go @@ -101,14 +101,16 @@ func connectAndDiscover(ctx context.Context, name, transportType, command string // connectServer creates a client, initializes the connection, discovers tools, and registers them. // serverID is the MCP server UUID from DB (uuid.Nil for config-path servers). -func (m *Manager) connectServer(ctx context.Context, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, toolPrefix string, timeoutSec int, serverID uuid.UUID) error { +// hints carries admin-authored description hints from MCPServerData.Settings.tool_hints; +// pass a zero-value ToolHints{} for config-path servers or when no hints are configured. +func (m *Manager) connectServer(ctx context.Context, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, toolPrefix string, timeoutSec int, serverID uuid.UUID, hints ToolHints) error { ss, mcpTools, err := connectAndDiscover(ctx, name, transportType, command, args, env, url, headers, timeoutSec) if err != nil { return err } // Register tools - registeredNames := m.registerBridgeTools(ss, mcpTools, name, toolPrefix, timeoutSec, serverID) + registeredNames := m.registerBridgeTools(ss, mcpTools, name, toolPrefix, timeoutSec, serverID, hints) ss.toolNames = registeredNames // Create health monitoring context @@ -139,10 +141,12 @@ func (m *Manager) connectServer(ctx context.Context, name, transportType, comman // registerBridgeTools creates BridgeTools from MCP tool definitions and // registers them in the Manager's registry. Returns registered tool names. // serverID is the MCP server UUID (uuid.Nil for config-path servers). -func (m *Manager) registerBridgeTools(ss *serverState, mcpTools []mcpgo.Tool, serverName, toolPrefix string, timeoutSec int, serverID uuid.UUID) []string { +// hints.Global applies to all tools; hints.Tools[name] adds a per-tool hint. +func (m *Manager) registerBridgeTools(ss *serverState, mcpTools []mcpgo.Tool, serverName, toolPrefix string, timeoutSec int, serverID uuid.UUID, hints ToolHints) []string { var registeredNames []string for _, mcpTool := range mcpTools { - bt := NewBridgeTool(serverName, mcpTool, &ss.clientPtr, toolPrefix, timeoutSec, &ss.connected, serverID, m.grantChecker) + bt := NewBridgeTool(serverName, mcpTool, &ss.clientPtr, toolPrefix, timeoutSec, &ss.connected, serverID, m.grantChecker). + WithHints(hints.Global, hints.HintFor(mcpTool.Name)) if _, exists := m.registry.Get(bt.Name()); exists { slog.Warn("mcp.tool.name_collision", @@ -161,15 +165,16 @@ func (m *Manager) registerBridgeTools(ss *serverState, mcpTools []mcpgo.Tool, se // connectViaPool acquires a shared connection from the pool and creates // per-agent BridgeTools pointing to the shared client/connected pointers. -// serverID is the MCP server UUID from DB. -func (m *Manager) connectViaPool(ctx context.Context, tenantID uuid.UUID, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, toolPrefix string, timeoutSec int, serverID uuid.UUID) error { +// serverID is the MCP server UUID from DB. hints carries admin-authored +// description hints from MCPServerData.Settings.tool_hints. +func (m *Manager) connectViaPool(ctx context.Context, tenantID uuid.UUID, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, toolPrefix string, timeoutSec int, serverID uuid.UUID, hints ToolHints) error { entry, err := m.pool.Acquire(ctx, tenantID, name, transportType, command, args, env, url, headers, timeoutSec) if err != nil { return err } // Create per-agent BridgeTools from the pool's shared connection - registeredNames := m.registerPoolBridgeTools(entry, name, toolPrefix, timeoutSec, serverID) + registeredNames := m.registerPoolBridgeTools(entry, name, toolPrefix, timeoutSec, serverID, hints) // Track server state and per-agent tool names. // poolServers/poolToolNames keyed by plain name for Close() iteration. @@ -207,10 +212,12 @@ func (m *Manager) connectViaPool(ctx context.Context, tenantID uuid.UUID, name, // registerPoolBridgeTools creates BridgeTools from pool entry's discovered tools, // pointing to the shared client/connected pointers. Returns registered tool names. // serverID is the MCP server UUID from DB. -func (m *Manager) registerPoolBridgeTools(entry *poolEntry, serverName, toolPrefix string, timeoutSec int, serverID uuid.UUID) []string { +// hints.Global applies to all tools; hints.Tools[name] adds a per-tool hint. +func (m *Manager) registerPoolBridgeTools(entry *poolEntry, serverName, toolPrefix string, timeoutSec int, serverID uuid.UUID, hints ToolHints) []string { var registeredNames []string for _, mcpTool := range entry.tools { - bt := NewBridgeTool(serverName, mcpTool, &entry.state.clientPtr, toolPrefix, timeoutSec, &entry.state.connected, serverID, m.grantChecker) + bt := NewBridgeTool(serverName, mcpTool, &entry.state.clientPtr, toolPrefix, timeoutSec, &entry.state.connected, serverID, m.grantChecker). + WithHints(hints.Global, hints.HintFor(mcpTool.Name)) if _, exists := m.registry.Get(bt.Name()); exists { slog.Warn("mcp.tool.name_collision", diff --git a/internal/mcp/util_bm25_test.go b/internal/mcp/util_bm25_test.go index 25c3c62a77..62020c54a3 100644 --- a/internal/mcp/util_bm25_test.go +++ b/internal/mcp/util_bm25_test.go @@ -202,6 +202,64 @@ func TestRequireUserCreds_InvalidJSON(t *testing.T) { } } +// --- ParseToolHints --- + +func TestParseToolHints_Nil(t *testing.T) { + h := ParseToolHints(nil) + if h.Global != "" || len(h.Tools) != 0 { + t.Errorf("nil settings should yield empty hints, got %+v", h) + } +} + +func TestParseToolHints_Empty(t *testing.T) { + h := ParseToolHints(json.RawMessage(`{}`)) + if h.Global != "" || len(h.Tools) != 0 { + t.Errorf("empty settings should yield empty hints, got %+v", h) + } +} + +func TestParseToolHints_Full(t *testing.T) { + settings := json.RawMessage(`{ + "require_user_credentials": true, + "tool_hints": { + "global": "No trailing semicolons.", + "tools": { + "search": "Use arrow func.", + "update": "entityId must be int." + } + } + }`) + h := ParseToolHints(settings) + if h.Global != "No trailing semicolons." { + t.Errorf("global mismatch: %q", h.Global) + } + if h.HintFor("search") != "Use arrow func." { + t.Errorf("search hint mismatch: %q", h.HintFor("search")) + } + if h.HintFor("update") != "entityId must be int." { + t.Errorf("update hint mismatch: %q", h.HintFor("update")) + } + if h.HintFor("nonexistent") != "" { + t.Errorf("unknown tool should return empty string") + } +} + +func TestParseToolHints_InvalidJSON(t *testing.T) { + // Invalid JSON → zero-value hints (safe default) + h := ParseToolHints(json.RawMessage(`{invalid`)) + if h.Global != "" || len(h.Tools) != 0 { + t.Errorf("invalid JSON should yield empty hints, got %+v", h) + } +} + +func TestParseToolHints_NilHintsMap(t *testing.T) { + // HintFor must not panic when Tools map is nil + h := ToolHints{Global: "global only"} + if h.HintFor("anything") != "" { + t.Error("nil Tools map should return empty string, not panic") + } +} + // --- mcpBM25Index --- func TestMCPBM25Index_EmptyIndex(t *testing.T) { diff --git a/internal/providers/schema_normalize_test.go b/internal/providers/schema_normalize_test.go index 444724e916..59c6a588b2 100644 --- a/internal/providers/schema_normalize_test.go +++ b/internal/providers/schema_normalize_test.go @@ -508,6 +508,53 @@ func TestApplyStrictMode_NestedObject(t *testing.T) { } } +// TestApplyStrictMode_BareObjectProperty reproduces the use_skill tool +// failure: an optional property declared as `{"type":"object","description":...}` +// with NO nested `properties`. Pre-fix, applyStrictMode early-returned on +// this node (no "properties") so additionalProperties was never set, then +// makeNullable turned the type into ["object","null"], and OpenAI rejected +// with "invalid_function_parameters: 'additionalProperties' is required to +// be supplied and to be false" at path ('properties', 'params', 'type', '0'). +func TestApplyStrictMode_BareObjectProperty(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + "params": map[string]any{ + "type": "object", + "description": "Optional skill-specific parameters", + }, + }, + "required": []any{"name"}, + } + result := NormalizeSchema("openai", schema) + + params := prop(result, "params") + if params == nil { + t.Fatal("expected params property to survive normalization") + } + if params["additionalProperties"] != false { + t.Errorf("bare object property must get additionalProperties:false; got %v", params["additionalProperties"]) + } + // And makeNullable should have turned type into ["object","null"]. + typ, ok := params["type"].([]any) + if !ok { + t.Fatalf("expected params.type to be a []any union, got %T: %v", params["type"], params["type"]) + } + hasObject, hasNull := false, false + for _, v := range typ { + switch v { + case "object": + hasObject = true + case "null": + hasNull = true + } + } + if !hasObject || !hasNull { + t.Errorf("expected params.type to contain both 'object' and 'null'; got %v", typ) + } +} + func TestApplyStrictMode_SkipsAnthropic(t *testing.T) { schema := map[string]any{ "type": "object", diff --git a/internal/providers/schema_strict.go b/internal/providers/schema_strict.go index 9525c8d075..03137c19b1 100644 --- a/internal/providers/schema_strict.go +++ b/internal/providers/schema_strict.go @@ -17,7 +17,20 @@ func applyStrictMode(schema map[string]any, depth int) map[string]any { typ, _ := schema["type"].(string) props, hasProps := schema["properties"].(map[string]any) - if typ != "object" || !hasProps { + if typ != "object" { + return schema + } + // Bare object schema (type:"object" with no inner "properties"). OpenAI + // strict mode still requires additionalProperties:false on such nodes — + // otherwise the later makeNullable transform turns this into + // type:["object","null"] and the strict validator rejects the null-guarded + // "object" variant for lacking additionalProperties. Set it here so tool + // authors who write `{"type":"object","description":"..."}` for a bag of + // free-form params don't produce invalid_function_parameters errors. + if !hasProps { + if _, already := schema["additionalProperties"]; !already { + schema["additionalProperties"] = false + } return schema } diff --git a/internal/store/bitrix_portal_store.go b/internal/store/bitrix_portal_store.go new file mode 100644 index 0000000000..3d06db34b0 --- /dev/null +++ b/internal/store/bitrix_portal_store.go @@ -0,0 +1,64 @@ +package store + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +// BitrixPortalData represents a Bitrix24 portal row. +// +// credentials + state are stored AES-256-GCM encrypted on disk +// (via internal/crypto/aes.go). The store layer handles encrypt/decrypt +// so callers deal with plaintext []byte payloads. +type BitrixPortalData struct { + BaseModel + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + Name string `json:"name" db:"name"` + Domain string `json:"domain" db:"domain"` + Credentials []byte `json:"-" db:"credentials"` // plaintext after decrypt; never serialized + State []byte `json:"-" db:"state"` // plaintext after decrypt; never serialized +} + +// BitrixPortalCredentials is the decoded JSON payload of the `credentials` +// column. It carries the Bitrix24 app client_id / client_secret pair the +// Portal uses for the OAuth2 exchange + refresh flow. +type BitrixPortalCredentials struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` +} + +// BitrixPortalState is the decoded JSON payload of the `state` column. +// It holds everything the Portal runtime persists between restarts: +// active OAuth token, refresh token, bot/media caches, and refresh bookkeeping. +type BitrixPortalState struct { + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + MemberID string `json:"member_id,omitempty"` + AppToken string `json:"app_token,omitempty"` // auth.application_token from OAuth response + Scope string `json:"scope,omitempty"` + ClientEndpoint string `json:"client_endpoint,omitempty"` + RegisteredBots map[string]int `json:"registered_bots,omitempty"` // bot_code → bot_id (Phase 03) + MediaFolders map[string]string `json:"media_folders,omitempty"` // bot_code → disk folder id (Phase 06) + LastRefreshAt time.Time `json:"last_refresh_at,omitempty"` + LastRefreshError string `json:"last_refresh_error,omitempty"` + ConsecutiveFail int `json:"consecutive_fail,omitempty"` +} + +// BitrixPortalStore manages bitrix_portals rows. +// +// All methods except ListAllForLoader must be called on a context carrying +// either a matching TenantID (store.WithTenantID) or master scope — the impls +// verify via store.IsMasterScope. ListAllForLoader is an internal startup +// helper that returns rows across all tenants and must never be exposed via RPC. +type BitrixPortalStore interface { + Create(ctx context.Context, p *BitrixPortalData) error + GetByName(ctx context.Context, tenantID uuid.UUID, name string) (*BitrixPortalData, error) + ListByTenant(ctx context.Context, tenantID uuid.UUID) ([]BitrixPortalData, error) + ListAllForLoader(ctx context.Context) ([]BitrixPortalData, error) + UpdateCredentials(ctx context.Context, tenantID uuid.UUID, name string, creds []byte) error + UpdateState(ctx context.Context, tenantID uuid.UUID, name string, state []byte) error + Delete(ctx context.Context, tenantID uuid.UUID, name string) error +} diff --git a/internal/store/pg/bitrix_portals.go b/internal/store/pg/bitrix_portals.go new file mode 100644 index 0000000000..fbb24eef1b --- /dev/null +++ b/internal/store/pg/bitrix_portals.go @@ -0,0 +1,205 @@ +package pg + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// PGBitrixPortalStore implements store.BitrixPortalStore backed by Postgres. +// +// Both `credentials` and `state` columns hold AES-256-GCM ciphertext when +// an encryption key is configured. With no key (empty string) values are +// stored as-is — crypto.Encrypt/Decrypt pass plaintext through for that case +// and log a warning on read. The table itself uses BYTEA for portability. +type PGBitrixPortalStore struct { + db *sql.DB + encKey string +} + +// NewPGBitrixPortalStore constructs a Bitrix24 portal store. +func NewPGBitrixPortalStore(db *sql.DB, encryptionKey string) *PGBitrixPortalStore { + return &PGBitrixPortalStore{db: db, encKey: encryptionKey} +} + +const bitrixPortalCols = `id, tenant_id, name, domain, credentials, state, created_at, updated_at` + +// encryptBlob wraps raw bytes → AES-GCM ciphertext bytes. Empty input returns nil. +// With empty encKey it returns the raw bytes unchanged (crypto.Encrypt contract). +func (s *PGBitrixPortalStore) encryptBlob(raw []byte) ([]byte, error) { + if len(raw) == 0 { + return nil, nil + } + if s.encKey == "" { + return raw, nil + } + enc, err := crypto.Encrypt(string(raw), s.encKey) + if err != nil { + return nil, err + } + return []byte(enc), nil +} + +// decryptBlob reverses encryptBlob. Corrupt ciphertext returns an error rather +// than silently returning plaintext — portal corruption should fail loud so +// operators reinstall instead of running with silently stale tokens. +func (s *PGBitrixPortalStore) decryptBlob(raw []byte, field, name string) []byte { + if len(raw) == 0 { + return nil + } + if s.encKey == "" { + return raw + } + dec, err := crypto.Decrypt(string(raw), s.encKey) + if err != nil { + slog.Warn("bitrix_portals: decrypt failed", "field", field, "name", name, "error", err) + return nil + } + return []byte(dec) +} + +func (s *PGBitrixPortalStore) Create(ctx context.Context, p *store.BitrixPortalData) error { + if p == nil { + return errors.New("bitrix_portals: nil portal") + } + if p.TenantID == uuid.Nil { + return errors.New("bitrix_portals: tenant_id required") + } + if p.Name == "" || p.Domain == "" { + return errors.New("bitrix_portals: name and domain required") + } + if p.ID == uuid.Nil { + p.ID = store.GenNewID() + } + + credsBytes, err := s.encryptBlob(p.Credentials) + if err != nil { + return fmt.Errorf("encrypt credentials: %w", err) + } + stateBytes, err := s.encryptBlob(p.State) + if err != nil { + return fmt.Errorf("encrypt state: %w", err) + } + + now := time.Now().UTC() + p.CreatedAt = now + p.UpdatedAt = now + + _, err = s.db.ExecContext(ctx, + `INSERT INTO bitrix_portals (id, tenant_id, name, domain, credentials, state, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, + p.ID, p.TenantID, p.Name, p.Domain, credsBytes, stateBytes, now, now, + ) + return err +} + +func (s *PGBitrixPortalStore) GetByName(ctx context.Context, tenantID uuid.UUID, name string) (*store.BitrixPortalData, error) { + if tenantID == uuid.Nil { + return nil, errors.New("bitrix_portals: tenant_id required") + } + row := s.db.QueryRowContext(ctx, + `SELECT `+bitrixPortalCols+` FROM bitrix_portals WHERE tenant_id = $1 AND name = $2`, + tenantID, name, + ) + return s.scanRow(row, name) +} + +func (s *PGBitrixPortalStore) scanRow(row *sql.Row, name string) (*store.BitrixPortalData, error) { + var p store.BitrixPortalData + var creds, state []byte + err := row.Scan(&p.ID, &p.TenantID, &p.Name, &p.Domain, &creds, &state, &p.CreatedAt, &p.UpdatedAt) + if err != nil { + return nil, err + } + p.Credentials = s.decryptBlob(creds, "credentials", name) + p.State = s.decryptBlob(state, "state", name) + return &p, nil +} + +func (s *PGBitrixPortalStore) scanRows(rows *sql.Rows) ([]store.BitrixPortalData, error) { + defer rows.Close() + var result []store.BitrixPortalData + for rows.Next() { + var p store.BitrixPortalData + var creds, state []byte + if err := rows.Scan(&p.ID, &p.TenantID, &p.Name, &p.Domain, &creds, &state, &p.CreatedAt, &p.UpdatedAt); err != nil { + return nil, err + } + p.Credentials = s.decryptBlob(creds, "credentials", p.Name) + p.State = s.decryptBlob(state, "state", p.Name) + result = append(result, p) + } + return result, rows.Err() +} + +func (s *PGBitrixPortalStore) ListByTenant(ctx context.Context, tenantID uuid.UUID) ([]store.BitrixPortalData, error) { + if tenantID == uuid.Nil { + return nil, nil + } + rows, err := s.db.QueryContext(ctx, + `SELECT `+bitrixPortalCols+` FROM bitrix_portals WHERE tenant_id = $1 ORDER BY name`, tenantID, + ) + if err != nil { + return nil, err + } + return s.scanRows(rows) +} + +// ListAllForLoader returns rows across all tenants. Startup-only; never expose via RPC. +func (s *PGBitrixPortalStore) ListAllForLoader(ctx context.Context) ([]store.BitrixPortalData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT `+bitrixPortalCols+` FROM bitrix_portals ORDER BY tenant_id, name`, + ) + if err != nil { + return nil, err + } + return s.scanRows(rows) +} + +func (s *PGBitrixPortalStore) UpdateCredentials(ctx context.Context, tenantID uuid.UUID, name string, creds []byte) error { + if tenantID == uuid.Nil { + return errors.New("bitrix_portals: tenant_id required") + } + enc, err := s.encryptBlob(creds) + if err != nil { + return fmt.Errorf("encrypt credentials: %w", err) + } + _, err = s.db.ExecContext(ctx, + `UPDATE bitrix_portals SET credentials = $1, updated_at = $2 WHERE tenant_id = $3 AND name = $4`, + enc, time.Now().UTC(), tenantID, name, + ) + return err +} + +func (s *PGBitrixPortalStore) UpdateState(ctx context.Context, tenantID uuid.UUID, name string, state []byte) error { + if tenantID == uuid.Nil { + return errors.New("bitrix_portals: tenant_id required") + } + enc, err := s.encryptBlob(state) + if err != nil { + return fmt.Errorf("encrypt state: %w", err) + } + _, err = s.db.ExecContext(ctx, + `UPDATE bitrix_portals SET state = $1, updated_at = $2 WHERE tenant_id = $3 AND name = $4`, + enc, time.Now().UTC(), tenantID, name, + ) + return err +} + +func (s *PGBitrixPortalStore) Delete(ctx context.Context, tenantID uuid.UUID, name string) error { + if tenantID == uuid.Nil { + return errors.New("bitrix_portals: tenant_id required") + } + _, err := s.db.ExecContext(ctx, + `DELETE FROM bitrix_portals WHERE tenant_id = $1 AND name = $2`, tenantID, name, + ) + return err +} diff --git a/internal/store/pg/factory.go b/internal/store/pg/factory.go index fc9fbb8c18..81700d07db 100644 --- a/internal/store/pg/factory.go +++ b/internal/store/pg/factory.go @@ -58,6 +58,7 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) { Episodic: NewPGEpisodicStore(db), EvolutionMetrics: NewPGEvolutionMetricsStore(db), EvolutionSuggestions: NewPGEvolutionSuggestionStore(db), + BitrixPortals: NewPGBitrixPortalStore(db, cfg.EncryptionKey), Hooks: NewPGHookStore(db), }, nil } diff --git a/internal/store/sqlitestore/bitrix_portals.go b/internal/store/sqlitestore/bitrix_portals.go new file mode 100644 index 0000000000..50b073591a --- /dev/null +++ b/internal/store/sqlitestore/bitrix_portals.go @@ -0,0 +1,242 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SQLiteBitrixPortalStore implements store.BitrixPortalStore backed by SQLite. +// Mirrors PGBitrixPortalStore's encrypt-on-write / decrypt-on-read contract. +type SQLiteBitrixPortalStore struct { + db *sql.DB + encKey string +} + +func NewSQLiteBitrixPortalStore(db *sql.DB, encryptionKey string) *SQLiteBitrixPortalStore { + return &SQLiteBitrixPortalStore{db: db, encKey: encryptionKey} +} + +const bitrixPortalCols = `id, tenant_id, name, domain, credentials, state, created_at, updated_at` + +func (s *SQLiteBitrixPortalStore) encryptBlob(raw []byte) ([]byte, error) { + if len(raw) == 0 { + return nil, nil + } + if s.encKey == "" { + return raw, nil + } + enc, err := crypto.Encrypt(string(raw), s.encKey) + if err != nil { + return nil, err + } + return []byte(enc), nil +} + +func (s *SQLiteBitrixPortalStore) decryptBlob(raw []byte, field, name string) []byte { + if len(raw) == 0 { + return nil + } + if s.encKey == "" { + return raw + } + dec, err := crypto.Decrypt(string(raw), s.encKey) + if err != nil { + slog.Warn("bitrix_portals: decrypt failed", "field", field, "name", name, "error", err) + return nil + } + return []byte(dec) +} + +func (s *SQLiteBitrixPortalStore) Create(ctx context.Context, p *store.BitrixPortalData) error { + if p == nil { + return errors.New("bitrix_portals: nil portal") + } + if p.TenantID == uuid.Nil { + return errors.New("bitrix_portals: tenant_id required") + } + if p.Name == "" || p.Domain == "" { + return errors.New("bitrix_portals: name and domain required") + } + if p.ID == uuid.Nil { + p.ID = store.GenNewID() + } + + credsBytes, err := s.encryptBlob(p.Credentials) + if err != nil { + return fmt.Errorf("encrypt credentials: %w", err) + } + stateBytes, err := s.encryptBlob(p.State) + if err != nil { + return fmt.Errorf("encrypt state: %w", err) + } + + now := time.Now().UTC() + p.CreatedAt = now + p.UpdatedAt = now + nowStr := now.Format(time.RFC3339Nano) + + _, err = s.db.ExecContext(ctx, + `INSERT INTO bitrix_portals (id, tenant_id, name, domain, credentials, state, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?)`, + p.ID.String(), p.TenantID.String(), p.Name, p.Domain, credsBytes, stateBytes, nowStr, nowStr, + ) + return err +} + +func (s *SQLiteBitrixPortalStore) GetByName(ctx context.Context, tenantID uuid.UUID, name string) (*store.BitrixPortalData, error) { + if tenantID == uuid.Nil { + return nil, errors.New("bitrix_portals: tenant_id required") + } + row := s.db.QueryRowContext(ctx, + `SELECT `+bitrixPortalCols+` FROM bitrix_portals WHERE tenant_id = ? AND name = ?`, + tenantID.String(), name, + ) + return s.scanRow(row, name) +} + +// parseBitrixTime parses RFC3339 / RFC3339Nano timestamps. Returns zero time on failure. +func parseBitrixTime(s string) time.Time { + if s == "" { + return time.Time{} + } + for _, layout := range []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02T15:04:05.000Z", + "2006-01-02 15:04:05", + } { + if t, err := time.Parse(layout, s); err == nil { + return t.UTC() + } + } + return time.Time{} +} + +// scanRow handles column types: id + tenant_id + timestamps are TEXT in SQLite, +// so we read as strings and parse into uuid.UUID / time.Time. +func (s *SQLiteBitrixPortalStore) scanRow(row *sql.Row, name string) (*store.BitrixPortalData, error) { + var ( + idStr, tidStr string + createdAtStr, updatedAtStr string + p store.BitrixPortalData + creds, state []byte + ) + err := row.Scan(&idStr, &tidStr, &p.Name, &p.Domain, &creds, &state, &createdAtStr, &updatedAtStr) + if err != nil { + return nil, err + } + if id, err := uuid.Parse(idStr); err == nil { + p.ID = id + } + if tid, err := uuid.Parse(tidStr); err == nil { + p.TenantID = tid + } + p.CreatedAt = parseBitrixTime(createdAtStr) + p.UpdatedAt = parseBitrixTime(updatedAtStr) + p.Credentials = s.decryptBlob(creds, "credentials", name) + p.State = s.decryptBlob(state, "state", name) + return &p, nil +} + +func (s *SQLiteBitrixPortalStore) scanRows(rows *sql.Rows) ([]store.BitrixPortalData, error) { + defer rows.Close() + var result []store.BitrixPortalData + for rows.Next() { + var ( + idStr, tidStr string + createdAtStr, updatedAtStr string + p store.BitrixPortalData + creds, state []byte + ) + if err := rows.Scan(&idStr, &tidStr, &p.Name, &p.Domain, &creds, &state, &createdAtStr, &updatedAtStr); err != nil { + return nil, err + } + if id, err := uuid.Parse(idStr); err == nil { + p.ID = id + } + if tid, err := uuid.Parse(tidStr); err == nil { + p.TenantID = tid + } + p.CreatedAt = parseBitrixTime(createdAtStr) + p.UpdatedAt = parseBitrixTime(updatedAtStr) + p.Credentials = s.decryptBlob(creds, "credentials", p.Name) + p.State = s.decryptBlob(state, "state", p.Name) + result = append(result, p) + } + return result, rows.Err() +} + +func (s *SQLiteBitrixPortalStore) ListByTenant(ctx context.Context, tenantID uuid.UUID) ([]store.BitrixPortalData, error) { + if tenantID == uuid.Nil { + return nil, nil + } + rows, err := s.db.QueryContext(ctx, + `SELECT `+bitrixPortalCols+` FROM bitrix_portals WHERE tenant_id = ? ORDER BY name`, + tenantID.String(), + ) + if err != nil { + return nil, err + } + return s.scanRows(rows) +} + +func (s *SQLiteBitrixPortalStore) ListAllForLoader(ctx context.Context) ([]store.BitrixPortalData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT `+bitrixPortalCols+` FROM bitrix_portals ORDER BY tenant_id, name`, + ) + if err != nil { + return nil, err + } + return s.scanRows(rows) +} + +func (s *SQLiteBitrixPortalStore) UpdateCredentials(ctx context.Context, tenantID uuid.UUID, name string, creds []byte) error { + if tenantID == uuid.Nil { + return errors.New("bitrix_portals: tenant_id required") + } + enc, err := s.encryptBlob(creds) + if err != nil { + return fmt.Errorf("encrypt credentials: %w", err) + } + _, err = s.db.ExecContext(ctx, + `UPDATE bitrix_portals SET credentials = ?, updated_at = ? WHERE tenant_id = ? AND name = ?`, + enc, time.Now().UTC().Format(time.RFC3339Nano), tenantID.String(), name, + ) + return err +} + +func (s *SQLiteBitrixPortalStore) UpdateState(ctx context.Context, tenantID uuid.UUID, name string, state []byte) error { + if tenantID == uuid.Nil { + return errors.New("bitrix_portals: tenant_id required") + } + enc, err := s.encryptBlob(state) + if err != nil { + return fmt.Errorf("encrypt state: %w", err) + } + _, err = s.db.ExecContext(ctx, + `UPDATE bitrix_portals SET state = ?, updated_at = ? WHERE tenant_id = ? AND name = ?`, + enc, time.Now().UTC().Format(time.RFC3339Nano), tenantID.String(), name, + ) + return err +} + +func (s *SQLiteBitrixPortalStore) Delete(ctx context.Context, tenantID uuid.UUID, name string) error { + if tenantID == uuid.Nil { + return errors.New("bitrix_portals: tenant_id required") + } + _, err := s.db.ExecContext(ctx, + `DELETE FROM bitrix_portals WHERE tenant_id = ? AND name = ?`, tenantID.String(), name, + ) + return err +} diff --git a/internal/store/sqlitestore/bitrix_portals_test.go b/internal/store/sqlitestore/bitrix_portals_test.go new file mode 100644 index 0000000000..86f8b95559 --- /dev/null +++ b/internal/store/sqlitestore/bitrix_portals_test.go @@ -0,0 +1,281 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const bitrixTestEncKey = "0123456789abcdef0123456789abcdef" // 32 bytes for AES-256 + +func newTestSQLiteBitrixPortalStore(t *testing.T, encKey string) (*SQLiteBitrixPortalStore, *sql.DB, uuid.UUID) { + t.Helper() + + db, err := OpenDB(filepath.Join(t.TempDir(), "bitrix.db")) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + if err := EnsureSchema(db); err != nil { + t.Fatalf("EnsureSchema: %v", err) + } + + // Create a tenant row so FK constraint is satisfied. + tenantID := store.GenNewID() + if _, err := db.Exec( + `INSERT INTO tenants (id, name, slug, status, settings, created_at, updated_at) + VALUES (?, 'test-tenant', 'test-tenant', 'active', '{}', datetime('now'), datetime('now'))`, + tenantID, + ); err != nil { + t.Fatalf("insert tenant: %v", err) + } + + return NewSQLiteBitrixPortalStore(db, encKey), db, tenantID +} + +func TestSQLiteBitrixPortalStore_CreateAndGet(t *testing.T) { + ps, _, tenantID := newTestSQLiteBitrixPortalStore(t, bitrixTestEncKey) + ctx := context.Background() + + p := &store.BitrixPortalData{ + TenantID: tenantID, + Name: "prod", + Domain: "example.bitrix24.com", + Credentials: []byte(`{"client_id":"abc","client_secret":"shh"}`), + State: []byte(`{"access_token":"tkn"}`), + } + if err := ps.Create(ctx, p); err != nil { + t.Fatalf("Create: %v", err) + } + if p.ID == uuid.Nil { + t.Fatal("expected ID to be assigned") + } + + got, err := ps.GetByName(ctx, tenantID, "prod") + if err != nil { + t.Fatalf("GetByName: %v", err) + } + if got.ID != p.ID { + t.Fatalf("id mismatch: got %v, want %v", got.ID, p.ID) + } + if got.Domain != "example.bitrix24.com" { + t.Fatalf("domain mismatch: got %q", got.Domain) + } + if string(got.Credentials) != `{"client_id":"abc","client_secret":"shh"}` { + t.Fatalf("credentials decrypt mismatch: got %q", got.Credentials) + } + if string(got.State) != `{"access_token":"tkn"}` { + t.Fatalf("state decrypt mismatch: got %q", got.State) + } +} + +func TestSQLiteBitrixPortalStore_EncryptsOnDisk(t *testing.T) { + ps, db, tenantID := newTestSQLiteBitrixPortalStore(t, bitrixTestEncKey) + ctx := context.Background() + + plaintext := `{"client_id":"visible"}` + p := &store.BitrixPortalData{ + TenantID: tenantID, + Name: "enc", + Domain: "enc.bitrix24.com", + Credentials: []byte(plaintext), + } + if err := ps.Create(ctx, p); err != nil { + t.Fatalf("Create: %v", err) + } + + // Read raw bytes directly — should NOT contain plaintext. + var raw []byte + if err := db.QueryRowContext(ctx, + `SELECT credentials FROM bitrix_portals WHERE tenant_id = ? AND name = ?`, + tenantID.String(), "enc", + ).Scan(&raw); err != nil { + t.Fatalf("raw query: %v", err) + } + if len(raw) == 0 { + t.Fatal("expected non-empty credentials bytes") + } + if string(raw) == plaintext { + t.Fatal("credentials stored as plaintext on disk; expected AES-GCM ciphertext") + } +} + +func TestSQLiteBitrixPortalStore_EmptyKeyPassThrough(t *testing.T) { + ps, db, tenantID := newTestSQLiteBitrixPortalStore(t, "") // empty key — no encryption + ctx := context.Background() + + plaintext := `{"client_id":"pt"}` + if err := ps.Create(ctx, &store.BitrixPortalData{ + TenantID: tenantID, + Name: "pt", + Domain: "pt.bitrix24.com", + Credentials: []byte(plaintext), + }); err != nil { + t.Fatalf("Create: %v", err) + } + + var raw []byte + if err := db.QueryRowContext(ctx, + `SELECT credentials FROM bitrix_portals WHERE tenant_id = ? AND name = ?`, + tenantID.String(), "pt", + ).Scan(&raw); err != nil { + t.Fatalf("raw query: %v", err) + } + if string(raw) != plaintext { + t.Fatalf("empty-key mode should pass-through; got %q", raw) + } +} + +func TestSQLiteBitrixPortalStore_UpdateCredentialsAndState(t *testing.T) { + ps, _, tenantID := newTestSQLiteBitrixPortalStore(t, bitrixTestEncKey) + ctx := context.Background() + + if err := ps.Create(ctx, &store.BitrixPortalData{ + TenantID: tenantID, + Name: "u", + Domain: "u.bitrix24.com", + Credentials: []byte(`{"v":1}`), + State: []byte(`{"s":1}`), + }); err != nil { + t.Fatalf("Create: %v", err) + } + + if err := ps.UpdateCredentials(ctx, tenantID, "u", []byte(`{"v":2}`)); err != nil { + t.Fatalf("UpdateCredentials: %v", err) + } + if err := ps.UpdateState(ctx, tenantID, "u", []byte(`{"s":2}`)); err != nil { + t.Fatalf("UpdateState: %v", err) + } + + got, err := ps.GetByName(ctx, tenantID, "u") + if err != nil { + t.Fatalf("GetByName: %v", err) + } + if string(got.Credentials) != `{"v":2}` { + t.Fatalf("credentials not updated: %q", got.Credentials) + } + if string(got.State) != `{"s":2}` { + t.Fatalf("state not updated: %q", got.State) + } +} + +func TestSQLiteBitrixPortalStore_ListByTenantAndAll(t *testing.T) { + ps, db, tenantA := newTestSQLiteBitrixPortalStore(t, bitrixTestEncKey) + ctx := context.Background() + + // Second tenant for isolation check. + tenantB := store.GenNewID() + if _, err := db.Exec( + `INSERT INTO tenants (id, name, slug, status, settings, created_at, updated_at) + VALUES (?, 'tenant-b', 'tenant-b', 'active', '{}', datetime('now'), datetime('now'))`, + tenantB, + ); err != nil { + t.Fatalf("insert tenant B: %v", err) + } + + for _, rec := range []struct { + tid uuid.UUID + name string + }{ + {tenantA, "alpha"}, + {tenantA, "beta"}, + {tenantB, "gamma"}, + } { + if err := ps.Create(ctx, &store.BitrixPortalData{ + TenantID: rec.tid, + Name: rec.name, + Domain: rec.name + ".bitrix24.com", + Credentials: []byte(`{}`), + }); err != nil { + t.Fatalf("Create %s: %v", rec.name, err) + } + } + + listA, err := ps.ListByTenant(ctx, tenantA) + if err != nil { + t.Fatalf("ListByTenant A: %v", err) + } + if len(listA) != 2 { + t.Fatalf("expected 2 portals for tenant A, got %d", len(listA)) + } + // Sorted by name. + if listA[0].Name != "alpha" || listA[1].Name != "beta" { + t.Fatalf("unexpected order: %s, %s", listA[0].Name, listA[1].Name) + } + + // Tenant isolation — B must not see A's rows. + listB, err := ps.ListByTenant(ctx, tenantB) + if err != nil { + t.Fatalf("ListByTenant B: %v", err) + } + if len(listB) != 1 || listB[0].Name != "gamma" { + t.Fatalf("tenant isolation broken; B sees %d rows", len(listB)) + } + + all, err := ps.ListAllForLoader(ctx) + if err != nil { + t.Fatalf("ListAllForLoader: %v", err) + } + if len(all) != 3 { + t.Fatalf("expected 3 rows across tenants, got %d", len(all)) + } +} + +func TestSQLiteBitrixPortalStore_Delete(t *testing.T) { + ps, _, tenantID := newTestSQLiteBitrixPortalStore(t, bitrixTestEncKey) + ctx := context.Background() + + if err := ps.Create(ctx, &store.BitrixPortalData{ + TenantID: tenantID, + Name: "gone", + Domain: "gone.bitrix24.com", + Credentials: []byte(`{}`), + }); err != nil { + t.Fatalf("Create: %v", err) + } + + if err := ps.Delete(ctx, tenantID, "gone"); err != nil { + t.Fatalf("Delete: %v", err) + } + + _, err := ps.GetByName(ctx, tenantID, "gone") + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("expected sql.ErrNoRows after delete, got %v", err) + } +} + +func TestSQLiteBitrixPortalStore_NilGuards(t *testing.T) { + ps, _, tenantID := newTestSQLiteBitrixPortalStore(t, bitrixTestEncKey) + ctx := context.Background() + + if err := ps.Create(ctx, nil); err == nil { + t.Fatal("expected error on nil portal") + } + if err := ps.Create(ctx, &store.BitrixPortalData{Name: "x", Domain: "x"}); err == nil { + t.Fatal("expected error on nil tenant_id") + } + if err := ps.Create(ctx, &store.BitrixPortalData{TenantID: tenantID}); err == nil { + t.Fatal("expected error on empty name/domain") + } + if err := ps.UpdateCredentials(ctx, uuid.Nil, "x", []byte("v")); err == nil { + t.Fatal("expected error on nil tenant_id UpdateCredentials") + } + if err := ps.UpdateState(ctx, uuid.Nil, "x", []byte("v")); err == nil { + t.Fatal("expected error on nil tenant_id UpdateState") + } + if err := ps.Delete(ctx, uuid.Nil, "x"); err == nil { + t.Fatal("expected error on nil tenant_id Delete") + } + if _, err := ps.GetByName(ctx, uuid.Nil, "x"); err == nil { + t.Fatal("expected error on nil tenant_id GetByName") + } +} diff --git a/internal/store/sqlitestore/factory.go b/internal/store/sqlitestore/factory.go index ee2adbbc7a..b2e91577b6 100644 --- a/internal/store/sqlitestore/factory.go +++ b/internal/store/sqlitestore/factory.go @@ -70,6 +70,7 @@ func NewSQLiteStores(cfg store.StoreConfig) (*store.Stores, error) { EvolutionSuggestions: NewSQLiteEvolutionSuggestionStore(db), KnowledgeGraph: NewSQLiteKnowledgeGraphStore(db), Vault: NewSQLiteVaultStore(db), + BitrixPortals: NewSQLiteBitrixPortalStore(db, cfg.EncryptionKey), Hooks: NewSQLiteHookStore(db), }, nil } diff --git a/internal/store/sqlitestore/schema.go b/internal/store/sqlitestore/schema.go index 49a1510977..5e078daf12 100644 --- a/internal/store/sqlitestore/schema.go +++ b/internal/store/sqlitestore/schema.go @@ -16,7 +16,7 @@ var schemaSQL string // SchemaVersion is the current SQLite schema version. // Bump this when adding new migration steps below. -const SchemaVersion = 26 +const SchemaVersion = 27 // migrations maps version → SQL to apply when upgrading FROM that version. // schema.sql always represents the LATEST full schema (for fresh DBs). @@ -561,6 +561,24 @@ ALTER TABLE agent_heartbeats_new RENAME TO agent_heartbeats; CREATE INDEX IF NOT EXISTS idx_heartbeats_due ON agent_heartbeats(next_run_at) WHERE enabled = 1 AND next_run_at IS NOT NULL;`, + + // Version 26 → 27: bitrix_portals table (mirrors PG migration 000058). + // Stores per-tenant OAuth credentials + refresh state for Bitrix24 portals. + // credentials + state are AES-256-GCM ciphertext via internal/crypto/aes.go. + 26: `CREATE TABLE IF NOT EXISTS bitrix_portals ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + domain VARCHAR(255) NOT NULL, + credentials BLOB, + state BLOB, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_bitrix_portals_tenant_name + ON bitrix_portals (tenant_id, name); +CREATE INDEX IF NOT EXISTS idx_bitrix_portals_domain + ON bitrix_portals (domain);`, } // addHooksTables is the SQLite incremental migration for schema v19 → v20. diff --git a/internal/store/sqlitestore/schema.sql b/internal/store/sqlitestore/schema.sql index 05e8ddffcc..be2fdc5c7c 100644 --- a/internal/store/sqlitestore/schema.sql +++ b/internal/store/sqlitestore/schema.sql @@ -1663,3 +1663,26 @@ CREATE TABLE IF NOT EXISTS tenant_hook_budget ( metadata TEXT NOT NULL DEFAULT '{}', updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); + +-- ============================================================ +-- Table: bitrix_portals (migration 000056 — PG; v24 → v25 SQLite patch) +-- Stores per-tenant OAuth credentials + refresh state for a Bitrix24 portal. +-- credentials + state are AES-256-GCM ciphertext (internal/crypto/aes.go). +-- ============================================================ + +CREATE TABLE IF NOT EXISTS bitrix_portals ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + domain VARCHAR(255) NOT NULL, + credentials BLOB, + state BLOB, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_bitrix_portals_tenant_name + ON bitrix_portals (tenant_id, name); + +CREATE INDEX IF NOT EXISTS idx_bitrix_portals_domain + ON bitrix_portals (domain); diff --git a/internal/store/stores.go b/internal/store/stores.go index f65426f652..5bfb1fcdfd 100644 --- a/internal/store/stores.go +++ b/internal/store/stores.go @@ -38,6 +38,7 @@ type Stores struct { Episodic EpisodicStore EvolutionMetrics EvolutionMetricsStore EvolutionSuggestions EvolutionSuggestionStore + BitrixPortals BitrixPortalStore // Hooks is hooks.HookStore — typed as any to avoid import cycle // (hooks package imports store for context helpers). // Callers: type-assert to hooks.HookStore before use. diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index fc18492ddf..2f367bb667 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 57 +const RequiredSchemaVersion uint = 58 diff --git a/migrations/000058_bitrix_portals.down.sql b/migrations/000058_bitrix_portals.down.sql new file mode 100644 index 0000000000..510fcd3c04 --- /dev/null +++ b/migrations/000058_bitrix_portals.down.sql @@ -0,0 +1,2 @@ +-- Revert migration 000058: Bitrix24 portal OAuth state +DROP TABLE IF EXISTS bitrix_portals; diff --git a/migrations/000058_bitrix_portals.up.sql b/migrations/000058_bitrix_portals.up.sql new file mode 100644 index 0000000000..83b2dfb2bb --- /dev/null +++ b/migrations/000058_bitrix_portals.up.sql @@ -0,0 +1,31 @@ +-- Migration 000058: Bitrix24 portal OAuth state +-- Creates bitrix_portals table that stores per-tenant OAuth credentials and +-- refresh state for a Bitrix24 portal. Multiple bitrix24 channels (chatbots) +-- can share the same portal row via a portal reference on the channel +-- instance config (Phase 03). +-- +-- `credentials` (client_id/client_secret) and `state` (access/refresh tokens, +-- member_id, app_token, registered_bots, media_folders) are both stored as +-- AES-256-GCM ciphertext via internal/crypto/aes.go. Empty encryption key +-- stores plaintext with a warn log (per crypto.Encrypt contract). + +CREATE TABLE IF NOT EXISTS bitrix_portals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + domain VARCHAR(255) NOT NULL, + -- credentials: AES-GCM ciphertext of {client_id, client_secret} + credentials BYTEA, + -- state: AES-GCM ciphertext of BitrixPortalState JSON + state BYTEA, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- One portal name per tenant. Different tenants may reuse the same name. +CREATE UNIQUE INDEX IF NOT EXISTS idx_bitrix_portals_tenant_name + ON bitrix_portals (tenant_id, name); + +-- Lookup by incoming webhook domain (Phase 02). +CREATE INDEX IF NOT EXISTS idx_bitrix_portals_domain + ON bitrix_portals (domain); diff --git a/ui/web/src/components/shared/key-value-editor.tsx b/ui/web/src/components/shared/key-value-editor.tsx index 9f1a857743..486659a424 100644 --- a/ui/web/src/components/shared/key-value-editor.tsx +++ b/ui/web/src/components/shared/key-value-editor.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef } from "react"; import { Plus, Trash2 } from "lucide-react"; import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; interface KeyValuePair { @@ -16,6 +17,8 @@ interface KeyValueEditorProps { addLabel?: string; /** Return true for keys whose values should be masked (type="password"). */ maskValue?: (key: string) => boolean; + /** Render value field as a single-line input (default) or multi-line textarea. */ + valueAs?: "input" | "textarea"; } function toEntries(obj: Record): KeyValuePair[] { @@ -40,6 +43,7 @@ export function KeyValueEditor({ valuePlaceholder = "Value", addLabel = "Add", maskValue, + valueAs = "input", }: KeyValueEditorProps) { const [entries, setEntries] = useState(() => toEntries(value)); const internalChange = useRef(false); @@ -78,20 +82,30 @@ export function KeyValueEditor({ return (
{entries.map((entry, idx) => ( -
+
updateEntry(idx, { key: e.target.value })} placeholder={keyPlaceholder} className="flex-1 font-mono text-sm" /> - updateEntry(idx, { value: e.target.value })} - placeholder={valuePlaceholder} - className="flex-1 font-mono text-sm" - /> + {valueAs === "textarea" ? ( +