diff --git a/server/internal/dev/routes.go b/server/internal/dev/routes.go index e02de8b..8f79f8d 100644 --- a/server/internal/dev/routes.go +++ b/server/internal/dev/routes.go @@ -2,38 +2,22 @@ package dev import ( "context" - "encoding/json" - "errors" - "fmt" - "io" "net/http" - "net/url" - "os" "regexp" - "sort" - "strconv" "strings" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/server/internal/auth" - authfeishu "github.com/MiniMax-AI-Dev/parsar/server/internal/auth/feishu" authinvite "github.com/MiniMax-AI-Dev/parsar/server/internal/auth/invite" - authpassword "github.com/MiniMax-AI-Dev/parsar/server/internal/auth/password" "github.com/MiniMax-AI-Dev/parsar/server/internal/connector" gatewaypkg "github.com/MiniMax-AI-Dev/parsar/server/internal/gateway" - "github.com/MiniMax-AI-Dev/parsar/server/internal/gateway/router" "github.com/MiniMax-AI-Dev/parsar/server/internal/httprunner" "github.com/MiniMax-AI-Dev/parsar/server/internal/runstream" - e2bsandbox "github.com/MiniMax-AI-Dev/parsar/server/internal/sandbox/e2b" - "github.com/MiniMax-AI-Dev/parsar/server/internal/secrets" "github.com/MiniMax-AI-Dev/parsar/server/internal/storage/blob" "github.com/MiniMax-AI-Dev/parsar/server/internal/store" "github.com/go-chi/chi/v5" "github.com/go-chi/httprate" - "github.com/google/uuid" ) var mentionPattern = regexp.MustCompile(`@[\p{Han}A-Za-z0-9_-]+`) @@ -730,5568 +714,3 @@ func RegisterRoutesWithStore(r chi.Router, runtimeStore RuntimeStore, opts ...Ro }) }) } - -func getSeed(w http.ResponseWriter, r *http.Request) { - // SeedData is the human-readable fixture (back-compat with - // existing dev consumers). The `db` key carries the real DB UUIDs - // `cmd/seeddev` writes so the admin frontend can auto-bind. - seed := DefaultSeed() - ids := store.DefaultDevFixtureIDs() - writeJSON(w, http.StatusOK, map[string]any{ - "workspace": seed.Workspace, - "users": seed.Users, - "agents": seed.Agents, - "conversations": seed.Conversations, - // Deterministic DB UUIDs from store.DefaultDevFixtureIDs — - // match exactly what `make seed-dev-db` inserts. - "db": map[string]any{ - "workspace_id": ids.WorkspaceID, - "user_id": ids.UserID, - "conversation_id": ids.ConversationID, - "agents": map[string]string{ - "product_agent_id": ids.ProductAgentID, - "backend_agent_id": ids.BackendAgentID, - "test_agent_id": ids.TestAgentID, - }, - }, - }) -} - -type verifyRequest struct { - Email string `json:"email"` - Code string `json:"code"` -} - -// verifyDevAuth is POST /dev/auth/verify. Dev-only fake login: accepts a -// {email, code} body where code must equal DevVerificationCode, then hands -// back a dev bearer token + user shape mirroring the real auth flow so -// smoke tests can bypass Feishu OIDC entirely. -// -// @Summary Dev-only email + code login -// @Description Development-only login. Verifies the fixed dev code and returns a bearer token plus the default seed workspace + user. Never enable in production. -// @Tags dev -// @ID verifyDevAuth -// @Accept json -// @Produce json -// @Param body body map[string]string true "{email, code}" -// @Success 200 {object} map[string]interface{} "Bearer token + user + workspace" -// @Failure 400 {object} map[string]string "Invalid json" -// @Failure 401 {object} map[string]string "Invalid dev credentials" -// @Router /dev/auth/verify [post] -func verifyDevAuth(w http.ResponseWriter, r *http.Request) { - var req verifyRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - if strings.TrimSpace(req.Email) == "" || req.Code != DevVerificationCode { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid dev credentials"}) - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "token": "dev-token", - "token_type": "Bearer", - "workspace_id": DefaultSeed().Workspace.ID, - "user": map[string]string{ - "id": "dev_admin", - "email": req.Email, - "name": "Dev Admin", - }, - }) -} - -type gatewayInboundRequest struct { - Gateway string `json:"gateway"` - Conversation string `json:"conversation"` - Sender string `json:"sender"` - Text string `json:"text"` - ExternalChatID string `json:"external_chat_id"` - ExternalUserID string `json:"external_user_id"` - ExternalThreadID string `json:"external_thread_id"` - ExternalMessageID string `json:"external_message_id"` - TargetAgentID string `json:"target_agent_id"` - SourceAppID string `json:"source_app_id"` - ConversationForm string `json:"conversation_form"` - Message gatewayMessage `json:"message"` - Actor gatewayActor `json:"actor"` - ConversationRef gatewayConversation `json:"conversation_ref"` - Metadata map[string]any `json:"metadata"` -} - -type gatewayMessage struct { - ID string `json:"id"` - Text string `json:"text"` -} - -type gatewayActor struct { - ID string `json:"id"` - Email string `json:"email"` -} - -type gatewayConversation struct { - ID string `json:"id"` - Title string `json:"title"` - ThreadID string `json:"thread_id"` -} - -type configureConversationExternalRefBody struct { - Gateway string `json:"gateway"` - ExternalChatID string `json:"external_chat_id"` - ExternalThreadID string `json:"external_thread_id"` -} - -// createGatewayInbound writes an inbound gateway envelope to storage. -// -// @Summary Create an inbound gateway envelope -// @Description Writes an inbound gateway envelope from the connector daemon into storage for dispatch. Development helper. -// @Tags gateway -// @ID createDevGatewayInbound -// @Accept json -// @Produce json -// @Param body body gatewayInboundRequest true "Gateway inbound payload" -// @Success 201 {object} map[string]interface{} "Created row" -// @Failure 400 {object} map[string]string "Invalid body" -// @Router /dev/gateway/inbound [post] -func createGatewayInbound(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - var req gatewayInboundRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - createGatewayInboundFromRequest(w, r, runtimeStore, req) - } -} - -// createFeishuMessageEvent receives Feishu event webhooks. -// -// @Summary Receive a Feishu event webhook -// @Description Receives inbound Feishu message-event webhooks. Handles URL verification challenge and event dispatch to the runtime. -// @Tags feishu -// @ID receiveFeishuMessageEvent -// @Accept json -// @Produce json -// @Param body body map[string]interface{} true "Feishu event envelope" -// @Success 200 {object} map[string]interface{} "Acknowledged event" -// @Failure 400 {object} map[string]string "Invalid event body or signature" -// @Router /api/v1/feishu/events/message [post] -func createFeishuMessageEvent(runtimeStore RuntimeStore, webhook feishuWebhookConfig, joinURLBuilder func(workspaceID string) string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) - return - } - if webhook.Enabled && !webhook.MockEnabled { - decoded, isChallenge, challenge, err := verifyFeishuWebhookEvent(r.Context(), runtimeStore, body, webhook) - if err != nil { - switch { - case errors.Is(err, authfeishu.ErrWebhookTokenMismatch): - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid feishu verification token"}) - case errors.Is(err, authfeishu.ErrWebhookDecryptFailed): - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to decrypt feishu event"}) - default: - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - } - return - } - if isChallenge { - writeJSON(w, http.StatusOK, map[string]string{"challenge": challenge}) - return - } - body = decoded - } - event, err := gatewaypkg.FeishuInboundEventFromWebhook(body) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - - // Tests and legacy dev shims may mount the Feishu endpoint without - // a DB-backed store or may send the pre-v2 shape without header.app_id. - // Keep the old normalization fallback there; real deployments go - // through app_id -> Agent routing below. - if runtimeStore == nil || strings.TrimSpace(event.AppID) == "" { - var legacy gatewaypkg.FeishuMessageEvent - if err := json.Unmarshal(body, &legacy); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - inbound := gatewaypkg.NormalizeFeishuInbound(legacy) - createGatewayInboundFromRequest(w, r, runtimeStore, gatewayInboundRequest{ - Gateway: inbound.Gateway, - Message: gatewayMessage{ID: inbound.Message.ID, Text: inbound.Message.Text}, - Actor: gatewayActor{ID: inbound.Actor.ID, Email: inbound.Actor.Email}, - ConversationRef: gatewayConversation{ID: inbound.ConversationRef.ID, Title: inbound.ConversationRef.Title, ThreadID: inbound.ConversationRef.ThreadID}, - Metadata: inbound.Metadata, - }) - return - } - - route := feishuRuntimeRouter{store: runtimeStore} - host, err := route.GetAgentByFeishuAppID(r.Context(), event.AppID) - if err != nil { - switch { - case errors.Is(err, gatewaypkg.ErrFeishuRouterUnknownAgent): - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown feishu app_id"}) - default: - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to route feishu inbound"}) - } - return - } - if isFeishuSelfMessage(host.Config, event.SenderOpenID) { - writeJSON(w, http.StatusOK, map[string]any{ - "gateway": "feishu", - "accepted": false, - "reason": "bot_self_message", - }) - return - } - if isFeishuGroupMessageWithoutBotMention(r.Context(), runtimeStore, host.Config, event) { - writeJSON(w, http.StatusOK, map[string]any{ - "gateway": "feishu", - "accepted": false, - "reason": "group_without_bot_mention", - }) - return - } - hostCfg, ok, err := gatewaypkg.DecodeFeishuConnectorConfig(host.Config) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to decode feishu connector"}) - return - } - if ok && router.IsSharedRoutingMode(hostCfg.RoutingMode) { - reply := func(ctx context.Context, agent gatewaypkg.FeishuRouteAgent, _ gatewaypkg.InboundEvent, text string) error { - return sendFeishuImmediateText(ctx, runtimeStore, agent, event, text) - } - outcome, err := router.HandleInbound(r.Context(), runtimeStore, host, gatewaypkg.NeutralFromFeishuEvent(event), reply, nil, gatewaypkg.GateConfig{JoinURLBuilder: joinURLBuilder}) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to handle shared feishu bot inbound"}) - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "gateway": "feishu", - "shared": true, - "accepted": outcome.Accepted, - "replied": outcome.Replied, - "reason": outcome.Reason, - "agent_id": outcome.AgentID, - }) - return - } - - decision, err := gatewaypkg.RouteInboundToAgent(r.Context(), route, gatewaypkg.NeutralFromFeishuEvent(event), host, gatewaypkg.GateConfig{JoinURLBuilder: joinURLBuilder}) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to route feishu inbound"}) - return - } - if !decision.Decision.Allowed { - replied := false - if decision.Decision.ReplyHint != "" { - if err := sendFeishuImmediateText(r.Context(), runtimeStore, decision.Agent, event, decision.Decision.ReplyHint); err != nil { - log.Bg().Warn("feishu inbound rejection reply failed", "app_id", event.AppID, "chat_id", event.ChatID, "err", err) - } else { - replied = true - } - } - writeJSON(w, http.StatusOK, map[string]any{ - "gateway": "feishu", - "accepted": false, - "replied": replied, - "reply_hint": decision.Decision.ReplyHint, - "reason": decision.Decision.Reason, - }) - return - } - - externalUserID := strings.TrimSpace(event.SenderUnionID) - if externalUserID == "" { - externalUserID = strings.TrimSpace(event.SenderOpenID) - } - conversationForm := "group" - if strings.EqualFold(strings.TrimSpace(event.ChatType), "p2p") { - conversationForm = "dm" - } - metadata := map[string]any{ - "chat_type": event.ChatType, - "tenant_key": event.TenantKey, - "sender_state": decision.SenderState, - "message_type": event.MessageType, - "raw_content": event.RawContent, - "root_id": event.RootID, - "parent_id": event.ParentID, - "thread_id": event.ThreadID, - } - for key, value := range event.Metadata { - if strings.TrimSpace(key) == "" || value == nil { - continue - } - metadata[key] = value - } - if decision.Decision.GuestReplyHint != "" { - metadata["guest_reply_hint"] = decision.Decision.GuestReplyHint - } - createGatewayInboundFromRequest(w, r, runtimeStore, gatewayInboundRequest{ - Gateway: "feishu", - Conversation: router.ConversationTitle(decision.NormalizedText), - ConversationForm: conversationForm, - Text: decision.NormalizedText, - ExternalChatID: event.ChatID, - // ThreadKey (not ReplyAnchorMessageID): every inbound in - // the same Feishu thread lands in the same Parsar - // conversation. Mirrors gateway/router/router.go. - ExternalThreadID: event.ThreadKey(), - ExternalMessageID: event.MessageID, - ExternalUserID: externalUserID, - TargetAgentID: decision.Agent.AgentID, - SourceAppID: event.AppID, - Metadata: metadata, - }) - } -} - -type feishuRuntimeRouter struct { - store RuntimeStore -} - -func (r feishuRuntimeRouter) GetAgentByFeishuAppID(ctx context.Context, appID string) (gatewaypkg.FeishuRouteAgent, error) { - route, err := r.store.GetAgentByFeishuAppID(ctx, appID) - if err != nil { - if errors.Is(err, store.ErrUnknownFeishuAgent) { - return gatewaypkg.FeishuRouteAgent{}, gatewaypkg.ErrFeishuRouterUnknownAgent - } - return gatewaypkg.FeishuRouteAgent{}, err - } - return gatewaypkg.FeishuRouteAgent{ - AgentID: route.AgentID, - WorkspaceID: route.WorkspaceID, - WorkspaceName: route.WorkspaceName, - AgentName: route.AgentName, - AgentSlug: route.AgentSlug, - Visibility: gatewaypkg.Visibility(route.Visibility), - Config: route.Config, - }, nil -} - -func (r feishuRuntimeRouter) GetAgentByID(ctx context.Context, agentID string) (gatewaypkg.FeishuRouteAgent, error) { - route, err := r.store.GetAgentByID(ctx, agentID) - if err != nil { - if errors.Is(err, store.ErrUnknownFeishuAgent) { - return gatewaypkg.FeishuRouteAgent{}, gatewaypkg.ErrFeishuRouterUnknownAgent - } - return gatewaypkg.FeishuRouteAgent{}, err - } - return gatewaypkg.FeishuRouteAgent{ - AgentID: route.AgentID, - WorkspaceID: route.WorkspaceID, - WorkspaceName: route.WorkspaceName, - AgentName: route.AgentName, - AgentSlug: route.AgentSlug, - Visibility: gatewaypkg.Visibility(route.Visibility), - Config: route.Config, - }, nil -} - -func (r feishuRuntimeRouter) FindUserIDByPlatformSubject(ctx context.Context, platform, subject string) (string, error) { - userID, err := r.store.FindUserIDByPlatformSubject(ctx, platform, subject) - if err != nil { - if errors.Is(err, store.ErrUnknownPlatformUser) { - return "", gatewaypkg.ErrRouterUnknownUser - } - return "", err - } - return userID, nil -} - -func (r feishuRuntimeRouter) IsActiveWorkspaceMember(ctx context.Context, workspaceID, userID string) (bool, error) { - return r.store.IsActiveWorkspaceMember(ctx, workspaceID, userID) -} - -func (r feishuRuntimeRouter) GetWorkspaceVisibility(ctx context.Context, workspaceID string) (string, error) { - return r.store.GetWorkspaceVisibility(ctx, workspaceID) -} - -func (r feishuRuntimeRouter) ListWorkspaceOwnerNames(ctx context.Context, workspaceID string, limit int32) ([]string, error) { - return r.store.ListActiveWorkspaceOwnerNames(ctx, workspaceID, limit) -} - -func verifyFeishuWebhookEvent(ctx context.Context, runtimeStore RuntimeStore, body []byte, webhook feishuWebhookConfig) ([]byte, bool, string, error) { - decoded, isChallenge, challenge, err := authfeishu.VerifyAndDecodeEvent(body, webhook.VerificationToken, webhook.EncryptKey) - if err == nil || !errors.Is(err, authfeishu.ErrWebhookTokenMismatch) { - return decoded, isChallenge, challenge, err - } - if runtimeStore == nil || feishuEnvelopeEncrypted(body) { - return nil, false, "", err - } - event, parseErr := gatewaypkg.FeishuInboundEventFromWebhook(body) - if parseErr != nil || strings.TrimSpace(event.AppID) == "" { - return nil, false, "", err - } - route, routeErr := runtimeStore.GetAgentByFeishuAppID(ctx, event.AppID) - if routeErr != nil { - return nil, false, "", err - } - cfg, ok, cfgErr := gatewaypkg.DecodeFeishuConnectorConfig(route.Config) - if cfgErr != nil || !ok || !cfg.Enabled || strings.TrimSpace(cfg.VerificationTokenRef) == "" { - return nil, false, "", err - } - verifyToken, tokenErr := loadFeishuSecretString(ctx, runtimeStore, route.WorkspaceID, cfg.VerificationTokenRef, "verification_token", "token", "value", "api_key") - if tokenErr != nil { - return nil, false, "", err - } - return authfeishu.VerifyAndDecodeEvent(body, verifyToken, "") -} - -func feishuEnvelopeEncrypted(body []byte) bool { - var envelope struct { - Encrypt string `json:"encrypt"` - } - if err := json.Unmarshal(body, &envelope); err != nil { - return false - } - return strings.TrimSpace(envelope.Encrypt) != "" -} - -func isFeishuSelfMessage(rawConfig []byte, senderOpenID string) bool { - senderOpenID = strings.TrimSpace(senderOpenID) - if senderOpenID == "" { - return false - } - cfg, ok, err := gatewaypkg.DecodeFeishuConnectorConfig(rawConfig) - if err != nil || !ok { - return false - } - return strings.TrimSpace(cfg.BotOpenID) != "" && strings.TrimSpace(cfg.BotOpenID) == senderOpenID -} - -// feishuThreadHistoryLookup is the narrow store surface -// isFeishuGroupMessageWithoutBotMention needs to support thread follow-up. -// It is satisfied by RuntimeStore (production) and by the -// feishuSecretRouteStore test double. -type feishuThreadHistoryLookup interface { - HasFeishuThreadInboundHistory(ctx context.Context, externalChatID, threadID string) (bool, error) -} - -// isFeishuGroupMessageWithoutBotMention decides whether a group-chat -// inbound should be silently dropped before any routing / storage work. -// -// Decision order (true = drop, false = let it through): -// 1. p2p chat → false. -// 2. mentions present: include bot_open_id → false; else → true. -// 3. no mentions in a group: if (chat_id, thread_id) has prior bot -// history → false (thread follow-up doesn't need re-@); else → true. -// -// bot_open_id missing → bot defaults to refusing all group messages; -// operator must configure via the connector panel or provisioning. -func isFeishuGroupMessageWithoutBotMention(ctx context.Context, store feishuThreadHistoryLookup, rawConfig []byte, event gatewaypkg.FeishuInboundEvent) bool { - chatType := strings.ToLower(strings.TrimSpace(event.ChatType)) - if chatType == "p2p" || chatType == "" { - return false - } - // Other Feishu apps/bots post interactive cards whose "@bot" text - // lives in the card body, never in message.mentions. Treat any - // non-user sender as already-targeted at us. - if event.IsBotSender() { - return false - } - botOpenID := "" - if cfg, ok, err := gatewaypkg.DecodeFeishuConnectorConfig(rawConfig); err == nil && ok { - botOpenID = strings.TrimSpace(cfg.BotOpenID) - } - // Step 2 — mentions present. - if len(event.MentionOpenIDs) > 0 { - if botOpenID == "" { - return true - } - for _, mentionedOpenID := range event.MentionOpenIDs { - if strings.TrimSpace(mentionedOpenID) == botOpenID { - return false - } - } - // Mentions exist but bot is not among them — message is aimed - // at another participant, do not respond. - return true - } - // Step 3 — no mentions in a group. Check thread participation via - // ThreadKey (thread_id → root_id → message_id fallback). ThreadKey - // == MessageID for non-thread inbounds; brand-new top-level - // messages have no conversation yet, so that branch is a no-op. - threadKey := strings.TrimSpace(event.ThreadKey()) - if threadKey != "" && store != nil { - hasHistory, err := store.HasFeishuThreadInboundHistory(ctx, strings.TrimSpace(event.ChatID), threadKey) - if err == nil && hasHistory { - return false - } - // Fail closed on lookup error: drop. The next @mention recovers. - } - return true -} - -func sendFeishuImmediateText(ctx context.Context, runtimeStore RuntimeStore, agent gatewaypkg.FeishuRouteAgent, event gatewaypkg.FeishuInboundEvent, text string) error { - if runtimeStore == nil { - return errors.New("runtime store is not configured") - } - cfg, ok, err := gatewaypkg.DecodeFeishuConnectorConfig(agent.Config) - if err != nil { - return err - } - if !ok || !cfg.Enabled || strings.TrimSpace(cfg.AppSecretRef) == "" { - return errors.New("feishu connector missing app_secret_ref") - } - appSecret, err := loadFeishuSecretString(ctx, runtimeStore, agent.WorkspaceID, cfg.AppSecretRef, "app_secret", "secret", "value", "api_key") - if err != nil { - return err - } - content, err := gatewaypkg.BuildFeishuInteractiveContent(text) - if err != nil { - return err - } - client, err := gatewaypkg.NewFeishuTenantClient(gatewaypkg.FeishuTenantClientOptions{ - AppID: cfg.AppID, - BaseURL: strings.TrimSpace(os.Getenv("PARSAR_FEISHU_OPENAPI_BASE_URL")), - }) - if err != nil { - return err - } - sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - if replyAnchor := event.ReplyAnchorMessageID(); replyAnchor != "" { - _, err = client.ReplyMessage(sendCtx, appSecret, replyAnchor, gatewaypkg.FeishuMessageReplyRequest{ - MsgType: "interactive", - Content: content, - ReplyInThread: true, - }) - return err - } - chatID := strings.TrimSpace(event.ChatID) - if chatID == "" { - return errors.New("feishu inbound missing chat_id for immediate reply") - } - _, err = client.SendMessage(sendCtx, appSecret, gatewaypkg.FeishuMessageSendRequest{ - ReceiveIDType: "chat_id", - ReceiveID: chatID, - MsgType: "interactive", - Content: content, - }) - return err -} - -func loadFeishuSecretString(ctx context.Context, runtimeStore RuntimeStore, workspaceID, secretID string, keys ...string) (string, error) { - secretID = strings.TrimSpace(secretID) - if secretID == "" { - return "", errors.New("secret id is required") - } - payload, err := runtimeStore.GetSecretPayload(ctx, workspaceID, secretID) - if err != nil { - return "", err - } - masterKey := strings.TrimSpace(os.Getenv("PARSAR_MASTER_KEY")) - if masterKey == "" { - return "", errors.New("PARSAR_MASTER_KEY env not set") - } - secretService, err := secrets.New(masterKey) - if err != nil { - return "", err - } - decoded, err := secretService.Decrypt(payload.EncryptedPayload) - if err != nil { - return "", err - } - for _, key := range keys { - if raw, ok := decoded[key].(string); ok && strings.TrimSpace(raw) != "" { - return strings.TrimSpace(raw), nil - } - } - return "", fmt.Errorf("secret %s payload missing expected string field", secretID) -} - -func createGatewayInboundFromRequest(w http.ResponseWriter, r *http.Request, runtimeStore RuntimeStore, req gatewayInboundRequest) { - if strings.TrimSpace(req.Gateway) == "" { - req.Gateway = "dev" - } - normalizeGatewayInbound(&req) - if strings.TrimSpace(req.Conversation) == "" && strings.TrimSpace(req.ExternalChatID) == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "conversation or external_chat_id is required"}) - return - } - if strings.TrimSpace(req.Sender) == "" && strings.TrimSpace(req.ExternalUserID) == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "sender or external_user_id is required"}) - return - } - if strings.TrimSpace(req.Text) == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "text is required"}) - return - } - - mentions := mentionPattern.FindAllString(req.Text, -1) - if runtimeStore == nil { - writeJSON(w, http.StatusCreated, map[string]any{ - "gateway": req.Gateway, - "message_id": fmt.Sprintf("gateway_msg_%d", time.Now().UnixNano()), - "run_ids": []string{}, - "mentions": mentions, - "created_at": time.Now().UTC(), - }) - return - } - - result, err := runtimeStore.CreateInboundIMMessage(r.Context(), store.CreateInboundIMMessageInput{ - ConversationTitle: req.Conversation, - SenderEmail: req.Sender, - Text: req.Text, - Mentions: mentions, - Source: "gateway", - Gateway: req.Gateway, - ExternalUserID: req.ExternalUserID, - ExternalChatID: req.ExternalChatID, - ExternalThreadID: req.ExternalThreadID, - ExternalMessageID: req.ExternalMessageID, - TargetAgentID: req.TargetAgentID, - SourceAppID: req.SourceAppID, - ConversationForm: req.ConversationForm, - Metadata: req.Metadata, - }) - if err != nil { - if errors.Is(err, store.ErrUnknownMention) || errors.Is(err, store.ErrUnknownConversation) || errors.Is(err, store.ErrUnknownSender) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create gateway inbound message"}) - return - } - - writeJSON(w, http.StatusCreated, map[string]any{ - "gateway": req.Gateway, - "message_id": result.MessageID, - "run_ids": result.RunIDs, - "mentions": result.Mentions, - "created_at": result.CreatedAt, - }) -} - -func normalizeGatewayInbound(req *gatewayInboundRequest) { - if strings.TrimSpace(req.Text) == "" { - req.Text = req.Message.Text - } - if strings.TrimSpace(req.ExternalMessageID) == "" { - req.ExternalMessageID = req.Message.ID - } - if strings.TrimSpace(req.Sender) == "" { - req.Sender = req.Actor.Email - } - if strings.TrimSpace(req.ExternalUserID) == "" { - req.ExternalUserID = req.Actor.ID - } - if strings.TrimSpace(req.Conversation) == "" { - req.Conversation = req.ConversationRef.Title - } - if strings.TrimSpace(req.ExternalChatID) == "" { - req.ExternalChatID = req.ConversationRef.ID - } - if strings.TrimSpace(req.ExternalThreadID) == "" { - req.ExternalThreadID = req.ConversationRef.ThreadID - } -} - -type httpAgentInvokeBody struct { - Endpoint string `json:"endpoint"` - Headers map[string]string `json:"headers"` -} - -type configureAgentConnectorBody struct { - ConnectorType string `json:"connector_type"` - Endpoint string `json:"endpoint"` - SecretID string `json:"secret_id"` - Model string `json:"model"` - ModelID string `json:"model_id"` - Workdir string `json:"workdir"` - SystemPrompt string `json:"system_prompt"` -} - -type createSecretBody struct { - Name string `json:"name"` - Kind string `json:"kind"` - Provider string `json:"provider"` - AuthType string `json:"auth_type"` - Payload map[string]any `json:"payload"` -} - -// createModelBody is the request body for POST /models in the new -// shared catalog. Provider info is inlined (no more model_providers -// table). Credential binding is one-of: -// - credential_mode="inline_secret" + secret_id → shared credential -// - credential_mode="credential_ref" + credential_kind_code → per-user -// -// Capabilities/limits are accepted as optional top-level convenience -// fields and folded into config server-side. -type createModelBody struct { - Name string `json:"name"` - ProviderType string `json:"provider_type"` - Adapter string `json:"adapter"` - BaseURL string `json:"base_url"` - ModelKey string `json:"model_key"` - CredentialMode string `json:"credential_mode"` - SecretID string `json:"secret_id"` - CredentialKindCode string `json:"credential_kind_code"` - Capabilities map[string]any `json:"capabilities"` - Limits map[string]any `json:"limits"` - Config map[string]any `json:"config"` -} - -// updateModelBody is the request body for PATCH /models/{id}. -// CredentialMode / ProviderType / Adapter are NOT editable here — to -// change them, create a new model. -type updateModelBody struct { - Name string `json:"name"` - ModelKey string `json:"model_key"` - BaseURL string `json:"base_url"` - SecretID string `json:"secret_id"` - CredentialKindCode string `json:"credential_kind_code"` - Capabilities map[string]any `json:"capabilities"` - Limits map[string]any `json:"limits"` - Config map[string]any `json:"config"` -} - -// foldModelConfig merges capabilities / limits into the config bag. Empty -// inputs are skipped so a caller that already nested them under config (or -// omitted them) is not clobbered with empty objects. -func foldModelConfig(config, capabilities, limits map[string]any) map[string]any { - merged := map[string]any{} - for k, v := range config { - merged[k] = v - } - if len(capabilities) > 0 { - merged["capabilities"] = capabilities - } - if len(limits) > 0 { - merged["limits"] = limits - } - return merged -} - -type configureAgentProfileBody struct { - ModelID string `json:"model_id"` - Workdir string `json:"workdir"` - SystemPrompt string `json:"system_prompt"` - Config map[string]any `json:"config"` -} - -type requeueAgentRunBody struct { - Reason string `json:"reason"` -} - -type markGatewayOutboundDeliveredBody struct { - DeliveryID string `json:"delivery_id"` -} - -// invokeHTTPAgentRun invokes a pending HTTP-agent run row. -// -// @Summary Invoke an HTTP agent run -// @Description Executes the HTTP-agent invocation associated with the given run row. Development helper. -// @Tags dev -// @ID invokeDevHTTPAgentRun -// @Accept json -// @Produce json -// @Param runID path string true "Run UUID" -// @Param body body httpAgentInvokeBody true "Invocation payload" -// @Success 200 {object} map[string]interface{} "Run result" -// @Failure 400 {object} map[string]string "Invalid body or UUID" -// @Failure 404 {object} map[string]string "Run not found" -// @Router /dev/http-agent/runs/{runID}/invoke [post] -func invokeHTTPAgentRun(runtimeStore RuntimeStore, client *http.Client, deps *httprunner.Deps) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed http agent connector is disabled"}) - return - } - - runID := strings.TrimSpace(chi.URLParam(r, "runID")) - if !isUUID(runID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "run_id must be a valid uuid"}) - return - } - - var req httpAgentInvokeBody - if r.Body != nil { - if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - } - - runHTTPAgentInvocation(w, r, runtimeStore, client, runID, req, deps) - } -} - -// runHTTPAgentOnce runs an HTTP-agent workload once, synchronously. -// -// @Summary Run an HTTP agent once -// @Description Executes a single HTTP-agent invocation synchronously and returns the result. Development helper. -// @Tags dev -// @ID runDevHTTPAgentOnce -// @Accept json -// @Produce json -// @Param body body map[string]interface{} true "Run payload" -// @Success 200 {object} map[string]interface{} "Run result" -// @Failure 400 {object} map[string]string "Invalid body" -// @Router /dev/http-agent/runner/run-once [post] -func runHTTPAgentOnce(runtimeStore RuntimeStore, client *http.Client, deps *httprunner.Deps) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed http runner is disabled"}) - return - } - - result, err := httprunner.RunOnce(r.Context(), runtimeStore, client, deps) - if err != nil { - switch { - case errors.Is(err, store.ErrUnknownAgentRun): - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - case errors.Is(err, store.ErrInvalidHTTPConnector), errors.Is(err, store.ErrAgentRunNotCompletable), errors.Is(err, store.ErrInvalidAgent): - writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) - case errors.Is(err, httprunner.ErrInvalidEndpoint): - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - case errors.Is(err, httprunner.ErrRequestFailed), errors.Is(err, httprunner.ErrNon2xx), errors.Is(err, httprunner.ErrInvalidJSON): - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - default: - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to run http agent once"}) - } - return - } - writeJSON(w, http.StatusOK, result) - } -} - -func runHTTPAgentInvocation(w http.ResponseWriter, r *http.Request, runtimeStore RuntimeStore, client *http.Client, runID string, req httpAgentInvokeBody, deps *httprunner.Deps) { - result, err := httprunner.Invoke(r.Context(), runtimeStore, client, httprunner.InvokeInput{RunID: runID, Endpoint: req.Endpoint, Headers: req.Headers}, deps) - if err != nil { - switch { - case errors.Is(err, store.ErrUnknownAgentRun): - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - case errors.Is(err, store.ErrInvalidHTTPConnector), errors.Is(err, store.ErrAgentRunNotCompletable), errors.Is(err, store.ErrInvalidAgent): - writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) - case errors.Is(err, httprunner.ErrInvalidEndpoint): - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - case errors.Is(err, httprunner.ErrRequestFailed), errors.Is(err, httprunner.ErrNon2xx), errors.Is(err, httprunner.ErrInvalidJSON): - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - default: - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to run http agent"}) - } - return - } - writeJSON(w, http.StatusOK, result) -} - -// configureAgentConnector wires an agent to a channel connector. -// -// @Summary Configure an agent's connector -// @Description Attaches or updates an agent's outbound channel connector configuration. Owner/admin only. -// @Tags agents -// @ID configureDevAgentConnector -// @Accept json -// @Produce json -// @Param agentID path string true "Agent UUID" -// @Param body body configureAgentConnectorBody true "Connector config payload" -// @Success 200 {object} map[string]interface{} "Updated agent" -// @Failure 400 {object} map[string]string "Invalid request" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 404 {object} map[string]string "Unknown agent" -// @Router /api/v1/agents/{agentID}/connector [post] -func configureAgentConnector(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed connector config is disabled"}) - return - } - agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) - if !isUUID(agentID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) - return - } - workspaceID, ok := workspaceIDForAgent(w, r.Context(), runtimeStore, agentID) - if !ok { - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - - var req configureAgentConnectorBody - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - if strings.TrimSpace(req.ConnectorType) == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "connector_type is required"}) - return - } - if req.ConnectorType == "http" && !isSafeHTTPAgentEndpoint(req.Endpoint) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "http connector endpoint must be an http(s) URL"}) - return - } - - result, err := runtimeStore.ConfigureDevAgentConnector(r.Context(), store.ConfigureDevAgentConnectorInput{ - AgentID: agentID, - ConnectorType: req.ConnectorType, - Endpoint: req.Endpoint, - SecretID: req.SecretID, - Model: req.Model, - ModelID: req.ModelID, - Workdir: req.Workdir, - SystemPrompt: req.SystemPrompt, - }) - if err != nil { - switch { - case errors.Is(err, store.ErrInvalidConnectorType): - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - case errors.Is(err, store.ErrUnknownAgent): - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - default: - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to configure agent connector"}) - } - return - } - writeJSON(w, http.StatusOK, result) - } -} - -// configureAgentProfile updates the agent's public profile. -// -// @Summary Configure an agent's profile -// @Description Updates the agent's public profile fields (display name, avatar, description). Owner/admin only. -// @Tags agents -// @ID configureDevAgentProfile -// @Accept json -// @Produce json -// @Param agentID path string true "Agent UUID" -// @Param body body configureAgentProfileBody true "Profile payload" -// @Success 200 {object} map[string]interface{} "Updated agent" -// @Failure 400 {object} map[string]string "Invalid body or UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 404 {object} map[string]string "Agent not found" -// @Router /api/v1/agents/{agentID}/profile [post] -func configureAgentProfile(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed agent profile config is disabled"}) - return - } - agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) - if !isUUID(agentID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) - return - } - workspaceID, ok := workspaceIDForAgent(w, r.Context(), runtimeStore, agentID) - if !ok { - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - var req configureAgentProfileBody - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - result, err := runtimeStore.ConfigureAgentProfile(r.Context(), store.ConfigureAgentProfileInput{AgentID: agentID, ModelID: req.ModelID, Workdir: req.Workdir, SystemPrompt: req.SystemPrompt, Config: req.Config}) - if err != nil { - switch { - case errors.Is(err, store.ErrUnknownAgent): - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - case errors.Is(err, store.ErrUnknownModel): - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - case errors.Is(err, store.ErrModelDisabled): - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - default: - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to configure agent profile"}) - } - return - } - - // Local-device binding: mirror createAgent so editing a - // local-mode agent's bound device keeps the agent's runtime_id in - // sync. Without this the admin list keeps reading "Runtime not bound". - if result.AgentConfig != nil { - if mode, _ := result.AgentConfig["daemon_mode"].(string); mode == "local" { - if deviceID, _ := result.AgentConfig["device_id"].(string); strings.TrimSpace(deviceID) != "" { - detail, detailErr := runtimeStore.GetAgentDetail(r.Context(), agentID) - if detailErr != nil { - log.Bg().Warn("configureAgentProfile: workspace lookup failed for runtime_id sync", - "agent_id", agentID, "err", detailErr) - } else if _, bindErr := runtimeStore.SetAgentRuntime(r.Context(), store.SetAgentRuntimeInput{ - WorkspaceID: detail.WorkspaceID, - AgentID: agentID, - RuntimeID: deviceID, - }); bindErr != nil { - log.Bg().Warn("configureAgentProfile: persist local device runtime_id failed", - "agent_id", agentID, - "device_id", deviceID, - "err", bindErr) - } - } - } - } - - writeJSON(w, http.StatusOK, result) - } -} - -// disableAgent disables an active agent. -// -// @Summary Disable an agent -// @Description Marks the agent as disabled so it is no longer scheduled. Owner/admin only. -// @Tags agents -// @ID disableDevAgent -// @Produce json -// @Param agentID path string true "Agent UUID" -// @Success 200 {object} map[string]interface{} "Updated agent" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 404 {object} map[string]string "Agent not found" -// @Router /api/v1/agents/{agentID}/disable [post] -func disableAgent(runtimeStore RuntimeStore) http.HandlerFunc { - return agentStatusHandler(runtimeStore, "disable") -} - -// enableAgent enables a disabled agent. -// -// @Summary Enable an agent -// @Description Marks a disabled agent as enabled so it can be scheduled again. Owner/admin only. -// @Tags agents -// @ID enableDevAgent -// @Produce json -// @Param agentID path string true "Agent UUID" -// @Success 200 {object} map[string]interface{} "Updated agent" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 404 {object} map[string]string "Agent not found" -// @Router /api/v1/agents/{agentID}/enable [post] -func enableAgent(runtimeStore RuntimeStore) http.HandlerFunc { - return agentStatusHandler(runtimeStore, "enable") -} - -// getAgentRuntimeBinding returns the runtime currently bound to -// this agent. Empty runtime_id means the user hasn't picked one -// yet — the dispatcher surfaces "Please bind a Runtime" when a run starts. -// getAgentRuntimeBinding returns the agent's current runtime binding. -// -// @Summary Get an agent's runtime binding -// @Description Returns the runtime/device currently bound to the agent. -// @Tags runtimes -// @ID getDevAgentRuntimeBinding -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param agentID path string true "Agent UUID" -// @Success 200 {object} map[string]interface{} "Runtime binding" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 404 {object} map[string]string "Agent not found" -// @Router /api/v1/workspaces/{workspaceID}/agents/{agentID}/runtime [get] -func getAgentRuntimeBinding(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed agent runtime binding is disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if !isUUID(agentID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) - return - } - if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - binding, err := runtimeStore.GetAgentRuntimeBinding(r.Context(), workspaceID, agentID) - if err != nil { - if errors.Is(err, store.ErrUnknownAgent) { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read runtime binding"}) - return - } - writeJSON(w, http.StatusOK, binding) - } -} - -// setAgentRuntimeBinding writes (or clears) the runtime an -// agent runs on. RuntimeID="" is a valid clear request that -// turns the agent back into an unbound state. Tenant guard: only -// workspace owners / admins can change the binding. -// setAgentRuntimeBinding binds an agent to a runtime/device. -// -// @Summary Bind an agent to a runtime -// @Description Binds the agent to a runtime/device. Empty runtime_id clears the binding. Owner/admin only. -// @Tags runtimes -// @ID setDevAgentRuntimeBinding -// @Accept json -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param agentID path string true "Agent UUID" -// @Param body body map[string]interface{} true "Runtime binding payload" -// @Success 200 {object} map[string]interface{} "Runtime binding" -// @Failure 400 {object} map[string]string "Invalid body or UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 404 {object} map[string]string "Agent not found" -// @Router /api/v1/workspaces/{workspaceID}/agents/{agentID}/runtime [post] -func setAgentRuntimeBinding(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed agent runtime binding is disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if !isUUID(agentID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - var body struct { - RuntimeID string `json:"runtime_id"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - if v := strings.TrimSpace(body.RuntimeID); v != "" && !isUUID(v) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "runtime_id must be a valid uuid or empty"}) - return - } - binding, err := runtimeStore.SetAgentRuntime(r.Context(), store.SetAgentRuntimeInput{ - WorkspaceID: workspaceID, - AgentID: agentID, - RuntimeID: strings.TrimSpace(body.RuntimeID), - }) - if err != nil { - if errors.Is(err, store.ErrUnknownAgent) { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to set runtime binding"}) - return - } - writeJSON(w, http.StatusOK, binding) - } -} - -type createAgentCapabilityBody struct { - CapabilityVersionID string `json:"capability_version_id"` - Configuration map[string]any `json:"configuration"` - // PinningMode is "latest" or "pinned". Empty falls back to store's - // default (pinned); create-agent dialog sends "latest" for new - // bindings unless the user picks a specific version. - PinningMode string `json:"pinning_mode,omitempty"` -} - -// createAgentInlineSecretBody describes one new shared secret the user -// asked to materialise during agent creation. The handler creates the -// secret via store.CreateSecret, then patches its id into -// req.Config.credential_bindings[Kind] (or model_credential_binding when -// IsModel=true) before delegating to runtimeStore.CreateAgent. -type createAgentInlineSecretBody struct { - Kind string `json:"kind"` - IsModel bool `json:"is_model"` - DisplayName string `json:"display_name"` - Plaintext string `json:"plaintext"` -} - -type createAgentBody struct { - Name string `json:"name"` - Description string `json:"description"` - ConnectorType string `json:"connector_type"` - SystemPrompt string `json:"system_prompt"` - DefaultModelID string `json:"default_model_id"` - Capabilities []string `json:"capabilities"` - InitialCapabilities []createAgentCapabilityBody `json:"initial_capabilities"` - Visibility string `json:"visibility"` - Runtime string `json:"runtime"` - Config map[string]any `json:"config"` - InlineNewSecrets []createAgentInlineSecretBody `json:"inline_new_secrets"` - Slug string `json:"slug"` -} - -type updateAgentBody struct { - Name *string `json:"name"` - Description *string `json:"description"` - ConnectorType *string `json:"connector_type"` - SystemPrompt *string `json:"system_prompt"` - DefaultModelID *string `json:"default_model_id"` - Capabilities []string `json:"capabilities"` - Config map[string]any `json:"config"` - InlineNewSecrets []createAgentInlineSecretBody `json:"inline_new_secrets"` - Slug *string `json:"slug"` - WorkspaceID *string `json:"workspace_id"` -} - -// getWorkspaceSettings returns workspace-level settings. -// -// @Summary Get workspace settings -// @Description Returns workspace-level settings. Caller must be a workspace member. -// @Tags workspaces -// @ID getDevWorkspaceSettings -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Success 200 {object} map[string]interface{} "Workspace settings" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not a workspace member" -// @Router /api/v1/workspaces/{workspaceID}/settings [get] -func getWorkspaceSettings(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if runtimeStore == nil || !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - result, err := runtimeStore.GetWorkspaceSettings(r.Context(), workspaceID) - if err != nil { - writeStoreAgentError(w, err) - return - } - writeJSON(w, http.StatusOK, result) - } -} - -// patchWorkspaceSettings applies a partial update to workspace settings. -// -// @Summary Update workspace settings -// @Description Partially updates workspace-level settings. Owner/admin only. -// @Tags workspaces -// @ID patchDevWorkspaceSettings -// @Accept json -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param body body map[string]interface{} true "Settings patch" -// @Success 200 {object} map[string]interface{} "Updated settings" -// @Failure 400 {object} map[string]string "Invalid body or UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Router /api/v1/workspaces/{workspaceID}/settings [patch] -func patchWorkspaceSettings(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if runtimeStore == nil || !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - if _, err := decodeJSONWithFields(r, &struct{}{}); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - result, err := runtimeStore.PatchWorkspaceSettings(r.Context(), workspaceID) - if err != nil { - writeStoreAgentError(w, err) - return - } - writeJSON(w, http.StatusOK, result) - } -} - -// createAgent creates a new agent in a workspace. Owner/admin only. -// -// @Summary Create an agent in a workspace -// @Description Creates an agent under the given workspace. Owner/admin only. inline_new_secrets are materialised into the shared secret vault before the agent is persisted; any binding entries in config are patched with the resolved ids. -// @Tags agents -// @ID createDevAgent -// @Accept json -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param body body createAgentBody true "Agent create payload" -// @Success 201 {object} map[string]interface{} "Created agent" -// @Failure 400 {object} map[string]string "workspace_id must be a valid uuid, or body invalid" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 422 {object} map[string]string "Immutable field or unknown capability" -// @Router /api/v1/workspaces/{workspaceID}/agents [post] -func createAgent(runtimeStore RuntimeStore, agentDaemonSandbox AgentDaemonSandboxAcquirer) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if runtimeStore == nil || !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - var req createAgentBody - hasCaps, err := decodeJSONWithField(r, &req, "capabilities") - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.ConnectorType) == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name and connector_type are required"}) - return - } - if strings.TrimSpace(req.Runtime) != "" { - writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "runtime is no longer accepted; use config.daemon_mode, config.device_id, and config.agent_kind for agent_daemon agents"}) - return - } - - // Materialise any inline_new_secrets the user pasted in step 3. - // Each one becomes a capability_inline secret in the org-global - // catalog; its id is then patched into the corresponding - // credential_bindings entry (or model_credential_binding when - // IsModel=true) inside req.Config so CreateAgent persists a - // fully-resolved binding map. Failure here is fatal — the agent - // is not created, the secrets that did succeed are left as - // orphans (we explicitly chose not to clean them up). - if cfg, ok := materialiseInlineSecrets(r.Context(), runtimeStore, req.Config, req.InlineNewSecrets, actorIDFromRequest(r)); ok { - req.Config = cfg - } else if len(req.InlineNewSecrets) > 0 { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to materialise inline_new_secrets"}) - return - } - - // Enforce visibility ⇄ binding consistency. Public agents may - // not depend on any personal credential (no platform user_id - // for lark guests); tenant agents are allowed but warned in UI. - // 422 (not 400) so the FE can distinguish "semantically wrong" - // from "malformed body" — same convention as updateAgent. - if err := validateAgentVisibilityBindings(req.Visibility, req.Config); err != nil { - writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) - return - } - initialCapabilities := make([]store.InitialAgentCapabilityInput, 0, len(req.InitialCapabilities)) - for _, capability := range req.InitialCapabilities { - initialCapabilities = append(initialCapabilities, store.InitialAgentCapabilityInput{CapabilityVersionID: capability.CapabilityVersionID, Configuration: capability.Configuration, PinningMode: capability.PinningMode}) - } - result, err := runtimeStore.CreateAgent(r.Context(), store.CreateAgentInput{WorkspaceID: workspaceID, Name: req.Name, Description: req.Description, ConnectorType: req.ConnectorType, SystemPrompt: req.SystemPrompt, DefaultModelID: req.DefaultModelID, Capabilities: req.Capabilities, CapabilitiesSet: hasCaps, InitialCapabilities: initialCapabilities, Runtime: "", AgentConfig: req.Config, Visibility: req.Visibility, Slug: req.Slug, CreatedBy: actorIDFromRequest(r)}) - if err != nil { - writeStoreAgentError(w, err) - return - } - writeJSON(w, http.StatusCreated, result) - - // Sync capability checkboxes → agent_capabilities table so the - // runtime's GetEnabledCapabilitiesForAgent sees them. - if hasCaps && len(req.Capabilities) > 0 { - if err := syncAgentCapabilities(r.Context(), runtimeStore, result.Agent.WorkspaceID, result.Agent.ID, req.Capabilities); err != nil { - log.Bg().Warn("createAgent: capability sync failed", "agent_id", result.Agent.ID, "err", err) - } - } - - // Local-device binding: when the user picked a paired daemon - // in the create form, the device_id sits in agents.config but - // agents.runtime_id stays NULL. Mirror device_id → runtime_id so - // the FK join lights up. device_id IS a runtime.id. - if result.Agent.Config != nil { - if mode, _ := result.Agent.Config["daemon_mode"].(string); mode == "local" { - if deviceID, _ := result.Agent.Config["device_id"].(string); strings.TrimSpace(deviceID) != "" { - if _, bindErr := runtimeStore.SetAgentRuntime(r.Context(), store.SetAgentRuntimeInput{ - WorkspaceID: workspaceID, - AgentID: result.Agent.ID, - RuntimeID: deviceID, - }); bindErr != nil { - // Non-fatal: row is created, the user can - // re-save from the edit dialog to retry. - log.Bg().Warn("createAgent: persist local device runtime_id failed", - "agent_id", result.Agent.ID, - "device_id", deviceID, - "err", bindErr) - } - } - } - } - - // Eager sandbox provisioning: kick off Acquire so the sandbox - // is ready before the user sends their first message. On - // success, persist deviceID to agents.runtime_id — - // without this write the connector's "user must bind a - // runtime first" guard would reject the very first prompt. - // Failure is non-fatal: the row is saved and SandboxPanel - // (or a follow-up Rebuild) gives the admin a recovery surface. - if agentDaemonSandbox != nil && result.Agent.Config != nil { - if mode, _ := result.Agent.Config["daemon_mode"].(string); mode == "sandbox" { - paID := result.Agent.ID - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - deviceID, err := agentDaemonSandbox.Acquire(ctx, connector.PromptInput{ - AgentID: paID, - WorkspaceID: workspaceID, - }) - if err != nil { - log.Bg().Warn("eager sandbox acquire failed", - "agent_id", paID, "err", err) - return - } - if _, bindErr := runtimeStore.SetAgentRuntime(ctx, store.SetAgentRuntimeInput{ - WorkspaceID: workspaceID, - AgentID: paID, - RuntimeID: deviceID, - }); bindErr != nil { - // Sandbox is alive but runtime_id write failed. - // Dispatch shows "Runtime not bound" until a retry - // succeeds or admin Rebuild rewrites. - log.Bg().Error("eager sandbox acquired but runtime_id persist failed", - "agent_id", paID, - "device_id", deviceID, - "err", bindErr) - return - } - log.Bg().Info("eager sandbox acquired and runtime bound", - "agent_id", paID, "device_id", deviceID) - }() - } - } - } -} - -// updateAgent applies a partial update to an existing agent. -// -// @Summary Update mutable agent fields -// @Description Applies a partial update. All fields are optional; nil pointers mean "leave as-is". Slug and runtime are immutable and rejected with 422. -// @Tags agents -// @ID updateDevAgent -// @Accept json -// @Produce json -// @Param agentID path string true "Agent UUID" -// @Param body body updateAgentBody true "Partial agent update" -// @Success 200 {object} map[string]interface{} "Updated agent" -// @Failure 400 {object} map[string]string "Malformed request body or invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 422 {object} map[string]string "Immutable field (slug/runtime), or unknown capability" -// @Router /api/v1/agents/{agentID} [patch] -func updateAgent(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) - if runtimeStore == nil || !isUUID(agentID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) - return - } - agent, err := runtimeStore.GetAgent(r.Context(), agentID) - if err != nil { - writeStoreAgentError(w, err) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, agent.WorkspaceID); err != nil { - writeRBACError(w, err) - return - } - var req updateAgentBody - fields, err := decodeJSONWithFields(r, &req) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - hasCaps := fields["capabilities"] - if fields["runtime"] { - // runtime is immutable post-create — recreate the agent to - // change runtime (it determines whether the agent runs in - // cloud sandbox or on local subprocess and is tied to - // every previous conversation's execution environment). - writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "runtime is immutable post-create; recreate the agent to change runtime"}) - return - } - if req.Slug != nil { - writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "slug is immutable"}) - return - } - if req.WorkspaceID != nil { - writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "workspace_id is immutable"}) - return - } - // Materialise inline secrets + validate visibility ⇄ binding consistency - // when the FE sends new credential bindings. Without this, edits that - // switch the shared secret (or introduce a new one) never reach - // agents.agent_config and the runtime keeps resolving the old binding. - // - // Same failure trade-off as createAgent: materialiseInlineSecrets is - // "commit-each-as-you-go", and a downstream failure (mid-list secret - // create, or visibility validation below) leaves the earlier secrets - // dangling in the workspace. Edit makes this slightly worse because - // users typically retry after fixing the offending field, accruing one - // orphan per retry. We accept it for symmetry with create; a future - // pass could wrap the chain in a tx + rollback. - configChanged := fields["config"] || len(req.InlineNewSecrets) > 0 - if configChanged { - if cfg, ok := materialiseInlineSecrets(r.Context(), runtimeStore, req.Config, req.InlineNewSecrets, actorIDFromRequest(r)); ok { - req.Config = cfg - } else if len(req.InlineNewSecrets) > 0 { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to materialise inline_new_secrets"}) - return - } - if err := validateAgentVisibilityBindings(agent.Visibility, req.Config); err != nil { - writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) - return - } - } - updated, _, err := runtimeStore.UpdateAgent(r.Context(), store.UpdateAgentInput{AgentID: agentID, ActorID: actorIDFromRequest(r), Name: req.Name, Description: req.Description, ConnectorType: req.ConnectorType, SystemPrompt: req.SystemPrompt, DefaultModelID: req.DefaultModelID, Capabilities: req.Capabilities, CapabilitiesSet: hasCaps, Config: req.Config, ConfigSet: configChanged}) - if err != nil { - writeStoreAgentError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"agent": updated}) - - // Sync capability checkboxes → agent_capabilities table. - if hasCaps { - if err := syncAgentCapabilities(r.Context(), runtimeStore, updated.WorkspaceID, agentID, req.Capabilities); err != nil { - log.Bg().Warn("updateAgent: capability sync failed", "agent_id", agentID, "err", err) - } - } - } -} - -// syncAgentCapabilities reconciles the agent_capabilities table with -// the capability name list from the agent edit form. Errors on -// individual capabilities are logged and skipped. -func syncAgentCapabilities( - ctx context.Context, - rs RuntimeStore, - workspaceID string, - agentID string, - capabilityNames []string, -) error { - // 1. Current state on this agent. - existing, err := rs.ListAgentCapabilities(ctx, agentID) - if err != nil { - return fmt.Errorf("syncAgentCapabilities: list existing: %w", err) - } - existingByCapID := make(map[string]store.AgentCapabilityRead, len(existing)) - for _, ac := range existing { - existingByCapID[ac.CapabilityID] = ac - } - - // 2. Resolve desired names. A name can come from this workspace's own - // capabilities, OR from the marketplace (a public capability published - // by another workspace and surfaced in the agent picker's marketplace - // section). Local capabilities win on name collision: a user shadowing - // a marketplace name with a private one should keep using their own. - allCaps, err := rs.ListCapabilities(ctx, workspaceID, store.ListCapabilityFilter{}) - if err != nil { - return fmt.Errorf("syncAgentCapabilities: list capabilities: %w", err) - } - marketplaceCaps, err := rs.ListMarketplaceCapabilities(ctx, workspaceID) - if err != nil { - return fmt.Errorf("syncAgentCapabilities: list marketplace capabilities: %w", err) - } - type resolved struct { - capabilityID string - latestVersionID string - fromMarketplace bool - } - capByName := make(map[string]resolved, len(allCaps)+len(marketplaceCaps)) - for _, c := range allCaps { - capByName[c.Name] = resolved{capabilityID: c.ID} - } - for _, m := range marketplaceCaps { - if m.SelfPublished { - continue - } - if _, exists := capByName[m.Name]; exists { - continue - } - capByName[m.Name] = resolved{capabilityID: m.CapabilityID, latestVersionID: m.LatestVersionID, fromMarketplace: true} - } - - desiredCapIDs := make(map[string]bool, len(capabilityNames)) - for _, name := range capabilityNames { - name = strings.TrimSpace(name) - if name == "" { - continue - } - cap, ok := capByName[name] - if !ok { - log.Bg().Warn("syncAgentCapabilities: capability not found, skipping", - "name", name, "workspace_id", workspaceID, "agent_id", agentID) - continue - } - desiredCapIDs[cap.capabilityID] = true - - // Already enabled — don't auto-upgrade version. - if _, exists := existingByCapID[cap.capabilityID]; exists { - continue - } - - latestVersionID := cap.latestVersionID - if latestVersionID == "" { - // Local capability: marketplace row already carries the version, - // but ListCapabilities does not, so we fetch lazily here. - versions, err := rs.ListCapabilityVersions(ctx, cap.capabilityID) - if err != nil { - log.Bg().Warn("syncAgentCapabilities: list versions failed, skipping", - "capability_id", cap.capabilityID, "name", name, "err", err) - continue - } - if len(versions) == 0 { - log.Bg().Warn("syncAgentCapabilities: no versions found, skipping", - "capability_id", cap.capabilityID, "name", name) - continue - } - latestVersionID = versions[0].ID // sorted created_at desc - } - - // Default pinning_mode depends on the source: - // * Local capability: user's expectation is "check it and follow the latest". After reupload, - // no need to re-edit the agent, and skill iteration in the local workshop has no - // breaking-change risk (owned by the same team). - // * Marketplace: the publisher's new version may carry breaking changes, - // keep pinned so the UpgradeCapabilityDialog explicit-confirm path remains - // valid; the user must explicitly pick latest from the picker to auto-follow. - mode := store.PinningModeLatest - if cap.fromMarketplace { - mode = store.PinningModePinned - } - if _, err := rs.EnableAgentCapability(ctx, agentID, latestVersionID, nil, mode); err != nil { - log.Bg().Warn("syncAgentCapabilities: enable failed, skipping", - "capability_id", cap.capabilityID, "name", name, "version_id", latestVersionID, "err", err) - continue - } - log.Bg().Info("syncAgentCapabilities: enabled capability", - "agent_id", agentID, "capability_id", cap.capabilityID, "name", name, "version_id", latestVersionID, "from_marketplace", cap.fromMarketplace, "pinning_mode", mode) - } - - // 3. Remove capabilities no longer in the desired list. - for capID, ac := range existingByCapID { - if desiredCapIDs[capID] { - continue - } - if err := rs.DeleteAgentCapability(ctx, agentID, ac.CapabilityVersionID); err != nil { - log.Bg().Warn("syncAgentCapabilities: delete failed, skipping", - "capability_id", capID, "capability_version_id", ac.CapabilityVersionID, "err", err) - continue - } - log.Bg().Info("syncAgentCapabilities: removed capability", - "agent_id", agentID, "capability_id", capID, "capability_version_id", ac.CapabilityVersionID) - } - return nil -} - -// updateAgentVisibilityBody is the request body for -// PATCH /api/v1/agents/{agentID}/visibility. -type updateAgentVisibilityBody struct { - Visibility string `json:"visibility"` -} - -// updateAgentVisibility flips an Agent's visibility between -// workspace / tenant / public. Owner/admin only. Identical visibility -// is treated as a 200 noop so idempotent replays don't pollute audit. -// updateAgentVisibility updates an agent's visibility setting. -// -// @Summary Update an agent's visibility -// @Description Updates an agent's visibility (public / tenant / private). Owner/admin only. Rejected if bindings are inconsistent with the requested visibility. -// @Tags agents -// @ID updateDevAgentVisibility -// @Accept json -// @Produce json -// @Param agentID path string true "Agent UUID" -// @Param body body updateAgentVisibilityBody true "Visibility payload" -// @Success 200 {object} map[string]interface{} "Updated agent" -// @Failure 400 {object} map[string]string "Invalid body or UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 422 {object} map[string]string "Visibility conflicts with agent bindings" -// @Router /api/v1/agents/{agentID}/visibility [patch] -func updateAgentVisibility(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) - if runtimeStore == nil || !isUUID(agentID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) - return - } - agent, err := runtimeStore.GetAgent(r.Context(), agentID) - if err != nil { - writeStoreAgentError(w, err) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, agent.WorkspaceID); err != nil { - writeRBACError(w, err) - return - } - var req updateAgentVisibilityBody - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - change, err := runtimeStore.UpdateAgentVisibility(r.Context(), agentID, req.Visibility, actorIDFromRequest(r)) - if err != nil { - if errors.Is(err, store.ErrInvalidAgentVisibility) { - writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) - return - } - writeStoreAgentError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"visibility": change}) - } -} - -// updateAgentFeishuConnectorBody is the request body for -// PATCH /api/v1/agents/{agentID}/connector/feishu. -type updateAgentFeishuConnectorBody struct { - Enabled bool `json:"enabled"` - AppID string `json:"app_id"` - AppSecretRef string `json:"app_secret_ref"` - VerificationTokenRef string `json:"verification_token_ref"` - EncryptKeyRef string `json:"encrypt_key_ref"` - BotOpenID string `json:"bot_open_id"` - EventMode string `json:"event_mode"` - RoutingMode string `json:"routing_mode"` -} - -type pollAgentFeishuProvisioningBody struct { - DeviceCode string `json:"device_code"` - IntervalSec int `json:"interval_sec"` - TenantBrand string `json:"tenant_brand"` -} - -type feishuProvisioningResponse struct { - Status string `json:"status"` - Begin *gatewaypkg.FeishuAppRegistrationBeginResult `json:"begin,omitempty"` - NextIntervalSec int `json:"next_interval_sec,omitempty"` - Error string `json:"error,omitempty"` - Description string `json:"description,omitempty"` - AppID string `json:"app_id,omitempty"` - AppSecretRef string `json:"app_secret_ref,omitempty"` - BotOpenID string `json:"bot_open_id,omitempty"` - BotName string `json:"bot_name,omitempty"` - FeishuConnector *store.AgentFeishuConnectorChange `json:"feishu_connector,omitempty"` -} - -// getAgentFeishuConnectorDiagnostics returns a read-only Feishu Bot -// observation snapshot for admins and workspace members. Unlike the -// write path, this is a read/debug surface and never exposes secret refs. -// getAgentFeishuConnectorDiagnostics returns Feishu connector health. -// -// @Summary Get an agent's Feishu connector diagnostics -// @Description Returns diagnostic snapshots for the agent's Feishu connector (webhook subscription, event verify, bot token). -// @Tags feishu -// @ID getDevAgentFeishuDiagnostics -// @Produce json -// @Param agentID path string true "Agent UUID" -// @Success 200 {object} map[string]interface{} "Diagnostics snapshot" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 404 {object} map[string]string "Agent not found" -// @Router /api/v1/agents/{agentID}/connector/feishu/diagnostics [get] -func getAgentFeishuConnectorDiagnostics(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) - if runtimeStore == nil || !isUUID(agentID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) - return - } - agent, err := runtimeStore.GetAgent(r.Context(), agentID) - if err != nil { - writeStoreAgentError(w, err) - return - } - if err := requireWorkspaceMember(r, runtimeStore, agent.WorkspaceID); err != nil { - writeRBACError(w, err) - return - } - diagnostics, err := runtimeStore.GetFeishuConnectorDiagnostics(r.Context(), agentID) - if err != nil { - writeStoreAgentError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"diagnostics": diagnostics}) - } -} - -// updateAgentFeishuConnector binds (or rebinds) an Agent to a Feishu -// Bot self-built app — writes agents.config.connectors.feishu so the -// inbound router and outbound worker can resolve this Agent. -// RBAC: workspace owner / admin (a misconfigured Bot can leak the -// workspace to the internet). -// updateAgentFeishuConnector updates the agent's Feishu connector config. -// -// @Summary Update an agent's Feishu connector -// @Description Updates the agent's Feishu connector credentials/config. Owner/admin only. -// @Tags feishu -// @ID updateDevAgentFeishuConnector -// @Accept json -// @Produce json -// @Param agentID path string true "Agent UUID" -// @Param body body updateAgentFeishuConnectorBody true "Connector payload" -// @Success 200 {object} map[string]interface{} "Updated agent" -// @Failure 400 {object} map[string]string "Invalid body or UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 404 {object} map[string]string "Agent not found" -// @Router /api/v1/agents/{agentID}/connector/feishu [patch] -func updateAgentFeishuConnector(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) - if runtimeStore == nil || !isUUID(agentID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) - return - } - agent, err := runtimeStore.GetAgent(r.Context(), agentID) - if err != nil { - writeStoreAgentError(w, err) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, agent.WorkspaceID); err != nil { - writeRBACError(w, err) - return - } - var req updateAgentFeishuConnectorBody - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - botOpenID, err := resolveFeishuBotOpenID(r.Context(), runtimeStore, agent.WorkspaceID, req.AppID, req.AppSecretRef, req.BotOpenID) - if err != nil { - log.Bg().Warn("feishu bot open_id auto-resolve failed", "agent_id", agentID, "app_id", req.AppID, "err", err) - writeJSON(w, http.StatusBadGateway, map[string]string{"error": "feishu_bot_open_id_resolve_failed", "detail": err.Error()}) - return - } - change, err := runtimeStore.UpdateAgentFeishuConnector(r.Context(), store.UpdateAgentFeishuConnectorInput{ - AgentID: agentID, - Enabled: req.Enabled, - AppID: req.AppID, - AppSecretRef: req.AppSecretRef, - VerificationTokenRef: req.VerificationTokenRef, - EncryptKeyRef: req.EncryptKeyRef, - BotOpenID: botOpenID, - EventMode: req.EventMode, - RoutingMode: req.RoutingMode, - }, actorIDFromRequest(r)) - if err != nil { - switch { - case errors.Is(err, store.ErrFeishuConnectorIncomplete): - // api-client.ts copies the JSON `error` field into - // both envelope.code and .message on the frontend, so - // the discriminator lives in `error`. `detail` carries - // human-readable text for logs/devtools. - writeJSON(w, http.StatusUnprocessableEntity, map[string]string{ - "error": "feishu_connector_incomplete", - "detail": err.Error(), - }) - return - case errors.Is(err, store.ErrFeishuAppIDInUse): - writeJSON(w, http.StatusConflict, map[string]string{ - "error": "feishu_app_id_in_use", - "detail": err.Error(), - }) - return - } - writeStoreAgentError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"feishu_connector": change}) - } -} - -// ============================================================ -// Workspace-dimension IM connectors (feishu / slack / discord) -// ------------------------------------------------------------ -// Bound to the workspace, NOT to an agent: the same dimension as the -// /list a user picks an agent from after @-summoning the shared bot. -// Credentials live in the vault; the table stores only *_ref UUIDs + -// non-secret fields. RBAC is enforced by the route gate middleware -// (member for GET, owner/admin for PATCH), so the handlers trust the -// URL workspaceID. -// ============================================================ - -type updateWorkspaceSlackConnectorBody struct { - Enabled bool `json:"enabled"` - AppID string `json:"app_id"` - BotTokenRef string `json:"bot_token_ref"` - AppTokenRef string `json:"app_token_ref"` - SigningSecretRef string `json:"signing_secret_ref"` - EventMode string `json:"event_mode"` -} - -type updateWorkspaceDiscordConnectorBody struct { - Enabled bool `json:"enabled"` - AppID string `json:"app_id"` - BotTokenRef string `json:"bot_token_ref"` - PublicKeyRef string `json:"public_key_ref"` - Intents string `json:"intents"` -} - -type updateWorkspaceTeamsConnectorBody struct { - Enabled bool `json:"enabled"` - AppID string `json:"app_id"` - AppPasswordRef string `json:"app_password_ref"` - TenantID string `json:"tenant_id"` -} - -type updateWorkspaceFeishuConnectorBody struct { - Enabled bool `json:"enabled"` - AppID string `json:"app_id"` - AppSecretRef string `json:"app_secret_ref"` - VerificationTokenRef string `json:"verification_token_ref"` - EncryptKeyRef string `json:"encrypt_key_ref"` - BotOpenID string `json:"bot_open_id"` - EventMode string `json:"event_mode"` -} - -// listWorkspaceConnectors returns all platforms' connector rows for the -// workspace, decoded (config jsonb → map). Backs the admin panel's -// initial state. Never exposes secret plaintext (only *_ref UUIDs). -// listWorkspaceConnectors lists the workspace's configured connectors. -// -// @Summary List workspace connectors -// @Description Returns the workspace's configured connectors. Owner/admin only. -// @Tags workspaces -// @ID listDevWorkspaceConnectors -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Success 200 {object} map[string]interface{} "Connector list" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Router /api/v1/workspaces/{workspaceID}/connectors [get] -func listWorkspaceConnectors(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - connectors, err := runtimeStore.GetWorkspaceIMConnectors(r.Context(), workspaceID) - if err != nil { - writeStoreAgentError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"connectors": connectors}) - } -} - -// updateWorkspaceSlackConnector updates the workspace-level Slack connector. -// -// @Summary Update the workspace's Slack connector -// @Description Updates the workspace-level Slack connector credentials/config. Owner/admin only. -// @Tags workspaces -// @ID updateDevWorkspaceSlackConnector -// @Accept json -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param body body updateWorkspaceSlackConnectorBody true "Connector config" -// @Success 200 {object} map[string]interface{} "Updated connector" -// @Failure 400 {object} map[string]string "Invalid body or UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Router /api/v1/workspaces/{workspaceID}/connector/slack [patch] -func updateWorkspaceSlackConnector(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - var req updateWorkspaceSlackConnectorBody - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - change, err := runtimeStore.UpsertWorkspaceSlackConnector(r.Context(), store.UpsertWorkspaceSlackConnectorInput{ - WorkspaceID: workspaceID, - Enabled: req.Enabled, - AppID: req.AppID, - BotTokenRef: req.BotTokenRef, - AppTokenRef: req.AppTokenRef, - SigningSecretRef: req.SigningSecretRef, - EventMode: req.EventMode, - }, actorIDFromRequest(r)) - if err != nil { - switch { - case errors.Is(err, store.ErrSlackConnectorIncomplete): - writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "slack_connector_incomplete", "detail": err.Error()}) - return - case errors.Is(err, store.ErrSlackAppIDInUse): - writeJSON(w, http.StatusConflict, map[string]string{"error": "slack_app_id_in_use", "detail": err.Error()}) - return - } - writeStoreAgentError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"connector": change}) - } -} - -// updateWorkspaceDiscordConnector updates the workspace-level Discord connector. -// -// @Summary Update the workspace's Discord connector -// @Description Updates the workspace-level Discord connector credentials/config. Owner/admin only. -// @Tags workspaces -// @ID updateDevWorkspaceDiscordConnector -// @Accept json -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param body body updateWorkspaceDiscordConnectorBody true "Connector config" -// @Success 200 {object} map[string]interface{} "Updated connector" -// @Failure 400 {object} map[string]string "Invalid body or UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Router /api/v1/workspaces/{workspaceID}/connector/discord [patch] -func updateWorkspaceDiscordConnector(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - var req updateWorkspaceDiscordConnectorBody - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - change, err := runtimeStore.UpsertWorkspaceDiscordConnector(r.Context(), store.UpsertWorkspaceDiscordConnectorInput{ - WorkspaceID: workspaceID, - Enabled: req.Enabled, - AppID: req.AppID, - BotTokenRef: req.BotTokenRef, - PublicKeyRef: req.PublicKeyRef, - Intents: req.Intents, - }, actorIDFromRequest(r)) - if err != nil { - switch { - case errors.Is(err, store.ErrDiscordConnectorIncomplete): - writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "discord_connector_incomplete", "detail": err.Error()}) - return - case errors.Is(err, store.ErrDiscordAppIDInUse): - writeJSON(w, http.StatusConflict, map[string]string{"error": "discord_app_id_in_use", "detail": err.Error()}) - return - } - writeStoreAgentError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"connector": change}) - } -} - -// updateWorkspaceTeamsConnector updates the workspace-level Teams connector. -// -// @Summary Update the workspace's Microsoft Teams connector -// @Description Updates the workspace-level Teams connector credentials/config. Owner/admin only. -// @Tags workspaces -// @ID updateDevWorkspaceTeamsConnector -// @Accept json -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param body body updateWorkspaceTeamsConnectorBody true "Connector config" -// @Success 200 {object} map[string]interface{} "Updated connector" -// @Failure 400 {object} map[string]string "Invalid body or UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Router /api/v1/workspaces/{workspaceID}/connector/teams [patch] -func updateWorkspaceTeamsConnector(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - var req updateWorkspaceTeamsConnectorBody - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - change, err := runtimeStore.UpsertWorkspaceTeamsConnector(r.Context(), store.UpsertWorkspaceTeamsConnectorInput{ - WorkspaceID: workspaceID, - Enabled: req.Enabled, - AppID: req.AppID, - AppPasswordRef: req.AppPasswordRef, - TenantID: req.TenantID, - }, actorIDFromRequest(r)) - if err != nil { - switch { - case errors.Is(err, store.ErrTeamsConnectorIncomplete): - writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "teams_connector_incomplete", "detail": err.Error()}) - return - case errors.Is(err, store.ErrTeamsAppIDInUse): - writeJSON(w, http.StatusConflict, map[string]string{"error": "teams_app_id_in_use", "detail": err.Error()}) - return - } - writeStoreAgentError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"connector": change}) - } -} - -// updateWorkspaceFeishuConnector updates the workspace-level Feishu connector. -// -// @Summary Update the workspace's Feishu connector -// @Description Updates the workspace-level Feishu connector credentials/config. Owner/admin only. -// @Tags workspaces -// @ID updateDevWorkspaceFeishuConnector -// @Accept json -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param body body updateWorkspaceFeishuConnectorBody true "Connector config" -// @Success 200 {object} map[string]interface{} "Updated connector" -// @Failure 400 {object} map[string]string "Invalid body or UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Router /api/v1/workspaces/{workspaceID}/connector/feishu [patch] -func updateWorkspaceFeishuConnector(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - var req updateWorkspaceFeishuConnectorBody - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - botOpenID, err := resolveFeishuBotOpenID(r.Context(), runtimeStore, workspaceID, req.AppID, req.AppSecretRef, req.BotOpenID) - if err != nil { - log.Bg().Warn("feishu bot open_id auto-resolve failed", "workspace_id", workspaceID, "app_id", req.AppID, "err", err) - writeJSON(w, http.StatusBadGateway, map[string]string{"error": "feishu_bot_open_id_resolve_failed", "detail": err.Error()}) - return - } - change, err := runtimeStore.UpsertWorkspaceFeishuConnector(r.Context(), store.UpsertWorkspaceFeishuConnectorInput{ - WorkspaceID: workspaceID, - Enabled: req.Enabled, - AppID: req.AppID, - AppSecretRef: req.AppSecretRef, - VerificationTokenRef: req.VerificationTokenRef, - EncryptKeyRef: req.EncryptKeyRef, - BotOpenID: botOpenID, - EventMode: req.EventMode, - }, actorIDFromRequest(r)) - if err != nil { - switch { - case errors.Is(err, store.ErrFeishuConnectorIncomplete): - writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "feishu_connector_incomplete", "detail": err.Error()}) - return - case errors.Is(err, store.ErrFeishuAppIDInUse): - writeJSON(w, http.StatusConflict, map[string]string{"error": "feishu_app_id_in_use", "detail": err.Error()}) - return - } - writeStoreAgentError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"connector": change}) - } -} - -// beginAgentFeishuProvisioning starts the async Feishu app provisioning. -// -// @Summary Begin Feishu provisioning -// @Description Starts the async Feishu app provisioning flow for the agent's connector. Returns a session handle to poll. -// @Tags feishu -// @ID beginDevAgentFeishuProvisioning -// @Produce json -// @Param agentID path string true "Agent UUID" -// @Success 200 {object} map[string]interface{} "Provisioning session" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 404 {object} map[string]string "Agent not found" -// @Router /api/v1/agents/{agentID}/connector/feishu/provision/begin [post] -func beginAgentFeishuProvisioning(runtimeStore RuntimeStore, cfg feishuRegistrationConfig) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if cfg.Client == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "feishu_app_registration_not_configured"}) - return - } - if _, ok := agentForFeishuConnectorWrite(w, r, runtimeStore); !ok { - return - } - begin, err := cfg.Client.Begin(r.Context()) - if err != nil { - log.Bg().Warn("feishu app registration begin failed", "err", err) - writeJSON(w, http.StatusBadGateway, map[string]string{"error": "feishu_app_registration_begin_failed"}) - return - } - writeJSON(w, http.StatusOK, feishuProvisioningResponse{Status: "pending", Begin: &begin, NextIntervalSec: begin.Interval}) - } -} - -// pollAgentFeishuProvisioning polls the async Feishu provisioning flow. -// -// @Summary Poll Feishu provisioning status -// @Description Polls the async Feishu provisioning flow. Returns the current step and any completion payload. -// @Tags feishu -// @ID pollDevAgentFeishuProvisioning -// @Accept json -// @Produce json -// @Param agentID path string true "Agent UUID" -// @Param body body pollAgentFeishuProvisioningBody true "Poll payload" -// @Success 200 {object} map[string]interface{} "Provisioning status" -// @Failure 400 {object} map[string]string "Invalid body or UUID" -// @Failure 404 {object} map[string]string "Provisioning session not found" -// @Router /api/v1/agents/{agentID}/connector/feishu/provision/poll [post] -func pollAgentFeishuProvisioning(runtimeStore RuntimeStore, cfg feishuRegistrationConfig) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if cfg.Client == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "feishu_app_registration_not_configured"}) - return - } - agent, ok := agentForFeishuConnectorWrite(w, r, runtimeStore) - if !ok { - return - } - var req pollAgentFeishuProvisioningBody - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - if strings.TrimSpace(req.DeviceCode) == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "device_code is required"}) - return - } - status, err := cfg.Client.Poll(r.Context(), req.DeviceCode, req.IntervalSec, req.TenantBrand) - if err != nil { - log.Bg().Warn("feishu app registration poll failed", "err", err) - writeJSON(w, http.StatusBadGateway, map[string]string{"error": "feishu_app_registration_poll_failed"}) - return - } - switch status.Kind { - case gatewaypkg.FeishuAppRegistrationPollPending: - writeJSON(w, http.StatusOK, feishuProvisioningResponse{Status: "pending", NextIntervalSec: status.NextIntervalSec}) - return - case gatewaypkg.FeishuAppRegistrationPollError: - writeJSON(w, http.StatusOK, feishuProvisioningResponse{Status: "error", Error: status.Error, Description: status.Description}) - return - case gatewaypkg.FeishuAppRegistrationPollSuccess: - // proceed below - default: - writeJSON(w, http.StatusBadGateway, map[string]string{"error": "feishu_app_registration_unknown_status"}) - return - } - - appID := strings.TrimSpace(status.ClientID) - appSecret := strings.TrimSpace(status.ClientSecret) - if appID == "" || appSecret == "" { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": "feishu_app_registration_missing_credentials"}) - return - } - if ok := assertFeishuAppIDAvailableForAgent(w, r.Context(), runtimeStore, appID, agent.ID); !ok { - return - } - - botInfo, err := validateProvisionedFeishuBot(r.Context(), appID, appSecret, feishuOpenAPIBaseURL(cfg.OpenAPIBaseURL)) - if err != nil { - log.Bg().Warn("feishu provisioned bot validation failed", "app_id", appID, "err", err) - writeJSON(w, http.StatusBadGateway, map[string]string{"error": "feishu_bot_validation_failed"}) - return - } - secret, err := createFeishuAppSecretFromProvisioning(r.Context(), runtimeStore, agent.WorkspaceID, agent.Name, appID, appSecret, actorIDFromRequest(r)) - if err != nil { - log.Bg().Warn("feishu provisioned app secret write failed", "app_id", appID, "err", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed_to_store_feishu_app_secret"}) - return - } - change, err := runtimeStore.UpdateAgentFeishuConnector(r.Context(), store.UpdateAgentFeishuConnectorInput{ - AgentID: agent.ID, - Enabled: true, - AppID: appID, - AppSecretRef: secret.ID, - BotOpenID: botInfo.OpenID, - EventMode: "websocket", - }, actorIDFromRequest(r)) - if err != nil { - switch { - case errors.Is(err, store.ErrFeishuAppIDInUse): - writeJSON(w, http.StatusConflict, map[string]string{"error": "feishu_app_id_in_use", "detail": err.Error()}) - case errors.Is(err, store.ErrFeishuConnectorIncomplete): - writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "feishu_connector_incomplete", "detail": err.Error()}) - default: - writeStoreAgentError(w, err) - } - return - } - writeJSON(w, http.StatusOK, feishuProvisioningResponse{ - Status: "success", - AppID: appID, - AppSecretRef: secret.ID, - BotOpenID: botInfo.OpenID, - BotName: botInfo.AppName, - FeishuConnector: &change, - }) - } -} - -func agentForFeishuConnectorWrite(w http.ResponseWriter, r *http.Request, runtimeStore RuntimeStore) (store.AgentSummary, bool) { - agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) - if runtimeStore == nil || !isUUID(agentID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) - return store.AgentSummary{}, false - } - agent, err := runtimeStore.GetAgent(r.Context(), agentID) - if err != nil { - writeStoreAgentError(w, err) - return store.AgentSummary{}, false - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, agent.WorkspaceID); err != nil { - writeRBACError(w, err) - return store.AgentSummary{}, false - } - return agent, true -} - -func assertFeishuAppIDAvailableForAgent(w http.ResponseWriter, ctx context.Context, runtimeStore RuntimeStore, appID, agentID string) bool { - existing, err := runtimeStore.GetAgentByFeishuAppID(ctx, appID) - switch { - case err == nil: - if existing.AgentID != agentID { - writeJSON(w, http.StatusConflict, map[string]string{"error": "feishu_app_id_in_use"}) - return false - } - case errors.Is(err, store.ErrUnknownFeishuAgent): - return true - default: - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed_to_check_feishu_app_id"}) - return false - } - return true -} - -func validateProvisionedFeishuBot(ctx context.Context, appID, appSecret, openAPIBaseURL string) (gatewaypkg.FeishuBotInfo, error) { - client, err := gatewaypkg.NewFeishuTenantClient(gatewaypkg.FeishuTenantClientOptions{ - AppID: appID, - BaseURL: openAPIBaseURL, - }) - if err != nil { - return gatewaypkg.FeishuBotInfo{}, err - } - validateCtx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - return client.BotInfo(validateCtx, appSecret) -} - -// resolveFeishuBotOpenID fills in the bot's open_id when the caller left it -// blank. The value is derivable from the app credentials (bot/v3/info returns -// it), so the bind form no longer needs it as a manual field. When botOpenID is -// already set it is returned untouched; when app_id or app_secret_ref is absent -// there is nothing to derive from, so the blank is passed through and the store's -// own completeness check decides whether that is acceptable. -func resolveFeishuBotOpenID(ctx context.Context, runtimeStore RuntimeStore, workspaceID, appID, appSecretRef, botOpenID string) (string, error) { - botOpenID = strings.TrimSpace(botOpenID) - if botOpenID != "" { - return botOpenID, nil - } - appID = strings.TrimSpace(appID) - appSecretRef = strings.TrimSpace(appSecretRef) - if appID == "" || appSecretRef == "" { - return "", nil - } - appSecret, err := loadFeishuSecretString(ctx, runtimeStore, workspaceID, appSecretRef, "app_secret", "secret", "value", "api_key") - if err != nil { - return "", err - } - info, err := validateProvisionedFeishuBot(ctx, appID, appSecret, feishuOpenAPIBaseURL("")) - if err != nil { - return "", err - } - return strings.TrimSpace(info.OpenID), nil -} - -func createFeishuAppSecretFromProvisioning(ctx context.Context, runtimeStore RuntimeStore, workspaceID, agentName, appID, appSecret, actorID string) (store.SecretRead, error) { - masterKey := strings.TrimSpace(os.Getenv("PARSAR_MASTER_KEY")) - if masterKey == "" { - return store.SecretRead{}, errors.New("PARSAR_MASTER_KEY env not set") - } - secretService, err := secrets.New(masterKey) - if err != nil { - return store.SecretRead{}, err - } - payload := map[string]any{ - "app_id": appID, - "app_secret": appSecret, - "source": "feishu_qr_provisioning", - } - encrypted, err := secretService.Encrypt(payload) - if err != nil { - return store.SecretRead{}, err - } - name := "Feishu Bot App Secret" - if strings.TrimSpace(agentName) != "" { - name = fmt.Sprintf("%s Feishu Bot App Secret", strings.TrimSpace(agentName)) - } - return runtimeStore.CreateSecret(ctx, store.CreateSecretInput{ - WorkspaceID: workspaceID, - Name: name, - Kind: "feishu_app_secret", - Provider: "feishu", - AuthType: "app_secret", - Payload: payload, - Masked: secrets.MaskPayload(payload), - CreatedBy: actorID, - }, encrypted) -} - -func feishuOpenAPIBaseURL(configured string) string { - if strings.TrimSpace(configured) != "" { - return strings.TrimSpace(configured) - } - if v := strings.TrimSpace(os.Getenv("PARSAR_FEISHU_OPENAPI_BASE_URL")); v != "" { - return v - } - return strings.TrimSpace(os.Getenv(authfeishu.EnvAPIBase)) -} - -// deleteAgent soft-deletes an agent. Owner/admin only. -// -// @Summary Soft-delete an agent -// @Description Marks the agent as deleted; existing conversations are retained. Owner/admin only. -// @Tags agents -// @ID deleteDevAgent -// @Produce json -// @Param agentID path string true "Agent UUID" -// @Success 200 {object} map[string]interface{} "Deleted agent" -// @Failure 400 {object} map[string]string "agent_id must be a valid uuid" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 404 {object} map[string]string "Unknown agent" -// @Router /api/v1/agents/{agentID} [delete] -func deleteAgent(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) - if runtimeStore == nil || !isUUID(agentID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) - return - } - agent, err := runtimeStore.GetAgent(r.Context(), agentID) - if err != nil { - writeStoreAgentError(w, err) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, agent.WorkspaceID); err != nil { - writeRBACError(w, err) - return - } - result, runCount, err := runtimeStore.DeleteAgent(r.Context(), agentID, actorIDFromRequest(r)) - if errors.Is(err, store.ErrInFlightAgentRuns) { - writeJSON(w, http.StatusConflict, map[string]any{"error": "in_flight_runs", "run_count": runCount}) - return - } - if err != nil { - writeStoreAgentError(w, err) - return - } - writeJSON(w, http.StatusOK, result) - } -} - -func agentStatusHandler(runtimeStore RuntimeStore, action string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed agent lifecycle is disabled"}) - return - } - agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) - if !isUUID(agentID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) - return - } - workspaceID, ok := workspaceIDForAgent(w, r.Context(), runtimeStore, agentID) - if !ok { - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - var ( - result store.AgentStatusRead - err error - ) - switch action { - case "disable": - result, err = runtimeStore.DisableAgent(r.Context(), agentID) - case "enable": - result, err = runtimeStore.EnableAgent(r.Context(), agentID) - default: - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "unsupported agent action"}) - return - } - if err != nil { - if errors.Is(err, store.ErrUnknownAgent) { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("failed to %s agent", action)}) - return - } - writeJSON(w, http.StatusOK, result) - } -} - -// createSecret adds a secret entry to a workspace's vault. -// -// @Summary Create a workspace secret -// @Description Adds a secret entry to the workspace vault. Owner/admin only. -// @Tags secrets -// @ID createDevSecret -// @Accept json -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param body body createSecretBody true "Secret create payload" -// @Success 201 {object} map[string]interface{} "Created secret" -// @Failure 400 {object} map[string]string "Invalid body or UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Router /api/v1/workspaces/{workspaceID}/secrets [post] -func createSecret(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed secret vault is disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - var req createSecretBody - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Provider) == "" || strings.TrimSpace(req.AuthType) == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name, provider, and auth_type are required"}) - return - } - serverMasterKey := os.Getenv("PARSAR_MASTER_KEY") - if serverMasterKey == "" { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "server has no PARSAR_MASTER_KEY configured; refusing to create a secret"}) - return - } - secretService, err := secrets.New(serverMasterKey) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - encrypted, err := secretService.Encrypt(req.Payload) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to encrypt secret"}) - return - } - secret, err := runtimeStore.CreateSecret(r.Context(), store.CreateSecretInput{ - WorkspaceID: workspaceID, - Name: req.Name, - Kind: req.Kind, - Provider: req.Provider, - AuthType: req.AuthType, - Payload: req.Payload, - Masked: secrets.MaskPayload(req.Payload), - }, encrypted) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create secret"}) - return - } - writeJSON(w, http.StatusCreated, secret) - } -} - -// listSecrets lists secret rows for a workspace. -// -// @Summary List workspace secrets -// @Description Returns secret rows for the workspace. Values are redacted. Owner/admin only. -// @Tags secrets -// @ID listDevSecrets -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Success 200 {object} map[string]interface{} "Secret list" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Router /api/v1/workspaces/{workspaceID}/secrets [get] -func listSecrets(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed secret vault is disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - limit := parseLimit(r, 100) - secrets, err := runtimeStore.ListSecrets(r.Context(), workspaceID, limit) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list secrets"}) - return - } - writeJSON(w, http.StatusOK, map[string]any{"secrets": secrets}) - } -} - -// disableSecret marks a secret row as disabled. -// -// @Summary Disable a secret -// @Description Marks the secret as disabled so agents can no longer bind to it. Owner/admin only. -// @Tags secrets -// @ID disableDevSecret -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param secretID path string true "Secret UUID" -// @Success 200 {object} map[string]interface{} "Disabled secret" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 404 {object} map[string]string "Secret not found" -// @Router /api/v1/workspaces/{workspaceID}/secrets/{secretID}/disable [post] -func disableSecret(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed secret vault is disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - secretID := strings.TrimSpace(chi.URLParam(r, "secretID")) - if !isUUID(secretID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "secret_id must be a valid uuid"}) - return - } - secret, err := runtimeStore.DisableSecret(r.Context(), workspaceID, secretID) - if err != nil { - if errors.Is(err, store.ErrUnknownSecret) { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to disable secret"}) - return - } - writeJSON(w, http.StatusOK, secret) - } -} - -// ============================================================ -// createModel creates a model row in a workspace. -// -// @Summary Create a model in a workspace -// @Description Creates a new model row bound to a provider and credential. Owner/admin only. -// @Tags models -// @ID createDevModel -// @Accept json -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param body body createModelBody true "Model create payload" -// @Success 201 {object} map[string]interface{} "Created model" -// @Failure 400 {object} map[string]string "Invalid body or UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Router /api/v1/workspaces/{workspaceID}/models [post] -func createModel(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed model registry is disabled"}) - return - } - // Model catalog is org-global; URL workspaceID is only used - // for RBAC. The created model is NOT scoped to it. - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - var req createModelBody - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - mode := strings.TrimSpace(req.CredentialMode) - if mode == "" { - mode = "inline_secret" - } - model, err := runtimeStore.CreateModel(r.Context(), store.CreateModelInput{ - Name: req.Name, - ProviderType: req.ProviderType, - Adapter: req.Adapter, - BaseURL: req.BaseURL, - ModelKey: req.ModelKey, - CredentialMode: mode, - SecretID: req.SecretID, - CredentialKindCode: req.CredentialKindCode, - Config: foldModelConfig(req.Config, req.Capabilities, req.Limits), - CreatedBy: actorIDFromRequest(r), - }) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create model"}) - return - } - writeJSON(w, http.StatusCreated, model) - } -} - -// disableModel marks a model row as disabled. -// -// @Summary Disable a model -// @Description Marks the model as disabled. Owner/admin only. Disabled models are hidden from selectors. -// @Tags models -// @ID disableDevModel -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param modelID path string true "Model UUID" -// @Success 200 {object} map[string]interface{} "Disabled model" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 404 {object} map[string]string "Model not found" -// @Router /api/v1/workspaces/{workspaceID}/models/{modelID}/disable [post] -func disableModel(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed model registry is disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - modelID := strings.TrimSpace(chi.URLParam(r, "modelID")) - if !isUUID(modelID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "model_id must be a valid uuid"}) - return - } - model, err := runtimeStore.DisableModel(r.Context(), workspaceID, modelID) - if err != nil { - if errors.Is(err, store.ErrUnknownModel) { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to disable model"}) - return - } - writeJSON(w, http.StatusOK, model) - } -} - -// updateModel applies a partial update to a model row. -// -// @Summary Update a model -// @Description Partially updates a model's mutable fields. Owner/admin only. -// @Tags models -// @ID updateDevModel -// @Accept json -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param modelID path string true "Model UUID" -// @Param body body updateModelBody true "Model update payload" -// @Success 200 {object} map[string]interface{} "Updated model" -// @Failure 400 {object} map[string]string "Invalid body or UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 404 {object} map[string]string "Model not found" -// @Router /api/v1/workspaces/{workspaceID}/models/{modelID} [patch] -func updateModel(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed model registry is disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - modelID := strings.TrimSpace(chi.URLParam(r, "modelID")) - if !isUUID(modelID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "model_id must be a valid uuid"}) - return - } - var req updateModelBody - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - if strings.TrimSpace(req.Name) == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) - return - } - if strings.TrimSpace(req.ModelKey) == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "model_key is required"}) - return - } - model, err := runtimeStore.UpdateModel(r.Context(), store.UpdateModelInput{ - ModelID: modelID, - Name: req.Name, - ModelKey: req.ModelKey, - BaseURL: req.BaseURL, - SecretID: req.SecretID, - CredentialKindCode: req.CredentialKindCode, - Config: foldModelConfig(req.Config, req.Capabilities, req.Limits), - }) - if err != nil { - if errors.Is(err, store.ErrUnknownModel) { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to update model"}) - return - } - writeJSON(w, http.StatusOK, model) - } -} - -// testModelHTTPClient is overridable by tests so the unit tests can -// point the connectivity check at a httptest.Server instead of -// reaching the real upstream. -var testModelHTTPClient = &http.Client{Timeout: 15 * time.Second} - -func isOpenAIChatCompletionsAdapter(adapter string) bool { - switch strings.TrimSpace(adapter) { - case "openai", "openai_compatible", "openai-compatible", "@ai-sdk/openai", "@ai-sdk/openai-compatible": - return true - default: - return false - } -} - -func isAnthropicMessagesAdapter(adapter string) bool { - switch strings.TrimSpace(adapter) { - case "anthropic", "anthropic_compatible", "anthropic-compatible", "@ai-sdk/anthropic": - return true - default: - return false - } -} - -func anthropicMessagesURL(baseURL string) string { - base := strings.TrimRight(strings.TrimSpace(baseURL), "/") - switch { - case strings.HasSuffix(base, "/v1/messages"), strings.HasSuffix(base, "/messages"): - return base - case strings.HasSuffix(base, "/v1"): - return base + "/messages" - default: - return base + "/v1/messages" - } -} - -type connectivityTestResponse struct { - Supported bool `json:"supported"` - Success bool `json:"success"` - LatencyMS int64 `json:"latency_ms"` - Status int `json:"http_status,omitempty"` - Error string `json:"error,omitempty"` - Sample string `json:"sample,omitempty"` -} - -// testModelConnectivity sends a minimal request to the upstream -// provider so the admin can verify base_url + api_key + custom headers -// + model_key without driving a full Agent Run. OpenAI-shaped adapters -// use chat-completions; Anthropic-shaped use Messages. Other protocols -// return supported=false. -// testModelConnectivity issues a live probe against a model row's credentials. -// -// @Summary Test a model's connectivity -// @Description Runs a live probe against the model's configured provider and credentials. Owner/admin only. -// @Tags models -// @ID testDevModelConnectivity -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param modelID path string true "Model UUID" -// @Success 200 {object} map[string]interface{} "Probe result" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 404 {object} map[string]string "Model not found" -// @Router /api/v1/workspaces/{workspaceID}/models/{modelID}/test [post] -func testModelConnectivity(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed model registry is disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - modelID := strings.TrimSpace(chi.URLParam(r, "modelID")) - if !isUUID(modelID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "model_id must be a valid uuid"}) - return - } - - // ResolveModelRuntimeForUser handles both modes in one shot. - // For credential_ref mode passing "" returns ErrModelDisabled, - // which we surface as supported=false below. - callerUserID := actorIDFromRequest(r) - if callerUserID == "" { - callerUserID = "" - } - mr, err := runtimeStore.ResolveModelRuntimeForUser(r.Context(), modelID, callerUserID) - if err != nil { - if errors.Is(err, store.ErrUnknownModel) { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - if errors.Is(err, store.ErrModelDisabled) { - writeJSON(w, http.StatusOK, connectivityTestResponse{ - Supported: true, - Success: false, - Error: err.Error(), - }) - return - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to resolve model"}) - return - } - - isOpenAICompatible := isOpenAIChatCompletionsAdapter(mr.Adapter) - isAnthropicCompatible := isAnthropicMessagesAdapter(mr.Adapter) - - if !isOpenAICompatible && !isAnthropicCompatible { - writeJSON(w, http.StatusOK, connectivityTestResponse{ - Supported: false, - Error: "connectivity test only supports OpenAI chat-completions and Anthropic messages compatible providers", - }) - return - } - - // Pick the encrypted payload: - // inline_secret → fetched via secret_id below. - // credential_ref → filled from the caller's user_credentials. - var encryptedPayload []byte - if mr.CredentialMode == "credential_ref" { - encryptedPayload = mr.EncryptedPayload - } else { - if mr.SecretID == "" { - writeJSON(w, http.StatusOK, connectivityTestResponse{ - Supported: true, - Success: false, - Error: "no API key bound to this model", - }) - return - } - sp, err := runtimeStore.GetSecretPayload(r.Context(), workspaceID, mr.SecretID) - if err != nil { - writeJSON(w, http.StatusOK, connectivityTestResponse{ - Supported: true, - Success: false, - Error: fmt.Sprintf("failed to fetch secret: %v", err), - }) - return - } - encryptedPayload = sp.EncryptedPayload - } - - secretService, err := secrets.New(os.Getenv("PARSAR_MASTER_KEY")) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "secrets service unavailable: " + err.Error()}) - return - } - payload, err := secretService.Decrypt(encryptedPayload) - if err != nil { - writeJSON(w, http.StatusOK, connectivityTestResponse{ - Supported: true, - Success: false, - Error: "failed to decrypt credential: " + err.Error(), - }) - return - } - // Two payload shapes coexist: - // `secrets` rows (inline_secret) carry {api_key: "..."} - // `user_credentials` rows (credential_ref) carry {value: "..."} - // Both encode an upstream-provider API key; accept either. - apiKey, _ := payload["api_key"].(string) - if strings.TrimSpace(apiKey) == "" { - if v, _ := payload["value"].(string); strings.TrimSpace(v) != "" { - apiKey = v - } - } - if strings.TrimSpace(apiKey) == "" { - writeJSON(w, http.StatusOK, connectivityTestResponse{ - Supported: true, - Success: false, - Error: "credential payload missing api_key / value field", - }) - return - } - - // Build a minimal protocol-shaped request. - url := "" - body := map[string]any{} - if isAnthropicCompatible { - url = anthropicMessagesURL(mr.BaseURL) - body = map[string]any{ - "model": mr.ModelKey, - "messages": []map[string]any{{"role": "user", "content": "ping"}}, - "max_tokens": 16, - } - } else { - url = strings.TrimRight(mr.BaseURL, "/") + "/chat/completions" - body = map[string]any{ - "model": mr.ModelKey, - "messages": []map[string]any{{"role": "user", "content": "ping"}}, - "max_tokens": 16, - } - } - bodyBytes, _ := json.Marshal(body) - req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, url, strings.NewReader(string(bodyBytes))) - if err != nil { - writeJSON(w, http.StatusOK, connectivityTestResponse{ - Supported: true, - Success: false, - Error: "failed to build request: " + err.Error(), - }) - return - } - req.Header.Set("Content-Type", "application/json") - if isAnthropicCompatible { - req.Header.Set("x-api-key", apiKey) - req.Header.Set("anthropic-version", "2023-06-01") - } else { - req.Header.Set("Authorization", "Bearer "+apiKey) - } - // Provider-level custom headers (e.g. X-Sub-Module for an internal gateway). - if hdrs, ok := mr.ProviderConfig["headers"].(map[string]any); ok { - for k, v := range hdrs { - if s, ok := v.(string); ok { - req.Header.Set(k, s) - } - } - } - - start := time.Now() - resp, err := testModelHTTPClient.Do(req) - latency := time.Since(start).Milliseconds() - if err != nil { - writeJSON(w, http.StatusOK, connectivityTestResponse{ - Supported: true, - Success: false, - LatencyMS: latency, - Error: "request failed: " + err.Error(), - }) - return - } - defer resp.Body.Close() - respBytes, _ := io.ReadAll(resp.Body) - - var parsed map[string]any - _ = json.Unmarshal(respBytes, &parsed) - - // HTTP 200 != business success — many gateways respond 200 with - // an `error` object inside the body. Treat non-2xx OR - // `error` field in body as failure. - if resp.StatusCode < 200 || resp.StatusCode >= 300 || parsed["error"] != nil { - msg := strings.TrimSpace(string(respBytes)) - if eo, ok := parsed["error"].(map[string]any); ok { - if m, ok := eo["message"].(string); ok { - msg = m - } - } - if len(msg) > 500 { - msg = msg[:500] + "…" - } - writeJSON(w, http.StatusOK, connectivityTestResponse{ - Supported: true, - Success: false, - LatencyMS: latency, - Status: resp.StatusCode, - Error: msg, - }) - return - } - - // Pull a sample first message content for the UI to display. - sample := "" - if isAnthropicCompatible { - if content, ok := parsed["content"].([]any); ok { - for _, part := range content { - if p, ok := part.(map[string]any); ok { - if text, ok := p["text"].(string); ok && strings.TrimSpace(text) != "" { - sample = strings.TrimSpace(text) - break - } - } - } - } else if content, ok := parsed["content"].(string); ok { - sample = strings.TrimSpace(content) - } - } else if choices, ok := parsed["choices"].([]any); ok && len(choices) > 0 { - if first, ok := choices[0].(map[string]any); ok { - if msg, ok := first["message"].(map[string]any); ok { - if c, ok := msg["content"].(string); ok { - sample = strings.TrimSpace(c) - } - } - } - } - if len(sample) > 200 { - sample = sample[:200] + "…" - } - writeJSON(w, http.StatusOK, connectivityTestResponse{ - Supported: true, - Success: true, - LatencyMS: latency, - Status: resp.StatusCode, - Sample: sample, - }) - } -} - -// listModels lists model rows for a workspace. -// -// @Summary List workspace models -// @Description Returns model rows for the workspace. Caller must be a workspace member. -// @Tags models -// @ID listDevModels -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Success 200 {object} map[string]interface{} "Model list" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not a workspace member" -// @Router /api/v1/workspaces/{workspaceID}/models [get] -func listModels(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed model registry is disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - models, err := runtimeStore.ListModels(r.Context(), workspaceID, parseLimit(r, 100)) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list models"}) - return - } - writeJSON(w, http.StatusOK, map[string]any{"models": models}) - } -} - -// requeueAgentRun re-schedules a failed or stalled agent run. -// -// @Summary Re-queue an agent run -// @Description Re-schedules a failed or stalled agent run. Owner/admin only. -// @Tags agent-runs -// @ID requeueDevAgentRun -// @Accept json -// @Produce json -// @Param runID path string true "Agent run UUID" -// @Param body body requeueAgentRunBody true "Requeue payload" -// @Success 200 {object} map[string]interface{} "Requeued run" -// @Failure 400 {object} map[string]string "Invalid body or UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 404 {object} map[string]string "Run not found" -// @Router /api/v1/agent-runs/{runID}/requeue [post] -func requeueAgentRun(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed run retry is disabled"}) - return - } - runID := strings.TrimSpace(chi.URLParam(r, "runID")) - if !isUUID(runID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "run_id must be a valid uuid"}) - return - } - - var req requeueAgentRunBody - if r.Body != nil { - if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - } - run, err := runtimeStore.GetAgentRun(r.Context(), runID) - if err != nil { - writeReadError(w, err, "failed to get agent run") - return - } - if err := requireWorkspaceMemberNotViewer(r, runtimeStore, run.WorkspaceID); err != nil { - if errors.Is(err, auth.ErrNotMember) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"}) - return - } - writeRBACError(w, err) - return - } - reason := strings.TrimSpace(req.Reason) - if reason == "" { - reason = "manual_retry" - } - result, err := runtimeStore.RequeueFailedAgentRun(r.Context(), store.RequeueAgentRunInput{RunID: runID, Source: "dev_retry", Reason: reason}) - if err != nil { - switch { - case errors.Is(err, store.ErrUnknownAgentRun): - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - case errors.Is(err, store.ErrAgentRunNotCompletable): - writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) - default: - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to requeue agent run"}) - } - return - } - if recorder, ok := runtimeStore.(runLifecycleEventRecorder); ok { - recordRunLifecycleEvent(recorder, runID, "run.requeued", map[string]any{"source": "dev_retry", "reason": reason, "previous_status": run.Status}, time.Now().UTC()) - } - writeJSON(w, http.StatusOK, result) - } -} - -// configureConversationExternalRef binds a conversation to an external reference. -// -// @Summary Configure a conversation's external reference -// @Description Binds a conversation to an external system reference (e.g. Feishu thread key) for dedupe / re-attach. -// @Tags conversations -// @ID configureDevConversationExternalRef -// @Accept json -// @Produce json -// @Param conversationID path string true "Conversation UUID" -// @Param body body configureConversationExternalRefBody true "External reference payload" -// @Success 200 {object} map[string]interface{} "Updated conversation" -// @Failure 400 {object} map[string]string "Invalid body or UUID" -// @Failure 404 {object} map[string]string "Conversation not found" -// @Router /api/v1/conversations/{conversationID}/external-ref [post] -func configureConversationExternalRef(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed conversation mapping is disabled"}) - return - } - conversationID := strings.TrimSpace(chi.URLParam(r, "conversationID")) - if !isUUID(conversationID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "conversation_id must be a valid uuid"}) - return - } - conversation, err := runtimeStore.GetConversation(r.Context(), conversationID) - if err != nil { - writeReadError(w, err, "failed to load conversation") - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, conversation.WorkspaceID); err != nil { - writeRBACError(w, err) - return - } - var req configureConversationExternalRefBody - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - if strings.TrimSpace(req.Gateway) == "" { - req.Gateway = "dev" - } - if strings.TrimSpace(req.ExternalChatID) == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "external_chat_id is required"}) - return - } - result, err := runtimeStore.ConfigureDevConversationExternalRef(r.Context(), store.ConfigureDevConversationExternalRefInput{ - ConversationID: conversationID, - Gateway: req.Gateway, - ExternalChatID: req.ExternalChatID, - ExternalThreadID: req.ExternalThreadID, - }) - if err != nil { - if errors.Is(err, store.ErrUnknownConversation) { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to configure conversation external ref"}) - return - } - writeJSON(w, http.StatusOK, result) - } -} - -func isSafeHTTPAgentEndpoint(endpoint string) bool { - parsed, err := url.Parse(endpoint) - if err != nil || parsed.Host == "" { - return false - } - return parsed.Scheme == "http" || parsed.Scheme == "https" -} - -// listWorkspaceEnabledAgents lists the agents visible to the caller in -// a workspace. By default only enabled/active agents are returned; -// `?include_disabled=true` widens the query to the admin view. -// -// @Summary List active agents in a workspace -// @Description Returns agents the caller can address in the given workspace. By default only enabled/active agents; pass include_disabled=true for the admin view. -// @Tags agents -// @ID listDevAgents -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param include_disabled query bool false "Include disabled/archived agents in the response" -// @Success 200 {object} map[string]interface{} "Active workspace agents with profile basics" -// @Failure 400 {object} map[string]string "workspace_id must be a valid uuid" -// @Failure 403 {object} map[string]string "Caller is not an active workspace member" -// @Failure 503 {object} map[string]string "Database-backed read APIs are disabled" -// @Router /api/v1/workspaces/{workspaceID}/agents [get] -func listWorkspaceEnabledAgents(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - - includeDisabled := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include_disabled")), "true") - var ( - agents []store.AgentRead - err error - ) - if includeDisabled { - agents, err = runtimeStore.ListWorkspaceAgentsForAdmin(r.Context(), workspaceID) - } else { - agents, err = runtimeStore.ListWorkspaceEnabledAgents(r.Context(), workspaceID) - } - if err != nil { - writeReadError(w, err, "failed to list agents") - return - } - writeJSON(w, http.StatusOK, map[string]any{"workspace_id": workspaceID, "agents": agents}) - } -} - -// getConversationTimeline returns the ordered timeline for a conversation. -// -// @Summary Get a conversation's timeline -// @Description Returns the ordered timeline (messages, tool calls, events) for a conversation. -// @Tags conversations -// @ID getDevConversationTimeline -// @Produce json -// @Param conversationID path string true "Conversation UUID" -// @Success 200 {object} map[string]interface{} "Timeline entries" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller lacks permission" -// @Failure 404 {object} map[string]string "Conversation not found" -// @Router /api/v1/conversations/{conversationID}/timeline [get] -func getConversationTimeline(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - conversationID := strings.TrimSpace(chi.URLParam(r, "conversationID")) - if !isUUID(conversationID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "conversation_id must be a valid uuid"}) - return - } - // Timeline doesn't carry workspace_id, so reverse-lookup the - // parent conversation to find the workspace to authorise against. - conv, err := runtimeStore.GetConversation(r.Context(), conversationID) - if err != nil { - writeReadError(w, err, "failed to load conversation for rbac check") - return - } - if err := requireWorkspaceMember(r, runtimeStore, conv.WorkspaceID); err != nil { - writeRBACError(w, err) - return - } - - timeline, err := runtimeStore.GetConversationTimeline(r.Context(), conversationID, parseLimit(r, 100)) - if err != nil { - writeReadError(w, err, "failed to get conversation timeline") - return - } - writeJSON(w, http.StatusOK, timeline) - } -} - -// getAgentRun returns details for a single agent run. -// -// @Summary Get an agent run -// @Description Returns the full record for a single agent run. Caller must belong to the run's workspace. -// @Tags agent-runs -// @ID getDevAgentRun -// @Produce json -// @Param runID path string true "Agent run UUID" -// @Success 200 {object} map[string]interface{} "Agent run" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller lacks permission" -// @Failure 404 {object} map[string]string "Run not found" -// @Router /api/v1/agent-runs/{runID} [get] -func getAgentRun(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - runID := strings.TrimSpace(chi.URLParam(r, "runID")) - if !isUUID(runID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "run_id must be a valid uuid"}) - return - } - - run, err := runtimeStore.GetAgentRun(r.Context(), runID) - if err != nil { - writeReadError(w, err, "failed to get agent run") - return - } - // Load first to discover the parent workspace, then gate. - if err := requireWorkspaceMember(r, runtimeStore, run.WorkspaceID); err != nil { - writeRBACError(w, err) - return - } - writeJSON(w, http.StatusOK, run) - } -} - -// listGatewayOutbound dumps a slice of the inflight-card driver state -// for ops / smoke tests. Each row carries conversation_id / agent_run_id -// so ops can correlate against agent_runs + conversations. -// listGatewayOutbound lists pending outbound gateway messages. -// -// @Summary List outbound gateway messages -// @Description Returns outbound gateway messages, optionally filtered by connector or delivery status. -// @Tags gateway -// @ID listDevGatewayOutbound -// @Produce json -// @Success 200 {object} map[string]interface{} "Outbound message rows" -// @Router /dev/gateway/outbound [get] -func listGatewayOutbound(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed gateway outbound is disabled"}) - return - } - gateway := strings.TrimSpace(r.URL.Query().Get("gateway")) - // inflightCutoffWindow is ~5m; debug surface uses a longer - // look-back, with the limit capping response size. - cutoff := time.Now().UTC().Add(-1 * time.Hour) - convs, err := runtimeStore.ListActiveFeishuInflightConversations(r.Context(), cutoff, parseLimit(r, 100)) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list gateway inflight conversations"}) - return - } - if gateway == "" { - gateway = "feishu" - } - writeJSON(w, http.StatusOK, map[string]any{"gateway": gateway, "inflight": inflightDeliveries(convs)}) - } -} - -func inflightDeliveries(convs []store.FeishuInflightConversation) []map[string]any { - deliveries := make([]map[string]any, 0, len(convs)) - for _, c := range convs { - row := map[string]any{ - "conversation_id": c.ConversationID, - "workspace_id": c.WorkspaceID, - "agent_run_id": c.AgentRunID, - "external_chat_id": c.ExternalChatID, - "external_thread_id": c.ExternalThreadID, - "source_app_id": c.SourceAppID, - "run_status": c.RunStatus, - "max_seq": c.MaxEventSequence, - } - // Pull the working slot (msg id / retry triad / seq_emitted) - // out of conversation gateway_inflight metadata. - if inflight, ok := c.ConversationMetadata["gateway_inflight"].(map[string]any); ok { - if working, ok := inflight["working"].(map[string]any); ok { - row["working_msg_id"] = working["external_msg_id"] - row["seq_emitted"] = working["seq_emitted"] - if v, ok := working["attempts"]; ok { - row["working_attempts"] = v - } - if v, ok := working["last_error"]; ok { - row["working_last_error"] = v - } - if v, ok := working["next_retry_at"]; ok { - row["working_next_retry_at"] = v - } - } - } - deliveries = append(deliveries, row) - } - return deliveries -} - -// markGatewayOutboundDelivered marks an outbound message as delivered. -// -// @Summary Mark an outbound gateway message delivered -// @Description Marks a gateway outbound message row as delivered. Used by the connector daemon to close the loop. -// @Tags gateway -// @ID markDevGatewayOutboundDelivered -// @Accept json -// @Produce json -// @Param messageID path string true "Message ID" -// @Param body body markGatewayOutboundDeliveredBody true "Delivery payload" -// @Success 200 {object} map[string]interface{} "Updated row" -// @Failure 400 {object} map[string]string "Invalid body or ID" -// @Failure 404 {object} map[string]string "Message not found" -// @Router /dev/gateway/outbound/{messageID}/delivered [post] -func markGatewayOutboundDelivered(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed gateway outbound is disabled"}) - return - } - messageID := strings.TrimSpace(chi.URLParam(r, "messageID")) - if !isUUID(messageID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "message_id must be a valid uuid"}) - return - } - var req markGatewayOutboundDeliveredBody - if r.Body != nil { - if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - } - result, err := runtimeStore.MarkGatewayOutboundDelivered(r.Context(), store.MarkGatewayOutboundDeliveredInput{MessageID: messageID, DeliveryID: req.DeliveryID}) - if err != nil { - if errors.Is(err, store.ErrUnknownMessage) { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to mark gateway outbound delivered"}) - return - } - writeJSON(w, http.StatusOK, result) - } -} - -// listWorkspaceAgentRuns lists agent runs within a workspace. -// -// @Summary List workspace agent runs -// @Description Returns agent-run rows for the workspace. Caller must be a workspace member. -// @Tags agent-runs -// @ID listDevWorkspaceAgentRuns -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Success 200 {object} map[string]interface{} "Agent run rows" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not a workspace member" -// @Router /api/v1/workspaces/{workspaceID}/agent-runs [get] -func listWorkspaceAgentRuns(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - - // `?status=` accepts a comma-separated list so the admin - // "In progress" tab can union {running,queued} in one round-trip. - // Empty values are stripped. The SQL `cardinality(...) = 0` - // branch handles the no-filter case. - statuses := parseStatusList(r.URL.Query().Get("status")) - limit := parseLimit(r, 100) - offset := parseOffset(r) - - result, err := runtimeStore.ListWorkspaceAgentRuns(r.Context(), workspaceID, statuses, limit, offset) - if err != nil { - writeReadError(w, err, "failed to list agent runs") - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "workspace_id": workspaceID, - "statuses": statuses, - "agent_runs": result.Runs, - "total": result.Total, - "limit": limit, - "offset": offset, - }) - } -} - -// getAgentMetrics returns aggregated run-history counters for -// a single agent over a sliding window. Powers the agent-detail -// "Last N days performance" panel: completion count, success rate, average duration. -// `?days=` is optional and clamps to [1, 365]; default 30. -// getAgentMetrics returns runtime/usage metrics for an agent. -// -// @Summary Get agent metrics -// @Description Returns runtime/usage metrics for the agent within the workspace. Caller must be a workspace member. -// @Tags agents -// @ID getDevAgentMetrics -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param agentID path string true "Agent UUID" -// @Success 200 {object} map[string]interface{} "Agent metrics" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not a workspace member" -// @Failure 404 {object} map[string]string "Agent not found" -// @Router /api/v1/workspaces/{workspaceID}/agents/{agentID}/metrics [get] -func getAgentMetrics(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) - if !isUUID(agentID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) - return - } - if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - - days := int32(30) - if raw := strings.TrimSpace(r.URL.Query().Get("days")); raw != "" { - if v, err := strconv.Atoi(raw); err == nil { - if v < 1 { - v = 1 - } else if v > 365 { - v = 365 - } - days = int32(v) - } - } - - metrics, err := runtimeStore.GetAgentMetrics(r.Context(), agentID, days) - if err != nil { - writeReadError(w, err, "failed to load agent metrics") - return - } - writeJSON(w, http.StatusOK, metrics) - } -} - -// parseStatusList splits `?status=a,b,c` into a trimmed, non-empty -// list. Returns nil for "no filter" (empty query string or all blanks) -// so handler code can pass it straight through to the store layer. -func parseStatusList(raw string) []string { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil - } - parts := strings.Split(raw, ",") - out := make([]string, 0, len(parts)) - for _, p := range parts { - s := strings.TrimSpace(p) - if s == "" { - continue - } - out = append(out, s) - } - if len(out) == 0 { - return nil - } - return out -} - -// listWorkspaceAuditRecords serves /workspaces/{workspaceID}/audit-records. -// It reads the unified audit_records table (5-category source taxonomy, -// jsonb payload). Optional query filters: source, event_type, -// target_type, target_id, actor_id. -// listWorkspaceAuditRecords lists audit-log entries for a workspace. -// -// @Summary List workspace audit records -// @Description Returns audit-log entries for the workspace. Owner/admin only. -// @Tags workspaces -// @ID listDevWorkspaceAuditRecords -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Success 200 {object} map[string]interface{} "Audit records" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Router /api/v1/workspaces/{workspaceID}/audit-records [get] -func listWorkspaceAuditRecords(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - - q := r.URL.Query() - filter := store.ListAuditRecordsFilter{ - WorkspaceID: workspaceID, - Source: strings.TrimSpace(q.Get("source")), - EventType: strings.TrimSpace(q.Get("event_type")), - ActorID: strings.TrimSpace(q.Get("actor_id")), - TargetType: strings.TrimSpace(q.Get("target_type")), - TargetID: strings.TrimSpace(q.Get("target_id")), - } - records, err := runtimeStore.ListAuditRecords(r.Context(), filter, parseLimit(r, 100)) - if err != nil { - writeReadError(w, err, "failed to list workspace audit records") - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "workspace_id": workspaceID, - "source": filter.Source, - "event_type": filter.EventType, - "target_type": filter.TargetType, - "audit_records": records, - }) - } -} - -// listWorkspaceConnectorUsage aggregates connector types in use by -// agents in a workspace. There is no `connectors` table — the -// connector identity lives on each agent's `connector_type` field. -// listWorkspaceConnectorUsage returns connector usage rows. -// -// @Summary List workspace connector usage -// @Description Returns per-connector usage rows for the workspace. Owner/admin only. -// @Tags workspaces -// @ID listDevWorkspaceConnectorUsage -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Success 200 {object} map[string]interface{} "Connector usage rows" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Router /api/v1/workspaces/{workspaceID}/connector-usage [get] -func listWorkspaceConnectorUsage(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - - agents, err := runtimeStore.ListWorkspaceAgentsForAdmin(r.Context(), workspaceID) - if err != nil { - writeReadError(w, err, "failed to list workspace connectors") - return - } - - type connectorRow struct { - ConnectorType string `json:"connector_type"` - Label string `json:"label"` - Status string `json:"status"` - AgentCount int `json:"agent_count"` - AgentSlugs []string `json:"agent_slugs"` - } - bucket := map[string]*connectorRow{} - for _, a := range agents { - ct := strings.TrimSpace(a.ConnectorType) - if ct == "" { - ct = "unknown" - } - row, ok := bucket[ct] - if !ok { - row = &connectorRow{ - ConnectorType: ct, - Label: connectorLabel(ct), - Status: connectorStatus(ct), - AgentSlugs: []string{}, - } - bucket[ct] = row - } - row.AgentCount++ - row.AgentSlugs = append(row.AgentSlugs, a.Slug) - } - - out := make([]connectorRow, 0, len(bucket)) - for _, row := range bucket { - out = append(out, *row) - } - sort.Slice(out, func(i, j int) bool { - if out[i].AgentCount != out[j].AgentCount { - return out[i].AgentCount > out[j].AgentCount - } - return out[i].ConnectorType < out[j].ConnectorType - }) - - writeJSON(w, http.StatusOK, map[string]any{ - "workspace_id": workspaceID, - "connectors": out, - }) - } -} - -// connectorLabel maps the raw connector_type to a UI-friendly label. -func connectorLabel(t string) string { - switch t { - case "agent_daemon": - return "Agent Daemon" - case "http-agent", "http": - return "HTTP Agent" - default: - return t - } -} - -// connectorStatus is a coarse health hint. Per-run failures show up -// in Run Detail. Future connectors needing external setup can return -// "needs_config" / "offline". -func connectorStatus(t string) string { - switch t { - case "agent_daemon", "http-agent", "http": - return "ready" - default: - return "unknown" - } -} - -// listWorkspaceGateways returns a static registry of known gateway types. -// No DB schema — connectors don't have one either. -// listWorkspaceGateways lists inbound/outbound gateway rows. -// -// @Summary List workspace gateways -// @Description Returns gateway inbound/outbound rows for the workspace. Owner/admin only. -// @Tags workspaces -// @ID listDevWorkspaceGateways -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Success 200 {object} map[string]interface{} "Gateway rows" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Router /api/v1/workspaces/{workspaceID}/gateways [get] -func listWorkspaceGateways() http.HandlerFunc { - type gatewayRow struct { - Type string `json:"type"` - Label string `json:"label"` - Status string `json:"status"` - Phase string `json:"phase"` - Description string `json:"description"` - } - registry := []gatewayRow{ - { - Type: "dev", - Label: "Dev Gateway", - Status: "active", - Phase: "phase_1", - Description: "Built-in dev gateway/inbound entry point used by the devgateway tool and E2E tests.", - }, - { - Type: "feishu", - Label: "Feishu", - Status: "not_configured", - Phase: "phase_3", - Description: "Feishu group / thread gateway with real webhook signature + OAuth.", - }, - { - Type: "slack", - Label: "Slack", - Status: "not_configured", - Phase: "phase_3", - Description: "Slack channel + thread gateway.", - }, - { - Type: "web", - Label: "Web", - Status: "active", - Phase: "phase_1", - Description: "Built-in web entrypoint for conversations created from the admin UI.", - }, - } - - return func(w http.ResponseWriter, r *http.Request) { - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "workspace_id": workspaceID, - "gateways": registry, - }) - } -} - -// listWorkspaceMembers lists members of a workspace. -// -// @Summary List workspace members -// @Description Returns members of the workspace. Caller must be a workspace member. -// @Tags workspace-members -// @ID listDevWorkspaceMembers -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Success 200 {object} map[string]interface{} "Member list" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not a workspace member" -// @Router /api/v1/workspaces/{workspaceID}/members [get] -func listWorkspaceMembers(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - members, err := runtimeStore.ListWorkspaceMembers(r.Context(), workspaceID, parseLimit(r, 100)) - if err != nil { - writeReadError(w, err, "failed to list workspace members") - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "workspace_id": workspaceID, - "members": members, - }) - } -} - -// searchUsers backs the platform-wide user picker. Returns at most 20 -// users matching q substring, optionally hiding users already in a -// workspace. RBAC: any authenticated user (add-member action still -// goes through workspace owner/admin gate). -// searchUsers looks up users by keyword. -// -// @Summary Search users -// @Description Searches directory users by name, email, or handle. Used for member invite pickers. -// @Tags me -// @ID searchDevUsers -// @Produce json -// @Param q query string false "Search keyword" -// @Success 200 {object} map[string]interface{} "User list" -// @Failure 401 {object} map[string]string "Caller is not authenticated" -// @Router /api/v1/users/search [get] -func searchUsers(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - q := strings.TrimSpace(r.URL.Query().Get("q")) - if q == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "q is required"}) - return - } - excludeWS := strings.TrimSpace(r.URL.Query().Get("exclude_workspace")) - // Silently ignore garbage UUIDs — the store treats unparseable - // input as "no filter" and the picker is read-only. - if excludeWS != "" && !isUUID(excludeWS) { - excludeWS = "" - } - - items, err := runtimeStore.SearchUsers(r.Context(), store.SearchUsersInput{ - Query: q, - ExcludeWorkspaceID: excludeWS, - Limit: 20, - }) - if err != nil { - writeReadError(w, err, "failed to search users") - return - } - - // Exclude the caller from results so the picker never offers - // "add yourself". - selfID := strings.TrimSpace(auth.UserIDFromContext(r.Context())) - out := make([]map[string]any, 0, len(items)) - for _, it := range items { - if selfID != "" && it.ID == selfID { - continue - } - out = append(out, map[string]any{ - "id": it.ID, - "email": it.Email, - "name": it.Name, - "avatar_url": it.AvatarURL, - "status": it.Status, - }) - } - writeJSON(w, http.StatusOK, map[string]any{"items": out}) - } -} - -// listMyWorkspaces returns the workspaces the authenticated caller belongs to. -// Platform admins (auth.IsPlatformAdmin) get the full list of active -// workspaces with role=owner so they can drop into any tenant. -// listMyWorkspaces returns workspaces the caller belongs to. -// -// @Summary List my workspaces -// @Description Returns workspaces the caller is a member of. -// @Tags me -// @ID listDevMyWorkspaces -// @Produce json -// @Success 200 {object} map[string]interface{} "Workspace list" -// @Failure 401 {object} map[string]string "Caller is not authenticated" -// @Router /api/v1/me/workspaces [get] -func listMyWorkspaces(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - userID := strings.TrimSpace(auth.UserIDFromContext(r.Context())) - if userID == "" { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "authenticated user missing from request context"}) - return - } - if !isUUID(userID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id must be a valid uuid"}) - return - } - limit := parseLimit(r, 50) - var ( - workspaces []store.UserWorkspaceRead - err error - ) - if auth.IsPlatformAdmin(userID) { - workspaces, err = runtimeStore.ListAllActiveWorkspaces(r.Context(), limit) - } else { - workspaces, err = runtimeStore.ListUserWorkspaces(r.Context(), userID, limit) - } - if err != nil { - writeReadError(w, err, "failed to list user workspaces") - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "user_id": userID, - "workspaces": workspaces, - }) - } -} - -// devActorID returns the authenticated caller for dev writes. Require -// middleware should have populated the context before handlers run. -func devActorID(w http.ResponseWriter, r *http.Request) (string, bool) { - userID := strings.TrimSpace(auth.UserIDFromContext(r.Context())) - if userID == "" { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "authenticated user missing from request context"}) - return "", false - } - if !isUUID(userID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id must be a valid uuid"}) - return "", false - } - return userID, true -} - -type createWorkspaceRequest struct { - Name string `json:"name"` - Visibility string `json:"visibility,omitempty"` // "public" / "private"; empty → server defaults to "private" -} - -// createWorkspace creates a new workspace with the caller as owner. -// -// @Summary Create a workspace -// @Description Creates a new workspace and adds the caller as the initial owner. -// @Tags workspaces -// @ID createDevWorkspace -// @Accept json -// @Produce json -// @Param body body createWorkspaceRequest true "Workspace payload" -// @Success 201 {object} map[string]interface{} "Created workspace" -// @Failure 400 {object} map[string]string "Invalid body" -// @Failure 401 {object} map[string]string "Caller is not authenticated" -// @Router /api/v1/workspaces [post] -func createWorkspace(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - actorID, ok := devActorID(w, r) - if !ok { - return - } - var body createWorkspaceRequest - if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err != io.EOF { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) - return - } - result, err := runtimeStore.CreateWorkspace(r.Context(), store.CreateWorkspaceInput{ - Name: body.Name, - Visibility: strings.TrimSpace(body.Visibility), - CreatedBy: actorID, - Now: time.Now().UTC(), - }) - if err != nil { - writeReadError(w, err, "failed to create workspace") - return - } - writeJSON(w, http.StatusCreated, map[string]any{ - "workspace": result.Workspace, - "member": result.Member, - }) - } -} - -type updateWorkspaceRequest struct { - Name *string `json:"name,omitempty"` - Visibility *string `json:"visibility,omitempty"` // "public" / "private"; nil → unchanged -} - -// updateWorkspace applies a partial update to a workspace. -// -// @Summary Update a workspace -// @Description Partially updates a workspace's mutable fields. Owner/admin only. -// @Tags workspaces -// @ID updateDevWorkspace -// @Accept json -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param body body updateWorkspaceRequest true "Workspace update payload" -// @Success 200 {object} map[string]interface{} "Updated workspace" -// @Failure 400 {object} map[string]string "Invalid body or UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Router /api/v1/workspaces/{workspaceID} [patch] -func updateWorkspace(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - actorID, ok := devActorID(w, r) - if !ok { - return - } - var body updateWorkspaceRequest - if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err != io.EOF { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) - return - } - row, err := runtimeStore.UpdateWorkspace(r.Context(), store.UpdateWorkspaceInput{ - WorkspaceID: workspaceID, - Name: body.Name, - Visibility: body.Visibility, - ActorID: actorID, - Now: time.Now().UTC(), - }) - if err != nil { - writeReadError(w, err, "failed to update workspace") - return - } - writeJSON(w, http.StatusOK, row) - } -} - -// archiveWorkspace marks a workspace as archived. -// -// @Summary Archive a workspace -// @Description Marks the workspace as archived. Owner only. -// @Tags workspaces -// @ID archiveDevWorkspace -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Success 200 {object} map[string]interface{} "Archived workspace" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner" -// @Router /api/v1/workspaces/{workspaceID}/archive [post] -func archiveWorkspace(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - actorID, ok := devActorID(w, r) - if !ok { - return - } - row, err := runtimeStore.ArchiveWorkspace(r.Context(), store.ArchiveWorkspaceInput{ - WorkspaceID: workspaceID, - ActorID: actorID, - Now: time.Now().UTC(), - }) - if err != nil { - writeReadError(w, err, "failed to archive workspace") - return - } - writeJSON(w, http.StatusOK, row) - } -} - -type addWorkspaceMemberRequest struct { - Email string `json:"email"` - Name string `json:"name"` - Role string `json:"role"` -} - -type addWorkspaceMemberResponse struct { - Member store.WorkspaceMemberRead `json:"member"` - UserCreated bool `json:"user_created"` -} - -// addWorkspaceMember adds a user to a workspace. -// -// @Summary Add a workspace member -// @Description Adds a user to the workspace with the given role. Owner/admin only. -// @Tags workspace-members -// @ID addDevWorkspaceMember -// @Accept json -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param body body addWorkspaceMemberRequest true "Member payload" -// @Success 201 {object} map[string]interface{} "Added member" -// @Failure 400 {object} map[string]string "Invalid body or UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 409 {object} map[string]string "User is already a member" -// @Router /api/v1/workspaces/{workspaceID}/members [post] -func addWorkspaceMember(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - var req addWorkspaceMemberRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) - return - } - req.Email = strings.TrimSpace(req.Email) - req.Name = strings.TrimSpace(req.Name) - req.Role = strings.TrimSpace(req.Role) - if req.Email == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "email is required"}) - return - } - if !store.IsValidMemberRole(req.Role) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "role must be one of owner|admin|member|viewer"}) - return - } - - result, err := runtimeStore.AddWorkspaceMember(r.Context(), store.AddWorkspaceMemberInput{ - WorkspaceID: workspaceID, - Email: req.Email, - Name: req.Name, - Role: req.Role, - Now: time.Now().UTC(), - }) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to add workspace member"}) - return - } - - writeJSON(w, http.StatusCreated, addWorkspaceMemberResponse{ - Member: result.Member, - UserCreated: result.UserCreated, - }) - } -} - -// ── Invitation handlers ───────────────────────────────────────── - -type createInvitationRequest struct { - Email string `json:"email"` - Name string `json:"name,omitempty"` - Role string `json:"role"` -} - -type createInvitationResponse struct { - InvitationID string `json:"invitation_id"` - InviteLink string `json:"invite_link"` - Email string `json:"email"` - Role string `json:"role"` - ExpiresAt string `json:"expires_at"` -} - -func createInvitation(runtimeStore RuntimeStore, cfg *routerConfig) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - var req createInvitationRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) - return - } - req.Email = strings.TrimSpace(req.Email) - req.Role = strings.TrimSpace(req.Role) - if req.Email == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "email is required"}) - return - } - if !store.IsValidMemberRole(req.Role) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "role must be one of owner|admin|member|viewer"}) - return - } - - token, err := cfg.inviteSigner.Sign(workspaceID, req.Email, req.Role, authinvite.MaxLifetime) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to sign invite token"}) - return - } - - now := time.Now().UTC() - expiresAt := now.Add(authinvite.MaxLifetime) - invID := uuid.New().String() - - callerID := auth.UserIDFromContext(r.Context()) - if callerID == "" { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing caller identity"}) - return - } - - if err := runtimeStore.CreateInvitation(r.Context(), store.CreateInvitationInput{ - ID: invID, - TokenHash: authinvite.TokenHash(token), - WorkspaceID: workspaceID, - Email: req.Email, - Role: req.Role, - InvitedBy: callerID, - ExpiresAt: expiresAt, - Now: now, - }); err != nil { - if strings.Contains(err.Error(), "uk_workspace_invitations_pending_email") { - writeJSON(w, http.StatusConflict, map[string]string{"error": "an invitation is already pending for this email"}) - return - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create invitation"}) - return - } - - link := cfg.publicURL + "/invite/" + token - writeJSON(w, http.StatusCreated, createInvitationResponse{ - InvitationID: invID, - InviteLink: link, - Email: store.NormalizeEmail(req.Email), - Role: req.Role, - ExpiresAt: expiresAt.Format(time.RFC3339), - }) - } -} - -func listInvitations(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - - rows, err := runtimeStore.ListPendingInvitations(r.Context(), workspaceID) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list invitations"}) - return - } - writeJSON(w, http.StatusOK, rows) - } -} - -func revokeInvitation(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - invitationID := strings.TrimSpace(chi.URLParam(r, "invitationID")) - if !isUUID(workspaceID) || !isUUID(invitationID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id and invitation_id must be valid uuids"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - - rows, err := runtimeStore.RevokeInvitation(r.Context(), workspaceID, invitationID) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to revoke invitation"}) - return - } - if rows == 0 { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "invitation not found or already consumed"}) - return - } - w.WriteHeader(http.StatusNoContent) - } -} - -type inviteInfoRequest struct { - Token string `json:"token"` -} - -type inviteInfoResponse struct { - WorkspaceName string `json:"workspace_name"` - Email string `json:"email"` - Role string `json:"role"` -} - -func getInviteInfo(runtimeStore RuntimeStore, cfg *routerConfig) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - var req inviteInfoRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) - return - } - token := strings.TrimSpace(req.Token) - if token == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "token is required"}) - return - } - - claims, err := cfg.inviteSigner.Verify(token) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid or expired token"}) - return - } - - inv, err := runtimeStore.GetInvitationByTokenHash(r.Context(), authinvite.TokenHash(token)) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "invitation not found"}) - return - } - if inv.AcceptedAt != nil || inv.RevokedAt != nil || inv.ExpiresAt.Before(time.Now()) { - writeJSON(w, http.StatusGone, map[string]string{"error": "invitation has already been used or revoked"}) - return - } - - writeJSON(w, http.StatusOK, inviteInfoResponse{ - WorkspaceName: inv.WorkspaceName, - Email: claims.Email, - Role: claims.Role, - }) - } -} - -type acceptInvitationRequest struct { - Token string `json:"token"` - Password string `json:"password"` -} - -type acceptInvitationResponse struct { - UserID string `json:"user_id"` - Email string `json:"email"` - WorkspaceID string `json:"workspace_id"` -} - -func acceptInvitation(runtimeStore RuntimeStore, cfg *routerConfig) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - var req acceptInvitationRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) - return - } - req.Token = strings.TrimSpace(req.Token) - if req.Token == "" || req.Password == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "token and password are required"}) - return - } - - claims, err := cfg.inviteSigner.Verify(req.Token) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid or expired token"}) - return - } - - if err := authpassword.Validate(req.Password); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - - hash, err := authpassword.Hash(req.Password) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to hash password"}) - return - } - - now := time.Now().UTC() - result, err := runtimeStore.AcceptInvitation(r.Context(), store.AcceptInvitationInput{ - TokenHash: authinvite.TokenHash(req.Token), - Email: claims.Email, - Role: claims.Role, - WorkspaceID: claims.WorkspaceID, - PasswordHash: hash, - Now: now, - }) - if err != nil { - if errors.Is(err, store.ErrInvitationInvalid) { - writeJSON(w, http.StatusGone, map[string]string{"error": "invitation is invalid, expired, or already used"}) - return - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to accept invitation"}) - return - } - - sid, err := cfg.inviteSessions.Create(r.Context(), auth.CreateSessionInput{ - UserID: result.Member.UserID, - UserAgent: r.UserAgent(), - IP: r.RemoteAddr, - }) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create session"}) - return - } - auth.IssueCookie(w, sid, 0, cfg.inviteCookieSecure) - - writeJSON(w, http.StatusOK, acceptInvitationResponse{ - UserID: result.Member.UserID, - Email: result.Member.UserEmail, - WorkspaceID: result.Member.WorkspaceID, - }) - } -} - -type updateWorkspaceMemberRoleRequest struct { - Role string `json:"role"` -} - -// updateWorkspaceMemberRole changes a member's role. -// -// @Summary Update a workspace member's role -// @Description Changes a member's role within the workspace. Owner/admin only. -// @Tags workspace-members -// @ID updateDevWorkspaceMemberRole -// @Accept json -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param userID path string true "User UUID" -// @Param body body updateWorkspaceMemberRoleRequest true "Role update payload" -// @Success 200 {object} map[string]interface{} "Updated member" -// @Failure 400 {object} map[string]string "Invalid body or UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Router /api/v1/workspaces/{workspaceID}/members/{userID} [patch] -func updateWorkspaceMemberRole(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - userID := strings.TrimSpace(chi.URLParam(r, "userID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if !isUUID(userID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - var req updateWorkspaceMemberRoleRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) - return - } - req.Role = strings.TrimSpace(req.Role) - if !store.IsValidMemberRole(req.Role) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "role must be one of owner|admin|member|viewer"}) - return - } - // Prevent changing the owner's role — ownership is transferred, not edited. - targetRole, err := runtimeStore.GetWorkspaceMemberRole(r.Context(), workspaceID, userID) - if err != nil { - if errors.Is(err, store.ErrNotMember) { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "workspace member not found"}) - return - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to look up member"}) - return - } - if targetRole == "owner" { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "cannot change the owner's role; use ownership transfer instead"}) - return - } - if req.Role == "owner" { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "cannot promote to owner; use ownership transfer instead"}) - return - } - member, err := runtimeStore.UpdateWorkspaceMemberRole(r.Context(), workspaceID, userID, req.Role, time.Now().UTC()) - if err != nil { - if errors.Is(err, store.ErrUnknownWorkspaceMember) { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "workspace member not found"}) - return - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to update workspace member role"}) - return - } - writeJSON(w, http.StatusOK, member) - } -} - -func removeWorkspaceMember(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - userID := strings.TrimSpace(chi.URLParam(r, "userID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if !isUUID(userID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - // Prevent removing the workspace owner. - targetRole, err := runtimeStore.GetWorkspaceMemberRole(r.Context(), workspaceID, userID) - if err != nil { - if errors.Is(err, store.ErrNotMember) { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "workspace member not found"}) - return - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to look up member"}) - return - } - if targetRole == "owner" { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "cannot remove the workspace owner"}) - return - } - result, err := runtimeStore.RemoveWorkspaceMember(r.Context(), workspaceID, userID, time.Now().UTC()) - if err != nil { - if errors.Is(err, store.ErrUnknownWorkspaceMember) { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "workspace member not found"}) - return - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to remove workspace member"}) - return - } - writeJSON(w, http.StatusOK, result) - } -} - -// ============================================================ -// Workspace self-service join request handlers -// -// POST /api/v1/workspaces/{wid}/join-requests submit request -// GET /api/v1/workspaces/{wid}/join-requests owner/admin list -// POST /api/v1/workspaces/{wid}/join-requests/{rid}/approve owner/admin approve -// POST /api/v1/workspaces/{wid}/join-requests/{rid}/reject owner/admin reject -// -// The WHERE status='pending' guard is atomic at the SQL layer; two admins racing -// will get ErrJoinRequestAlreadyHandled, which converts to a 409. -// ============================================================ - -type createJoinRequestRequest struct { - Reason string `json:"reason,omitempty"` -} - -func createJoinRequest(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - userID, ok := devActorID(w, r) - if !ok { - return - } - var body createJoinRequestRequest - if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err != io.EOF { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) - return - } - // Optional reason; optional field, length soft limit — prevents abuse without enforcing schema - if len(body.Reason) > 1000 { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "reason must be 1000 characters or less"}) - return - } - result, err := runtimeStore.RequestJoinWorkspace(r.Context(), store.RequestJoinWorkspaceInput{ - WorkspaceID: workspaceID, - UserID: userID, - Reason: body.Reason, - Now: time.Now().UTC(), - }) - if err != nil { - if errors.Is(err, store.ErrUnknownWorkspace) { - // Covers two cases: does not exist / private and not open — always 404 to prevent enumeration - writeJSON(w, http.StatusNotFound, map[string]string{"error": "workspace not found or not open to join requests"}) - return - } - writeReadError(w, err, "failed to submit join request") - return - } - if result.Already { - writeJSON(w, http.StatusConflict, map[string]any{ - "error": "already a member or pending request exists", - "request": result.Request, - }) - return - } - writeJSON(w, http.StatusCreated, map[string]any{ - "request": result.Request, - }) - } -} - -// withdrawOwnJoinRequest — requester self-withdraws a pending request. Path -// carries no requestID: the requester has only one pending row, and -// (workspace_id, current_user_id) uniquely locates it, so the client doesn't -// need to hold a request id either. -func withdrawOwnJoinRequest(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - userID, ok := devActorID(w, r) - if !ok { - return - } - if err := runtimeStore.WithdrawOwnJoinRequest(r.Context(), workspaceID, userID, time.Now().UTC()); err != nil { - if errors.Is(err, store.ErrJoinRequestAlreadyHandled) { - // No pending row found: may have been approved/rejected by owner, or the user never applied - writeJSON(w, http.StatusConflict, map[string]string{"error": "no pending request to withdraw"}) - return - } - writeReadError(w, err, "failed to withdraw join request") - return - } - w.WriteHeader(http.StatusNoContent) - } -} - -func listJoinRequests(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - rows, err := runtimeStore.ListPendingJoinRequests(r.Context(), workspaceID) - if err != nil { - writeReadError(w, err, "failed to list join requests") - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "workspace_id": workspaceID, - "requests": rows, - }) - } -} - -func approveJoinRequest(runtimeStore RuntimeStore) http.HandlerFunc { - return reviewJoinRequestHandler(runtimeStore, true) -} - -func rejectJoinRequest(runtimeStore RuntimeStore) http.HandlerFunc { - return reviewJoinRequestHandler(runtimeStore, false) -} - -func reviewJoinRequestHandler(runtimeStore RuntimeStore, approve bool) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - requestID := strings.TrimSpace(chi.URLParam(r, "requestID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if !isUUID(requestID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "request_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - reviewerID, ok := devActorID(w, r) - if !ok { - return - } - input := store.ReviewJoinRequestInput{ - WorkspaceID: workspaceID, - RequestID: requestID, - ReviewerID: reviewerID, - Now: time.Now().UTC(), - } - var ( - member store.WorkspaceMemberRead - err error - ) - if approve { - member, err = runtimeStore.ApproveJoinRequest(r.Context(), input) - } else { - member, err = runtimeStore.RejectJoinRequest(r.Context(), input) - } - if err != nil { - if errors.Is(err, store.ErrJoinRequestAlreadyHandled) { - // Already handled by another admin / row is not in pending state - writeJSON(w, http.StatusConflict, map[string]string{"error": "join request already handled"}) - return - } - writeReadError(w, err, "failed to review join request") - return - } - writeJSON(w, http.StatusOK, member) - } -} - -// listDiscoverableWorkspaces — `GET /api/v1/me/discoverable-workspaces` -// Public workspaces the current user can request to join. -// -// Query params: -// - q : fuzzy search on workspace.name (case-insensitive); empty returns all -// - limit : default 50, clamped to [1, 100] -// - offset: default 0 -// -// Response carries a `total` field (post-filter count); the frontend uses it for -// "View all (N)" and pagination. -func listDiscoverableWorkspaces(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - userID := strings.TrimSpace(auth.UserIDFromContext(r.Context())) - if userID == "" { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "authenticated user missing from request context"}) - return - } - if !isUUID(userID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id must be a valid uuid"}) - return - } - q := strings.TrimSpace(r.URL.Query().Get("q")) - // Limit search-term length to prevent malicious overlong strings from slowing ILIKE index scans - if len(q) > 100 { - q = q[:100] - } - offset := parseOffset(r) - result, err := runtimeStore.ListDiscoverableWorkspaces(r.Context(), store.ListDiscoverableWorkspacesInput{ - UserID: userID, - Search: q, - Limit: parseLimit(r, 50), - Offset: offset, - }) - if err != nil { - writeReadError(w, err, "failed to list discoverable workspaces") - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "user_id": userID, - "workspaces": result.Workspaces, - "total": result.Total, - "limit": parseLimit(r, 50), - "offset": offset, - }) - } -} - -func listWorkspaceUsageLogs(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - agentRunID := strings.TrimSpace(r.URL.Query().Get("agent_run_id")) - if agentRunID != "" && !isUUID(agentRunID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_run_id must be a valid uuid"}) - return - } - - usage, err := runtimeStore.ListWorkspaceUsageLogs(r.Context(), workspaceID, agentRunID, parseLimit(r, 100)) - if err != nil { - writeReadError(w, err, "failed to list workspace usage") - return - } - writeJSON(w, http.StatusOK, map[string]any{"workspace_id": workspaceID, "agent_run_id": agentRunID, "usage_logs": usage}) - } -} - -func parseLimit(r *http.Request, fallback int32) int32 { - raw := strings.TrimSpace(r.URL.Query().Get("limit")) - if raw == "" { - return fallback - } - limit, err := strconv.Atoi(raw) - if err != nil || limit <= 0 { - return fallback - } - if limit > 500 { - return 500 - } - return int32(limit) -} - -// parseOffset reads ?offset=N for paginated endpoints. Defaults to 0; -// clamps negative inputs to 0 (pagination underflow is meaningless). -func parseOffset(r *http.Request) int32 { - raw := strings.TrimSpace(r.URL.Query().Get("offset")) - if raw == "" { - return 0 - } - offset, err := strconv.Atoi(raw) - if err != nil || offset < 0 { - return 0 - } - return int32(offset) -} - -func isUUID(value string) bool { - if len(value) != 36 { - return false - } - for i, c := range value { - switch i { - case 8, 13, 18, 23: - if c != '-' { - return false - } - default: - if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { - return false - } - } - } - return true -} - -func writeReadError(w http.ResponseWriter, err error, fallback string) { - switch { - case errors.Is(err, store.ErrUnknownWorkspace), errors.Is(err, store.ErrUnknownConversationForRead), errors.Is(err, store.ErrUnknownAgentRun), errors.Is(err, store.ErrUnknownConversation): - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - case errors.Is(err, store.ErrDuplicateWorkspaceSlug): - writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) - case errors.Is(err, store.ErrMarketplaceDependents): - writeJSON(w, http.StatusConflict, map[string]string{"error": "has_marketplace_dependents", "message": err.Error()}) - case errors.Is(err, store.ErrInvalidWorkspaceInput), errors.Is(err, store.ErrInvalidInput): - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - default: - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fallback}) - } -} - -func writeStoreAgentError(w http.ResponseWriter, err error) { - switch { - case errors.Is(err, store.ErrDuplicateAgentSlug): - suggested := "" - parts := strings.Split(err.Error(), ": ") - if len(parts) > 1 { - suggested = parts[len(parts)-1] - } - writeJSON(w, http.StatusConflict, map[string]string{"error": "slug_conflict", "suggested": suggested}) - case errors.Is(err, store.ErrUnknownCapability): - invalid := []string{} - parts := strings.Split(err.Error(), ": ") - if len(parts) > 1 && strings.TrimSpace(parts[len(parts)-1]) != "" { - invalid = strings.Split(parts[len(parts)-1], ",") - } - writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "unknown_capability", "invalid": invalid}) - case errors.Is(err, store.ErrUnknownCapabilityVersion): - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - case errors.Is(err, store.ErrMarketplaceCapabilityUnavailable): - writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) - case errors.Is(err, store.ErrUnknownAgent), errors.Is(err, store.ErrUnknownAgent), errors.Is(err, store.ErrUnknownWorkspace), errors.Is(err, store.ErrUnknownWorkspace): - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - case errors.Is(err, store.ErrInvalidConnectorType), errors.Is(err, store.ErrInvalidInput), errors.Is(err, store.ErrInvalidAgentVisibility): - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - default: - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "agent operation failed"}) - } -} - -func decodeJSONWithField(r *http.Request, target any, field string) (bool, error) { - fields, err := decodeJSONWithFields(r, target) - if err != nil { - return false, err - } - return fields[field], nil -} - -func decodeJSONWithFields(r *http.Request, target any) (map[string]bool, error) { - body, err := io.ReadAll(r.Body) - if err != nil { - return nil, err - } - var raw map[string]json.RawMessage - if err := json.Unmarshal(body, &raw); err != nil { - return nil, err - } - if err := json.Unmarshal(body, target); err != nil { - return nil, err - } - fields := make(map[string]bool, len(raw)) - for field := range raw { - fields[field] = true - } - return fields, nil -} - -func actorIDFromRequest(r *http.Request) string { - if userID := auth.UserIDFromContext(r.Context()); userID != "" { - return userID - } - return store.DefaultDevFixtureIDs().UserID -} - -func requestContextForRBAC(r *http.Request) context.Context { - if auth.UserIDFromContext(r.Context()) != "" { - return r.Context() - } - return auth.WithUserID(r.Context(), store.DefaultDevFixtureIDs().UserID) -} - -func requireWorkspaceOwnerOrAdmin(r *http.Request, runtimeStore RuntimeStore, workspaceID string) error { - return auth.RequireWorkspaceRole(requestContextForRBAC(r), runtimeStore, workspaceID, "owner", "admin") -} - -// requireWorkspaceMember gates read endpoints scoped to a workspace. -// Returns ErrNotMember when the caller is not an active member of the -// workspace. -func requireWorkspaceMember(r *http.Request, runtimeStore RuntimeStore, workspaceID string) error { - return auth.RequireWorkspaceRole(requestContextForRBAC(r), runtimeStore, workspaceID, "owner", "admin", "member", "viewer") -} - -// requireWorkspaceMemberNotViewer gates write endpoints that any -// non-viewer member of the workspace can perform — creating a -// conversation, triggering a run, editing one's own conversation -// title, etc. viewer is read-only. -func requireWorkspaceMemberNotViewer(r *http.Request, runtimeStore RuntimeStore, workspaceID string) error { - return auth.RequireWorkspaceRole(requestContextForRBAC(r), runtimeStore, workspaceID, "owner", "admin", "member") -} - -// gateWorkspaceMember wraps a handler whose URL is -// /workspaces/{workspaceID}/... and rejects callers that aren't an -// active member. Used by sandbox admin endpoints whose handlers don't -// carry runtimeStore — wrapping at register-time avoids polluting -// sandboxAdminDeps. -// -// Returns 503 when runtimeStore is nil (local-mode server without -// DB) so the response surface matches the other DB-backed endpoints -// instead of silently bypassing RBAC. -func gateWorkspaceMember(runtimeStore RuntimeStore, next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - next(w, r) - } -} - -// gateWorkspaceOwnerOrAdmin gates sandbox kill / rebuild on owner+admin -// only. Mid-run kill interrupts an Agent task in flight. -func gateWorkspaceOwnerOrAdmin(runtimeStore RuntimeStore, next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - next(w, r) - } -} - -func workspaceIDForAgent(w http.ResponseWriter, ctx context.Context, runtimeStore RuntimeStore, agentID string) (string, bool) { - agent, err := runtimeStore.GetAgentDetail(ctx, agentID) - if err != nil { - if errors.Is(err, store.ErrUnknownAgent) { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return "", false - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load agent"}) - return "", false - } - return agent.WorkspaceID, true -} - -type createConversationUserMessageBody struct { - Content string `json:"content"` - MentionedAgentIDs []string `json:"mentioned_agent_ids"` -} - -func createConversationUserMessage(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed conversation messages are disabled"}) - return - } - conversationID := strings.TrimSpace(chi.URLParam(r, "conversationID")) - if !isUUID(conversationID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "conversation_id must be a valid uuid"}) - return - } - var req createConversationUserMessageBody - if r.Body != nil { - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - } - content := strings.TrimSpace(req.Content) - if content == "" || len(content) > 32000 { - writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "content must be 1-32000 characters"}) - return - } - conversation, err := runtimeStore.GetConversation(r.Context(), conversationID) - if err != nil { - writeReadError(w, err, "failed to get conversation") - return - } - if err := requireWorkspaceMemberNotViewer(r, runtimeStore, conversation.WorkspaceID); err != nil { - if errors.Is(err, auth.ErrNotMember) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"}) - return - } - writeRBACError(w, err) - return - } - result, err := runtimeStore.SendUserMessageToConversation(r.Context(), store.SendUserMessageToConversationInput{ - ConversationID: conversationID, - UserID: actorIDFromRequest(r), - Content: content, - MentionedAgentIDs: req.MentionedAgentIDs, - }) - if err != nil { - switch { - case errors.Is(err, store.ErrUnknownConversation): - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - case errors.Is(err, store.ErrUnknownMention), errors.Is(err, store.ErrInvalidInput): - writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) - default: - log.Bg().Error("send conversation message failed", - "error", err, - "conversation_id", conversationID, - "user_id", actorIDFromRequest(r)) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to send conversation message"}) - } - return - } - agentRunID := any(nil) - if len(result.RunIDs) > 0 { - agentRunID = result.RunIDs[0] - } - writeJSON(w, http.StatusCreated, map[string]any{ - "message": result.Message, - "agent_run_id": agentRunID, - "dispatched_agent_count": len(result.RunIDs), - }) - } -} - -type createWorkspaceConversationBody struct { - Title string `json:"title"` - Surface string `json:"surface"` - Form string `json:"form"` - AgentID string `json:"agent_id"` - Metadata map[string]any `json:"metadata"` -} - -func listWorkspaceConversations(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - agentFilter := strings.TrimSpace(r.URL.Query().Get("agent_id")) - conversations, err := runtimeStore.ListWorkspaceConversations(r.Context(), workspaceID, agentFilter, parseLimit(r, 100)) - if err != nil { - if errors.Is(err, store.ErrInvalidInput) { - writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) - return - } - writeReadError(w, err, "failed to list workspace conversations") - return - } - writeJSON(w, http.StatusOK, map[string]any{"conversations": conversations}) - } -} - -func createWorkspaceConversation(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed conversation creation is disabled"}) - return - } - workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) - if !isUUID(workspaceID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) - return - } - if err := requireWorkspaceMemberNotViewer(r, runtimeStore, workspaceID); err != nil { - writeRBACError(w, err) - return - } - var req createWorkspaceConversationBody - if r.Body != nil { - if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - } - conversation, err := runtimeStore.CreateWorkspaceConversation(r.Context(), store.CreateWorkspaceConversationInput{ - WorkspaceID: workspaceID, - Title: req.Title, - Surface: req.Surface, - Form: req.Form, - PrimaryAgentID: req.AgentID, - Metadata: req.Metadata, - }) - if err != nil { - switch { - case errors.Is(err, store.ErrUnknownWorkspace): - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - case errors.Is(err, store.ErrUnknownMention), errors.Is(err, store.ErrInvalidInput): - writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) - default: - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - } - return - } - writeJSON(w, http.StatusCreated, conversation) - } -} - -func getConversation(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) - return - } - conversationID := strings.TrimSpace(chi.URLParam(r, "conversationID")) - if !isUUID(conversationID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "conversation_id must be a valid uuid"}) - return - } - conversation, err := runtimeStore.GetConversation(r.Context(), conversationID) - if err != nil { - writeReadError(w, err, "failed to get conversation") - return - } - // URL is keyed by conversation_id, so load before knowing - // which workspace to authorise against. - if err := requireWorkspaceMember(r, runtimeStore, conversation.WorkspaceID); err != nil { - writeRBACError(w, err) - return - } - writeJSON(w, http.StatusOK, conversation) - } -} - -// updateConversationTitleBody — PATCH /api/v1/conversations/{cid}; only title is editable. -type updateConversationTitleBody struct { - Title string `json:"title"` -} - -func updateConversationTitle(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed write APIs are disabled"}) - return - } - conversationID := strings.TrimSpace(chi.URLParam(r, "conversationID")) - if !isUUID(conversationID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "conversation_id must be a valid uuid"}) - return - } - // Load row first so RBAC can gate on the resolved workspace. - conv, err := runtimeStore.GetConversation(r.Context(), conversationID) - if err != nil { - writeReadError(w, err, "failed to get conversation") - return - } - if err := requireWorkspaceMemberNotViewer(r, runtimeStore, conv.WorkspaceID); err != nil { - writeRBACError(w, err) - return - } - var req updateConversationTitleBody - if r.Body != nil { - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - } - if err := runtimeStore.UpdateConversationTitle(r.Context(), conversationID, req.Title); err != nil { - switch { - case errors.Is(err, store.ErrUnknownConversation): - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - case errors.Is(err, store.ErrInvalidInput): - writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) - default: - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to update conversation"}) - } - return - } - // Re-read so response shape matches GET. - updated, err := runtimeStore.GetConversation(r.Context(), conversationID) - if err != nil { - writeReadError(w, err, "failed to read updated conversation") - return - } - writeJSON(w, http.StatusOK, updated) - } -} - -func deleteConversation(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if runtimeStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed write APIs are disabled"}) - return - } - conversationID := strings.TrimSpace(chi.URLParam(r, "conversationID")) - if !isUUID(conversationID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "conversation_id must be a valid uuid"}) - return - } - conv, err := runtimeStore.GetConversation(r.Context(), conversationID) - if err != nil { - writeReadError(w, err, "failed to get conversation") - return - } - if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, conv.WorkspaceID); err != nil { - writeRBACError(w, err) - return - } - if err := runtimeStore.SoftDeleteConversation(r.Context(), conversationID); err != nil { - if errors.Is(err, store.ErrUnknownConversation) { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to delete conversation"}) - return - } - w.WriteHeader(http.StatusNoContent) - } -} - -func writeJSON(w http.ResponseWriter, status int, value any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(value) -} - -// E2B sandbox smoke handler. One-off API key from request body or -// `E2B_API_KEY` env; sandbox is created, command runs, and (unless -// keep_alive=true) the sandbox is killed. Errors bubble up via -// `sanitizeE2BSmokeError` which redacts the API key. - -// defaultDevOpenCodeSandboxTemplate is the E2B template id the dev -// smoke endpoints default to. `parsar-opencode-base` (infra/e2b- -// templates/opencode/) preinstalls the opencode CLI, Node 22, and the -// four ai-sdk provider adapters, avoiding a ~30s npm install round. -// Callers can override via `template`; non-dev callers set TemplateID -// on BuildSandboxRunnerOptions directly. -const defaultDevOpenCodeSandboxTemplate = "parsar-opencode-base" - -type e2bSmokeRequest struct { - APIKey string `json:"api_key"` - APIBaseURL string `json:"api_base_url"` - SandboxHost string `json:"sandbox_host"` - SandboxBaseURL string `json:"sandbox_base_url"` - Template string `json:"template"` - Command string `json:"command"` - TimeoutSeconds int `json:"timeout_seconds"` - CommandTimeoutSeconds int `json:"command_timeout_seconds"` - KeepAlive bool `json:"keep_alive"` - Env map[string]string `json:"env"` -} - -type e2bSmokeResponse struct { - SandboxID string `json:"sandbox_id"` - TemplateID string `json:"template_id"` - Killed bool `json:"killed"` - Command e2bsandbox.CommandResult `json:"command"` -} - -func smokeE2BSandbox(w http.ResponseWriter, r *http.Request) { - var req e2bSmokeRequest - if r.Body != nil { - if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - } - apiKey := strings.TrimSpace(req.APIKey) - if apiKey == "" { - apiKey = strings.TrimSpace(os.Getenv("E2B_API_KEY")) - } - if apiKey == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "api_key or E2B_API_KEY is required"}) - return - } - template := strings.TrimSpace(req.Template) - if template == "" { - template = defaultDevOpenCodeSandboxTemplate - } - command := strings.TrimSpace(req.Command) - if command == "" { - command = `printf "hello from parsar e2b smoke\n"` - } - timeoutSeconds := req.TimeoutSeconds - if timeoutSeconds <= 0 { - timeoutSeconds = 120 - } - commandTimeout := time.Duration(req.CommandTimeoutSeconds) * time.Second - if commandTimeout <= 0 { - commandTimeout = 60 * time.Second - } - secure := true - client := &e2bsandbox.Client{ - HTTPClient: http.DefaultClient, - APIBaseURL: req.APIBaseURL, - SandboxHost: req.SandboxHost, - SandboxBaseURL: req.SandboxBaseURL, - APIKey: apiKey, - } - sandbox, err := client.Create(r.Context(), e2bsandbox.CreateInput{ - TemplateID: template, - TimeoutSeconds: timeoutSeconds, - Secure: &secure, - Env: req.Env, - Metadata: map[string]string{ - "source": "parsar_dev_smoke", - }, - }) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": sanitizeE2BSmokeError(err, apiKey)}) - return - } - killed := false - cleanupNeeded := !req.KeepAlive - defer func() { - if !cleanupNeeded { - return - } - killCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - if err := client.Kill(killCtx, sandbox.SandboxID); err == nil { - killed = true - } - }() - result, err := client.RunCommand(r.Context(), e2bsandbox.RunCommandInput{ - Sandbox: sandbox, - Command: command, - Timeout: commandTimeout, - }) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]any{ - "error": sanitizeE2BSmokeError(err, apiKey), - "sandbox_id": sandbox.SandboxID, - "template_id": sandbox.TemplateID, - "keep_alive": req.KeepAlive, - }) - return - } - if !req.KeepAlive { - killCtx, cancel := context.WithTimeout(r.Context(), 15*time.Second) - killErr := client.Kill(killCtx, sandbox.SandboxID) - cancel() - killed = killErr == nil - cleanupNeeded = false - } - writeJSON(w, http.StatusOK, e2bSmokeResponse{ - SandboxID: sandbox.SandboxID, - TemplateID: sandbox.TemplateID, - Killed: killed, - Command: result, - }) -} - -func sanitizeE2BSmokeError(err error, apiKey string) string { - if err == nil { - return "" - } - return e2bsandbox.RedactSecret(err.Error(), apiKey) -} diff --git a/server/internal/dev/routes_agents.go b/server/internal/dev/routes_agents.go new file mode 100644 index 0000000..fde97e6 --- /dev/null +++ b/server/internal/dev/routes_agents.go @@ -0,0 +1,922 @@ +package dev + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/connector" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" + "github.com/go-chi/chi/v5" +) + +type configureAgentConnectorBody struct { + ConnectorType string `json:"connector_type"` + Endpoint string `json:"endpoint"` + SecretID string `json:"secret_id"` + Model string `json:"model"` + ModelID string `json:"model_id"` + Workdir string `json:"workdir"` + SystemPrompt string `json:"system_prompt"` +} + +type configureAgentProfileBody struct { + ModelID string `json:"model_id"` + Workdir string `json:"workdir"` + SystemPrompt string `json:"system_prompt"` + Config map[string]any `json:"config"` +} + +// configureAgentConnector wires an agent to a channel connector. +// +// @Summary Configure an agent's connector +// @Description Attaches or updates an agent's outbound channel connector configuration. Owner/admin only. +// @Tags agents +// @ID configureDevAgentConnector +// @Accept json +// @Produce json +// @Param agentID path string true "Agent UUID" +// @Param body body configureAgentConnectorBody true "Connector config payload" +// @Success 200 {object} map[string]interface{} "Updated agent" +// @Failure 400 {object} map[string]string "Invalid request" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 404 {object} map[string]string "Unknown agent" +// @Router /api/v1/agents/{agentID}/connector [post] +func configureAgentConnector(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed connector config is disabled"}) + return + } + agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) + if !isUUID(agentID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) + return + } + workspaceID, ok := workspaceIDForAgent(w, r.Context(), runtimeStore, agentID) + if !ok { + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + + var req configureAgentConnectorBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + if strings.TrimSpace(req.ConnectorType) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "connector_type is required"}) + return + } + if req.ConnectorType == "http" && !isSafeHTTPAgentEndpoint(req.Endpoint) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "http connector endpoint must be an http(s) URL"}) + return + } + + result, err := runtimeStore.ConfigureDevAgentConnector(r.Context(), store.ConfigureDevAgentConnectorInput{ + AgentID: agentID, + ConnectorType: req.ConnectorType, + Endpoint: req.Endpoint, + SecretID: req.SecretID, + Model: req.Model, + ModelID: req.ModelID, + Workdir: req.Workdir, + SystemPrompt: req.SystemPrompt, + }) + if err != nil { + switch { + case errors.Is(err, store.ErrInvalidConnectorType): + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + case errors.Is(err, store.ErrUnknownAgent): + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + default: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to configure agent connector"}) + } + return + } + writeJSON(w, http.StatusOK, result) + } +} + +// configureAgentProfile updates the agent's public profile. +// +// @Summary Configure an agent's profile +// @Description Updates the agent's public profile fields (display name, avatar, description). Owner/admin only. +// @Tags agents +// @ID configureDevAgentProfile +// @Accept json +// @Produce json +// @Param agentID path string true "Agent UUID" +// @Param body body configureAgentProfileBody true "Profile payload" +// @Success 200 {object} map[string]interface{} "Updated agent" +// @Failure 400 {object} map[string]string "Invalid body or UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 404 {object} map[string]string "Agent not found" +// @Router /api/v1/agents/{agentID}/profile [post] +func configureAgentProfile(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed agent profile config is disabled"}) + return + } + agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) + if !isUUID(agentID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) + return + } + workspaceID, ok := workspaceIDForAgent(w, r.Context(), runtimeStore, agentID) + if !ok { + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + var req configureAgentProfileBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + result, err := runtimeStore.ConfigureAgentProfile(r.Context(), store.ConfigureAgentProfileInput{AgentID: agentID, ModelID: req.ModelID, Workdir: req.Workdir, SystemPrompt: req.SystemPrompt, Config: req.Config}) + if err != nil { + switch { + case errors.Is(err, store.ErrUnknownAgent): + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + case errors.Is(err, store.ErrUnknownModel): + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + case errors.Is(err, store.ErrModelDisabled): + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + default: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to configure agent profile"}) + } + return + } + + // Local-device binding: mirror createAgent so editing a + // local-mode agent's bound device keeps the agent's runtime_id in + // sync. Without this the admin list keeps reading "Runtime not bound". + if result.AgentConfig != nil { + if mode, _ := result.AgentConfig["daemon_mode"].(string); mode == "local" { + if deviceID, _ := result.AgentConfig["device_id"].(string); strings.TrimSpace(deviceID) != "" { + detail, detailErr := runtimeStore.GetAgentDetail(r.Context(), agentID) + if detailErr != nil { + log.Bg().Warn("configureAgentProfile: workspace lookup failed for runtime_id sync", + "agent_id", agentID, "err", detailErr) + } else if _, bindErr := runtimeStore.SetAgentRuntime(r.Context(), store.SetAgentRuntimeInput{ + WorkspaceID: detail.WorkspaceID, + AgentID: agentID, + RuntimeID: deviceID, + }); bindErr != nil { + log.Bg().Warn("configureAgentProfile: persist local device runtime_id failed", + "agent_id", agentID, + "device_id", deviceID, + "err", bindErr) + } + } + } + } + + writeJSON(w, http.StatusOK, result) + } +} + +// disableAgent disables an active agent. +// +// @Summary Disable an agent +// @Description Marks the agent as disabled so it is no longer scheduled. Owner/admin only. +// @Tags agents +// @ID disableDevAgent +// @Produce json +// @Param agentID path string true "Agent UUID" +// @Success 200 {object} map[string]interface{} "Updated agent" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 404 {object} map[string]string "Agent not found" +// @Router /api/v1/agents/{agentID}/disable [post] +func disableAgent(runtimeStore RuntimeStore) http.HandlerFunc { + return agentStatusHandler(runtimeStore, "disable") +} + +// enableAgent enables a disabled agent. +// +// @Summary Enable an agent +// @Description Marks a disabled agent as enabled so it can be scheduled again. Owner/admin only. +// @Tags agents +// @ID enableDevAgent +// @Produce json +// @Param agentID path string true "Agent UUID" +// @Success 200 {object} map[string]interface{} "Updated agent" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 404 {object} map[string]string "Agent not found" +// @Router /api/v1/agents/{agentID}/enable [post] +func enableAgent(runtimeStore RuntimeStore) http.HandlerFunc { + return agentStatusHandler(runtimeStore, "enable") +} + +type createAgentCapabilityBody struct { + CapabilityVersionID string `json:"capability_version_id"` + Configuration map[string]any `json:"configuration"` + // PinningMode is "latest" or "pinned". Empty falls back to store's + // default (pinned); create-agent dialog sends "latest" for new + // bindings unless the user picks a specific version. + PinningMode string `json:"pinning_mode,omitempty"` +} + +// createAgentInlineSecretBody describes one new shared secret the user +// asked to materialise during agent creation. The handler creates the +// secret via store.CreateSecret, then patches its id into +// req.Config.credential_bindings[Kind] (or model_credential_binding when +// IsModel=true) before delegating to runtimeStore.CreateAgent. +type createAgentInlineSecretBody struct { + Kind string `json:"kind"` + IsModel bool `json:"is_model"` + DisplayName string `json:"display_name"` + Plaintext string `json:"plaintext"` +} + +type createAgentBody struct { + Name string `json:"name"` + Description string `json:"description"` + ConnectorType string `json:"connector_type"` + SystemPrompt string `json:"system_prompt"` + DefaultModelID string `json:"default_model_id"` + Capabilities []string `json:"capabilities"` + InitialCapabilities []createAgentCapabilityBody `json:"initial_capabilities"` + Visibility string `json:"visibility"` + Runtime string `json:"runtime"` + Config map[string]any `json:"config"` + InlineNewSecrets []createAgentInlineSecretBody `json:"inline_new_secrets"` + Slug string `json:"slug"` +} + +type updateAgentBody struct { + Name *string `json:"name"` + Description *string `json:"description"` + ConnectorType *string `json:"connector_type"` + SystemPrompt *string `json:"system_prompt"` + DefaultModelID *string `json:"default_model_id"` + Capabilities []string `json:"capabilities"` + Config map[string]any `json:"config"` + InlineNewSecrets []createAgentInlineSecretBody `json:"inline_new_secrets"` + Slug *string `json:"slug"` + WorkspaceID *string `json:"workspace_id"` +} + +// createAgent creates a new agent in a workspace. Owner/admin only. +// +// @Summary Create an agent in a workspace +// @Description Creates an agent under the given workspace. Owner/admin only. inline_new_secrets are materialised into the shared secret vault before the agent is persisted; any binding entries in config are patched with the resolved ids. +// @Tags agents +// @ID createDevAgent +// @Accept json +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param body body createAgentBody true "Agent create payload" +// @Success 201 {object} map[string]interface{} "Created agent" +// @Failure 400 {object} map[string]string "workspace_id must be a valid uuid, or body invalid" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 422 {object} map[string]string "Immutable field or unknown capability" +// @Router /api/v1/workspaces/{workspaceID}/agents [post] +func createAgent(runtimeStore RuntimeStore, agentDaemonSandbox AgentDaemonSandboxAcquirer) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if runtimeStore == nil || !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + var req createAgentBody + hasCaps, err := decodeJSONWithField(r, &req, "capabilities") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.ConnectorType) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name and connector_type are required"}) + return + } + if strings.TrimSpace(req.Runtime) != "" { + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "runtime is no longer accepted; use config.daemon_mode, config.device_id, and config.agent_kind for agent_daemon agents"}) + return + } + + // Materialise any inline_new_secrets the user pasted in step 3. + // Each one becomes a capability_inline secret in the org-global + // catalog; its id is then patched into the corresponding + // credential_bindings entry (or model_credential_binding when + // IsModel=true) inside req.Config so CreateAgent persists a + // fully-resolved binding map. Failure here is fatal — the agent + // is not created, the secrets that did succeed are left as + // orphans (we explicitly chose not to clean them up). + if cfg, ok := materialiseInlineSecrets(r.Context(), runtimeStore, req.Config, req.InlineNewSecrets, actorIDFromRequest(r)); ok { + req.Config = cfg + } else if len(req.InlineNewSecrets) > 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to materialise inline_new_secrets"}) + return + } + + // Enforce visibility ⇄ binding consistency. Public agents may + // not depend on any personal credential (no platform user_id + // for lark guests); tenant agents are allowed but warned in UI. + // 422 (not 400) so the FE can distinguish "semantically wrong" + // from "malformed body" — same convention as updateAgent. + if err := validateAgentVisibilityBindings(req.Visibility, req.Config); err != nil { + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) + return + } + initialCapabilities := make([]store.InitialAgentCapabilityInput, 0, len(req.InitialCapabilities)) + for _, capability := range req.InitialCapabilities { + initialCapabilities = append(initialCapabilities, store.InitialAgentCapabilityInput{CapabilityVersionID: capability.CapabilityVersionID, Configuration: capability.Configuration, PinningMode: capability.PinningMode}) + } + result, err := runtimeStore.CreateAgent(r.Context(), store.CreateAgentInput{WorkspaceID: workspaceID, Name: req.Name, Description: req.Description, ConnectorType: req.ConnectorType, SystemPrompt: req.SystemPrompt, DefaultModelID: req.DefaultModelID, Capabilities: req.Capabilities, CapabilitiesSet: hasCaps, InitialCapabilities: initialCapabilities, Runtime: "", AgentConfig: req.Config, Visibility: req.Visibility, Slug: req.Slug, CreatedBy: actorIDFromRequest(r)}) + if err != nil { + writeStoreAgentError(w, err) + return + } + writeJSON(w, http.StatusCreated, result) + + // Sync capability checkboxes → agent_capabilities table so the + // runtime's GetEnabledCapabilitiesForAgent sees them. + if hasCaps && len(req.Capabilities) > 0 { + if err := syncAgentCapabilities(r.Context(), runtimeStore, result.Agent.WorkspaceID, result.Agent.ID, req.Capabilities); err != nil { + log.Bg().Warn("createAgent: capability sync failed", "agent_id", result.Agent.ID, "err", err) + } + } + + // Local-device binding: when the user picked a paired daemon + // in the create form, the device_id sits in agents.config but + // agents.runtime_id stays NULL. Mirror device_id → runtime_id so + // the FK join lights up. device_id IS a runtime.id. + if result.Agent.Config != nil { + if mode, _ := result.Agent.Config["daemon_mode"].(string); mode == "local" { + if deviceID, _ := result.Agent.Config["device_id"].(string); strings.TrimSpace(deviceID) != "" { + if _, bindErr := runtimeStore.SetAgentRuntime(r.Context(), store.SetAgentRuntimeInput{ + WorkspaceID: workspaceID, + AgentID: result.Agent.ID, + RuntimeID: deviceID, + }); bindErr != nil { + // Non-fatal: row is created, the user can + // re-save from the edit dialog to retry. + log.Bg().Warn("createAgent: persist local device runtime_id failed", + "agent_id", result.Agent.ID, + "device_id", deviceID, + "err", bindErr) + } + } + } + } + + // Eager sandbox provisioning: kick off Acquire so the sandbox + // is ready before the user sends their first message. On + // success, persist deviceID to agents.runtime_id — + // without this write the connector's "user must bind a + // runtime first" guard would reject the very first prompt. + // Failure is non-fatal: the row is saved and SandboxPanel + // (or a follow-up Rebuild) gives the admin a recovery surface. + if agentDaemonSandbox != nil && result.Agent.Config != nil { + if mode, _ := result.Agent.Config["daemon_mode"].(string); mode == "sandbox" { + paID := result.Agent.ID + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + deviceID, err := agentDaemonSandbox.Acquire(ctx, connector.PromptInput{ + AgentID: paID, + WorkspaceID: workspaceID, + }) + if err != nil { + log.Bg().Warn("eager sandbox acquire failed", + "agent_id", paID, "err", err) + return + } + if _, bindErr := runtimeStore.SetAgentRuntime(ctx, store.SetAgentRuntimeInput{ + WorkspaceID: workspaceID, + AgentID: paID, + RuntimeID: deviceID, + }); bindErr != nil { + // Sandbox is alive but runtime_id write failed. + // Dispatch shows "Runtime not bound" until a retry + // succeeds or admin Rebuild rewrites. + log.Bg().Error("eager sandbox acquired but runtime_id persist failed", + "agent_id", paID, + "device_id", deviceID, + "err", bindErr) + return + } + log.Bg().Info("eager sandbox acquired and runtime bound", + "agent_id", paID, "device_id", deviceID) + }() + } + } + } +} + +// updateAgent applies a partial update to an existing agent. +// +// @Summary Update mutable agent fields +// @Description Applies a partial update. All fields are optional; nil pointers mean "leave as-is". Slug and runtime are immutable and rejected with 422. +// @Tags agents +// @ID updateDevAgent +// @Accept json +// @Produce json +// @Param agentID path string true "Agent UUID" +// @Param body body updateAgentBody true "Partial agent update" +// @Success 200 {object} map[string]interface{} "Updated agent" +// @Failure 400 {object} map[string]string "Malformed request body or invalid UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 422 {object} map[string]string "Immutable field (slug/runtime), or unknown capability" +// @Router /api/v1/agents/{agentID} [patch] +func updateAgent(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) + if runtimeStore == nil || !isUUID(agentID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) + return + } + agent, err := runtimeStore.GetAgent(r.Context(), agentID) + if err != nil { + writeStoreAgentError(w, err) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, agent.WorkspaceID); err != nil { + writeRBACError(w, err) + return + } + var req updateAgentBody + fields, err := decodeJSONWithFields(r, &req) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + hasCaps := fields["capabilities"] + if fields["runtime"] { + // runtime is immutable post-create — recreate the agent to + // change runtime (it determines whether the agent runs in + // cloud sandbox or on local subprocess and is tied to + // every previous conversation's execution environment). + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "runtime is immutable post-create; recreate the agent to change runtime"}) + return + } + if req.Slug != nil { + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "slug is immutable"}) + return + } + if req.WorkspaceID != nil { + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "workspace_id is immutable"}) + return + } + // Materialise inline secrets + validate visibility ⇄ binding consistency + // when the FE sends new credential bindings. Without this, edits that + // switch the shared secret (or introduce a new one) never reach + // agents.agent_config and the runtime keeps resolving the old binding. + // + // Same failure trade-off as createAgent: materialiseInlineSecrets is + // "commit-each-as-you-go", and a downstream failure (mid-list secret + // create, or visibility validation below) leaves the earlier secrets + // dangling in the workspace. Edit makes this slightly worse because + // users typically retry after fixing the offending field, accruing one + // orphan per retry. We accept it for symmetry with create; a future + // pass could wrap the chain in a tx + rollback. + configChanged := fields["config"] || len(req.InlineNewSecrets) > 0 + if configChanged { + if cfg, ok := materialiseInlineSecrets(r.Context(), runtimeStore, req.Config, req.InlineNewSecrets, actorIDFromRequest(r)); ok { + req.Config = cfg + } else if len(req.InlineNewSecrets) > 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to materialise inline_new_secrets"}) + return + } + if err := validateAgentVisibilityBindings(agent.Visibility, req.Config); err != nil { + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) + return + } + } + updated, _, err := runtimeStore.UpdateAgent(r.Context(), store.UpdateAgentInput{AgentID: agentID, ActorID: actorIDFromRequest(r), Name: req.Name, Description: req.Description, ConnectorType: req.ConnectorType, SystemPrompt: req.SystemPrompt, DefaultModelID: req.DefaultModelID, Capabilities: req.Capabilities, CapabilitiesSet: hasCaps, Config: req.Config, ConfigSet: configChanged}) + if err != nil { + writeStoreAgentError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"agent": updated}) + + // Sync capability checkboxes → agent_capabilities table. + if hasCaps { + if err := syncAgentCapabilities(r.Context(), runtimeStore, updated.WorkspaceID, agentID, req.Capabilities); err != nil { + log.Bg().Warn("updateAgent: capability sync failed", "agent_id", agentID, "err", err) + } + } + } +} + +// syncAgentCapabilities reconciles the agent_capabilities table with +// the capability name list from the agent edit form. Errors on +// individual capabilities are logged and skipped. +func syncAgentCapabilities( + ctx context.Context, + rs RuntimeStore, + workspaceID string, + agentID string, + capabilityNames []string, +) error { + // 1. Current state on this agent. + existing, err := rs.ListAgentCapabilities(ctx, agentID) + if err != nil { + return fmt.Errorf("syncAgentCapabilities: list existing: %w", err) + } + existingByCapID := make(map[string]store.AgentCapabilityRead, len(existing)) + for _, ac := range existing { + existingByCapID[ac.CapabilityID] = ac + } + + // 2. Resolve desired names. A name can come from this workspace's own + // capabilities, OR from the marketplace (a public capability published + // by another workspace and surfaced in the agent picker's marketplace + // section). Local capabilities win on name collision: a user shadowing + // a marketplace name with a private one should keep using their own. + allCaps, err := rs.ListCapabilities(ctx, workspaceID, store.ListCapabilityFilter{}) + if err != nil { + return fmt.Errorf("syncAgentCapabilities: list capabilities: %w", err) + } + marketplaceCaps, err := rs.ListMarketplaceCapabilities(ctx, workspaceID) + if err != nil { + return fmt.Errorf("syncAgentCapabilities: list marketplace capabilities: %w", err) + } + type resolved struct { + capabilityID string + latestVersionID string + fromMarketplace bool + } + capByName := make(map[string]resolved, len(allCaps)+len(marketplaceCaps)) + for _, c := range allCaps { + capByName[c.Name] = resolved{capabilityID: c.ID} + } + for _, m := range marketplaceCaps { + if m.SelfPublished { + continue + } + if _, exists := capByName[m.Name]; exists { + continue + } + capByName[m.Name] = resolved{capabilityID: m.CapabilityID, latestVersionID: m.LatestVersionID, fromMarketplace: true} + } + + desiredCapIDs := make(map[string]bool, len(capabilityNames)) + for _, name := range capabilityNames { + name = strings.TrimSpace(name) + if name == "" { + continue + } + cap, ok := capByName[name] + if !ok { + log.Bg().Warn("syncAgentCapabilities: capability not found, skipping", + "name", name, "workspace_id", workspaceID, "agent_id", agentID) + continue + } + desiredCapIDs[cap.capabilityID] = true + + // Already enabled — don't auto-upgrade version. + if _, exists := existingByCapID[cap.capabilityID]; exists { + continue + } + + latestVersionID := cap.latestVersionID + if latestVersionID == "" { + // Local capability: marketplace row already carries the version, + // but ListCapabilities does not, so we fetch lazily here. + versions, err := rs.ListCapabilityVersions(ctx, cap.capabilityID) + if err != nil { + log.Bg().Warn("syncAgentCapabilities: list versions failed, skipping", + "capability_id", cap.capabilityID, "name", name, "err", err) + continue + } + if len(versions) == 0 { + log.Bg().Warn("syncAgentCapabilities: no versions found, skipping", + "capability_id", cap.capabilityID, "name", name) + continue + } + latestVersionID = versions[0].ID // sorted created_at desc + } + + // Default pinning_mode depends on the source: + // * Local capability: user's expectation is "check it and follow the latest". After reupload, + // no need to re-edit the agent, and skill iteration in the local workshop has no + // breaking-change risk (owned by the same team). + // * Marketplace: the publisher's new version may carry breaking changes, + // keep pinned so the UpgradeCapabilityDialog explicit-confirm path remains + // valid; the user must explicitly pick latest from the picker to auto-follow. + mode := store.PinningModeLatest + if cap.fromMarketplace { + mode = store.PinningModePinned + } + if _, err := rs.EnableAgentCapability(ctx, agentID, latestVersionID, nil, mode); err != nil { + log.Bg().Warn("syncAgentCapabilities: enable failed, skipping", + "capability_id", cap.capabilityID, "name", name, "version_id", latestVersionID, "err", err) + continue + } + log.Bg().Info("syncAgentCapabilities: enabled capability", + "agent_id", agentID, "capability_id", cap.capabilityID, "name", name, "version_id", latestVersionID, "from_marketplace", cap.fromMarketplace, "pinning_mode", mode) + } + + // 3. Remove capabilities no longer in the desired list. + for capID, ac := range existingByCapID { + if desiredCapIDs[capID] { + continue + } + if err := rs.DeleteAgentCapability(ctx, agentID, ac.CapabilityVersionID); err != nil { + log.Bg().Warn("syncAgentCapabilities: delete failed, skipping", + "capability_id", capID, "capability_version_id", ac.CapabilityVersionID, "err", err) + continue + } + log.Bg().Info("syncAgentCapabilities: removed capability", + "agent_id", agentID, "capability_id", capID, "capability_version_id", ac.CapabilityVersionID) + } + return nil +} + +// updateAgentVisibilityBody is the request body for +// PATCH /api/v1/agents/{agentID}/visibility. +type updateAgentVisibilityBody struct { + Visibility string `json:"visibility"` +} + +// updateAgentVisibility flips an Agent's visibility between +// workspace / tenant / public. Owner/admin only. Identical visibility +// is treated as a 200 noop so idempotent replays don't pollute audit. +// updateAgentVisibility updates an agent's visibility setting. +// +// @Summary Update an agent's visibility +// @Description Updates an agent's visibility (public / tenant / private). Owner/admin only. Rejected if bindings are inconsistent with the requested visibility. +// @Tags agents +// @ID updateDevAgentVisibility +// @Accept json +// @Produce json +// @Param agentID path string true "Agent UUID" +// @Param body body updateAgentVisibilityBody true "Visibility payload" +// @Success 200 {object} map[string]interface{} "Updated agent" +// @Failure 400 {object} map[string]string "Invalid body or UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 422 {object} map[string]string "Visibility conflicts with agent bindings" +// @Router /api/v1/agents/{agentID}/visibility [patch] +func updateAgentVisibility(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) + if runtimeStore == nil || !isUUID(agentID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) + return + } + agent, err := runtimeStore.GetAgent(r.Context(), agentID) + if err != nil { + writeStoreAgentError(w, err) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, agent.WorkspaceID); err != nil { + writeRBACError(w, err) + return + } + var req updateAgentVisibilityBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + change, err := runtimeStore.UpdateAgentVisibility(r.Context(), agentID, req.Visibility, actorIDFromRequest(r)) + if err != nil { + if errors.Is(err, store.ErrInvalidAgentVisibility) { + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) + return + } + writeStoreAgentError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"visibility": change}) + } +} + +// deleteAgent soft-deletes an agent. Owner/admin only. +// +// @Summary Soft-delete an agent +// @Description Marks the agent as deleted; existing conversations are retained. Owner/admin only. +// @Tags agents +// @ID deleteDevAgent +// @Produce json +// @Param agentID path string true "Agent UUID" +// @Success 200 {object} map[string]interface{} "Deleted agent" +// @Failure 400 {object} map[string]string "agent_id must be a valid uuid" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 404 {object} map[string]string "Unknown agent" +// @Router /api/v1/agents/{agentID} [delete] +func deleteAgent(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) + if runtimeStore == nil || !isUUID(agentID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) + return + } + agent, err := runtimeStore.GetAgent(r.Context(), agentID) + if err != nil { + writeStoreAgentError(w, err) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, agent.WorkspaceID); err != nil { + writeRBACError(w, err) + return + } + result, runCount, err := runtimeStore.DeleteAgent(r.Context(), agentID, actorIDFromRequest(r)) + if errors.Is(err, store.ErrInFlightAgentRuns) { + writeJSON(w, http.StatusConflict, map[string]any{"error": "in_flight_runs", "run_count": runCount}) + return + } + if err != nil { + writeStoreAgentError(w, err) + return + } + writeJSON(w, http.StatusOK, result) + } +} + +func agentStatusHandler(runtimeStore RuntimeStore, action string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed agent lifecycle is disabled"}) + return + } + agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) + if !isUUID(agentID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) + return + } + workspaceID, ok := workspaceIDForAgent(w, r.Context(), runtimeStore, agentID) + if !ok { + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + var ( + result store.AgentStatusRead + err error + ) + switch action { + case "disable": + result, err = runtimeStore.DisableAgent(r.Context(), agentID) + case "enable": + result, err = runtimeStore.EnableAgent(r.Context(), agentID) + default: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "unsupported agent action"}) + return + } + if err != nil { + if errors.Is(err, store.ErrUnknownAgent) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("failed to %s agent", action)}) + return + } + writeJSON(w, http.StatusOK, result) + } +} + +func isSafeHTTPAgentEndpoint(endpoint string) bool { + parsed, err := url.Parse(endpoint) + if err != nil || parsed.Host == "" { + return false + } + return parsed.Scheme == "http" || parsed.Scheme == "https" +} + +// listWorkspaceEnabledAgents lists the agents visible to the caller in +// a workspace. By default only enabled/active agents are returned; +// `?include_disabled=true` widens the query to the admin view. +// +// @Summary List active agents in a workspace +// @Description Returns agents the caller can address in the given workspace. By default only enabled/active agents; pass include_disabled=true for the admin view. +// @Tags agents +// @ID listDevAgents +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param include_disabled query bool false "Include disabled/archived agents in the response" +// @Success 200 {object} map[string]interface{} "Active workspace agents with profile basics" +// @Failure 400 {object} map[string]string "workspace_id must be a valid uuid" +// @Failure 403 {object} map[string]string "Caller is not an active workspace member" +// @Failure 503 {object} map[string]string "Database-backed read APIs are disabled" +// @Router /api/v1/workspaces/{workspaceID}/agents [get] +func listWorkspaceEnabledAgents(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + + includeDisabled := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include_disabled")), "true") + var ( + agents []store.AgentRead + err error + ) + if includeDisabled { + agents, err = runtimeStore.ListWorkspaceAgentsForAdmin(r.Context(), workspaceID) + } else { + agents, err = runtimeStore.ListWorkspaceEnabledAgents(r.Context(), workspaceID) + } + if err != nil { + writeReadError(w, err, "failed to list agents") + return + } + writeJSON(w, http.StatusOK, map[string]any{"workspace_id": workspaceID, "agents": agents}) + } +} + +// getAgentMetrics returns aggregated run-history counters for +// a single agent over a sliding window. Powers the agent-detail +// "Last N days performance" panel: completion count, success rate, average duration. +// `?days=` is optional and clamps to [1, 365]; default 30. +// getAgentMetrics returns runtime/usage metrics for an agent. +// +// @Summary Get agent metrics +// @Description Returns runtime/usage metrics for the agent within the workspace. Caller must be a workspace member. +// @Tags agents +// @ID getDevAgentMetrics +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param agentID path string true "Agent UUID" +// @Success 200 {object} map[string]interface{} "Agent metrics" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller is not a workspace member" +// @Failure 404 {object} map[string]string "Agent not found" +// @Router /api/v1/workspaces/{workspaceID}/agents/{agentID}/metrics [get] +func getAgentMetrics(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) + if !isUUID(agentID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) + return + } + if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + + days := int32(30) + if raw := strings.TrimSpace(r.URL.Query().Get("days")); raw != "" { + if v, err := strconv.Atoi(raw); err == nil { + if v < 1 { + v = 1 + } else if v > 365 { + v = 365 + } + days = int32(v) + } + } + + metrics, err := runtimeStore.GetAgentMetrics(r.Context(), agentID, days) + if err != nil { + writeReadError(w, err, "failed to load agent metrics") + return + } + writeJSON(w, http.StatusOK, metrics) + } +} + +func workspaceIDForAgent(w http.ResponseWriter, ctx context.Context, runtimeStore RuntimeStore, agentID string) (string, bool) { + agent, err := runtimeStore.GetAgentDetail(ctx, agentID) + if err != nil { + if errors.Is(err, store.ErrUnknownAgent) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return "", false + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load agent"}) + return "", false + } + return agent.WorkspaceID, true +} diff --git a/server/internal/dev/routes_conversations.go b/server/internal/dev/routes_conversations.go new file mode 100644 index 0000000..7506542 --- /dev/null +++ b/server/internal/dev/routes_conversations.go @@ -0,0 +1,399 @@ +package dev + +import ( + "encoding/json" + "errors" + "io" + + "net/http" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/auth" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" + "github.com/go-chi/chi/v5" +) + +type configureConversationExternalRefBody struct { + Gateway string `json:"gateway"` + ExternalChatID string `json:"external_chat_id"` + ExternalThreadID string `json:"external_thread_id"` +} + +// configureConversationExternalRef binds a conversation to an external reference. +// +// @Summary Configure a conversation's external reference +// @Description Binds a conversation to an external system reference (e.g. Feishu thread key) for dedupe / re-attach. +// @Tags conversations +// @ID configureDevConversationExternalRef +// @Accept json +// @Produce json +// @Param conversationID path string true "Conversation UUID" +// @Param body body configureConversationExternalRefBody true "External reference payload" +// @Success 200 {object} map[string]interface{} "Updated conversation" +// @Failure 400 {object} map[string]string "Invalid body or UUID" +// @Failure 404 {object} map[string]string "Conversation not found" +// @Router /api/v1/conversations/{conversationID}/external-ref [post] +func configureConversationExternalRef(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed conversation mapping is disabled"}) + return + } + conversationID := strings.TrimSpace(chi.URLParam(r, "conversationID")) + if !isUUID(conversationID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "conversation_id must be a valid uuid"}) + return + } + conversation, err := runtimeStore.GetConversation(r.Context(), conversationID) + if err != nil { + writeReadError(w, err, "failed to load conversation") + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, conversation.WorkspaceID); err != nil { + writeRBACError(w, err) + return + } + var req configureConversationExternalRefBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + if strings.TrimSpace(req.Gateway) == "" { + req.Gateway = "dev" + } + if strings.TrimSpace(req.ExternalChatID) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "external_chat_id is required"}) + return + } + result, err := runtimeStore.ConfigureDevConversationExternalRef(r.Context(), store.ConfigureDevConversationExternalRefInput{ + ConversationID: conversationID, + Gateway: req.Gateway, + ExternalChatID: req.ExternalChatID, + ExternalThreadID: req.ExternalThreadID, + }) + if err != nil { + if errors.Is(err, store.ErrUnknownConversation) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to configure conversation external ref"}) + return + } + writeJSON(w, http.StatusOK, result) + } +} + +// getConversationTimeline returns the ordered timeline for a conversation. +// +// @Summary Get a conversation's timeline +// @Description Returns the ordered timeline (messages, tool calls, events) for a conversation. +// @Tags conversations +// @ID getDevConversationTimeline +// @Produce json +// @Param conversationID path string true "Conversation UUID" +// @Success 200 {object} map[string]interface{} "Timeline entries" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller lacks permission" +// @Failure 404 {object} map[string]string "Conversation not found" +// @Router /api/v1/conversations/{conversationID}/timeline [get] +func getConversationTimeline(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + conversationID := strings.TrimSpace(chi.URLParam(r, "conversationID")) + if !isUUID(conversationID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "conversation_id must be a valid uuid"}) + return + } + // Timeline doesn't carry workspace_id, so reverse-lookup the + // parent conversation to find the workspace to authorise against. + conv, err := runtimeStore.GetConversation(r.Context(), conversationID) + if err != nil { + writeReadError(w, err, "failed to load conversation for rbac check") + return + } + if err := requireWorkspaceMember(r, runtimeStore, conv.WorkspaceID); err != nil { + writeRBACError(w, err) + return + } + + timeline, err := runtimeStore.GetConversationTimeline(r.Context(), conversationID, parseLimit(r, 100)) + if err != nil { + writeReadError(w, err, "failed to get conversation timeline") + return + } + writeJSON(w, http.StatusOK, timeline) + } +} + +type createConversationUserMessageBody struct { + Content string `json:"content"` + MentionedAgentIDs []string `json:"mentioned_agent_ids"` +} + +func createConversationUserMessage(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed conversation messages are disabled"}) + return + } + conversationID := strings.TrimSpace(chi.URLParam(r, "conversationID")) + if !isUUID(conversationID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "conversation_id must be a valid uuid"}) + return + } + var req createConversationUserMessageBody + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + } + content := strings.TrimSpace(req.Content) + if content == "" || len(content) > 32000 { + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "content must be 1-32000 characters"}) + return + } + conversation, err := runtimeStore.GetConversation(r.Context(), conversationID) + if err != nil { + writeReadError(w, err, "failed to get conversation") + return + } + if err := requireWorkspaceMemberNotViewer(r, runtimeStore, conversation.WorkspaceID); err != nil { + if errors.Is(err, auth.ErrNotMember) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"}) + return + } + writeRBACError(w, err) + return + } + result, err := runtimeStore.SendUserMessageToConversation(r.Context(), store.SendUserMessageToConversationInput{ + ConversationID: conversationID, + UserID: actorIDFromRequest(r), + Content: content, + MentionedAgentIDs: req.MentionedAgentIDs, + }) + if err != nil { + switch { + case errors.Is(err, store.ErrUnknownConversation): + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + case errors.Is(err, store.ErrUnknownMention), errors.Is(err, store.ErrInvalidInput): + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) + default: + log.Bg().Error("send conversation message failed", + "error", err, + "conversation_id", conversationID, + "user_id", actorIDFromRequest(r)) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to send conversation message"}) + } + return + } + agentRunID := any(nil) + if len(result.RunIDs) > 0 { + agentRunID = result.RunIDs[0] + } + writeJSON(w, http.StatusCreated, map[string]any{ + "message": result.Message, + "agent_run_id": agentRunID, + "dispatched_agent_count": len(result.RunIDs), + }) + } +} + +type createWorkspaceConversationBody struct { + Title string `json:"title"` + Surface string `json:"surface"` + Form string `json:"form"` + AgentID string `json:"agent_id"` + Metadata map[string]any `json:"metadata"` +} + +func listWorkspaceConversations(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + agentFilter := strings.TrimSpace(r.URL.Query().Get("agent_id")) + conversations, err := runtimeStore.ListWorkspaceConversations(r.Context(), workspaceID, agentFilter, parseLimit(r, 100)) + if err != nil { + if errors.Is(err, store.ErrInvalidInput) { + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) + return + } + writeReadError(w, err, "failed to list workspace conversations") + return + } + writeJSON(w, http.StatusOK, map[string]any{"conversations": conversations}) + } +} + +func createWorkspaceConversation(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed conversation creation is disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceMemberNotViewer(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + var req createWorkspaceConversationBody + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + } + conversation, err := runtimeStore.CreateWorkspaceConversation(r.Context(), store.CreateWorkspaceConversationInput{ + WorkspaceID: workspaceID, + Title: req.Title, + Surface: req.Surface, + Form: req.Form, + PrimaryAgentID: req.AgentID, + Metadata: req.Metadata, + }) + if err != nil { + switch { + case errors.Is(err, store.ErrUnknownWorkspace): + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + case errors.Is(err, store.ErrUnknownMention), errors.Is(err, store.ErrInvalidInput): + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) + default: + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + return + } + writeJSON(w, http.StatusCreated, conversation) + } +} + +func getConversation(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + conversationID := strings.TrimSpace(chi.URLParam(r, "conversationID")) + if !isUUID(conversationID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "conversation_id must be a valid uuid"}) + return + } + conversation, err := runtimeStore.GetConversation(r.Context(), conversationID) + if err != nil { + writeReadError(w, err, "failed to get conversation") + return + } + // URL is keyed by conversation_id, so load before knowing + // which workspace to authorise against. + if err := requireWorkspaceMember(r, runtimeStore, conversation.WorkspaceID); err != nil { + writeRBACError(w, err) + return + } + writeJSON(w, http.StatusOK, conversation) + } +} + +// updateConversationTitleBody — PATCH /api/v1/conversations/{cid}; only title is editable. +type updateConversationTitleBody struct { + Title string `json:"title"` +} + +func updateConversationTitle(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed write APIs are disabled"}) + return + } + conversationID := strings.TrimSpace(chi.URLParam(r, "conversationID")) + if !isUUID(conversationID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "conversation_id must be a valid uuid"}) + return + } + // Load row first so RBAC can gate on the resolved workspace. + conv, err := runtimeStore.GetConversation(r.Context(), conversationID) + if err != nil { + writeReadError(w, err, "failed to get conversation") + return + } + if err := requireWorkspaceMemberNotViewer(r, runtimeStore, conv.WorkspaceID); err != nil { + writeRBACError(w, err) + return + } + var req updateConversationTitleBody + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + } + if err := runtimeStore.UpdateConversationTitle(r.Context(), conversationID, req.Title); err != nil { + switch { + case errors.Is(err, store.ErrUnknownConversation): + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + case errors.Is(err, store.ErrInvalidInput): + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) + default: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to update conversation"}) + } + return + } + // Re-read so response shape matches GET. + updated, err := runtimeStore.GetConversation(r.Context(), conversationID) + if err != nil { + writeReadError(w, err, "failed to read updated conversation") + return + } + writeJSON(w, http.StatusOK, updated) + } +} + +func deleteConversation(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed write APIs are disabled"}) + return + } + conversationID := strings.TrimSpace(chi.URLParam(r, "conversationID")) + if !isUUID(conversationID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "conversation_id must be a valid uuid"}) + return + } + conv, err := runtimeStore.GetConversation(r.Context(), conversationID) + if err != nil { + writeReadError(w, err, "failed to get conversation") + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, conv.WorkspaceID); err != nil { + writeRBACError(w, err) + return + } + if err := runtimeStore.SoftDeleteConversation(r.Context(), conversationID); err != nil { + if errors.Is(err, store.ErrUnknownConversation) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to delete conversation"}) + return + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/server/internal/dev/routes_dev.go b/server/internal/dev/routes_dev.go new file mode 100644 index 0000000..63a75c5 --- /dev/null +++ b/server/internal/dev/routes_dev.go @@ -0,0 +1,324 @@ +package dev + +import ( + "context" + "encoding/json" + "errors" + "io" + + "net/http" + "os" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/httprunner" + e2bsandbox "github.com/MiniMax-AI-Dev/parsar/server/internal/sandbox/e2b" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" + "github.com/go-chi/chi/v5" +) + +func getSeed(w http.ResponseWriter, r *http.Request) { + // SeedData is the human-readable fixture (back-compat with + // existing dev consumers). The `db` key carries the real DB UUIDs + // `cmd/seeddev` writes so the admin frontend can auto-bind. + seed := DefaultSeed() + ids := store.DefaultDevFixtureIDs() + writeJSON(w, http.StatusOK, map[string]any{ + "workspace": seed.Workspace, + "users": seed.Users, + "agents": seed.Agents, + "conversations": seed.Conversations, + // Deterministic DB UUIDs from store.DefaultDevFixtureIDs — + // match exactly what `make seed-dev-db` inserts. + "db": map[string]any{ + "workspace_id": ids.WorkspaceID, + "user_id": ids.UserID, + "conversation_id": ids.ConversationID, + "agents": map[string]string{ + "product_agent_id": ids.ProductAgentID, + "backend_agent_id": ids.BackendAgentID, + "test_agent_id": ids.TestAgentID, + }, + }, + }) +} + +type verifyRequest struct { + Email string `json:"email"` + Code string `json:"code"` +} + +// verifyDevAuth is POST /dev/auth/verify. Dev-only fake login: accepts a +// {email, code} body where code must equal DevVerificationCode, then hands +// back a dev bearer token + user shape mirroring the real auth flow so +// smoke tests can bypass Feishu OIDC entirely. +// +// @Summary Dev-only email + code login +// @Description Development-only login. Verifies the fixed dev code and returns a bearer token plus the default seed workspace + user. Never enable in production. +// @Tags dev +// @ID verifyDevAuth +// @Accept json +// @Produce json +// @Param body body map[string]string true "{email, code}" +// @Success 200 {object} map[string]interface{} "Bearer token + user + workspace" +// @Failure 400 {object} map[string]string "Invalid json" +// @Failure 401 {object} map[string]string "Invalid dev credentials" +// @Router /dev/auth/verify [post] +func verifyDevAuth(w http.ResponseWriter, r *http.Request) { + var req verifyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + if strings.TrimSpace(req.Email) == "" || req.Code != DevVerificationCode { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid dev credentials"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "token": "dev-token", + "token_type": "Bearer", + "workspace_id": DefaultSeed().Workspace.ID, + "user": map[string]string{ + "id": "dev_admin", + "email": req.Email, + "name": "Dev Admin", + }, + }) +} + +type httpAgentInvokeBody struct { + Endpoint string `json:"endpoint"` + Headers map[string]string `json:"headers"` +} + +// invokeHTTPAgentRun invokes a pending HTTP-agent run row. +// +// @Summary Invoke an HTTP agent run +// @Description Executes the HTTP-agent invocation associated with the given run row. Development helper. +// @Tags dev +// @ID invokeDevHTTPAgentRun +// @Accept json +// @Produce json +// @Param runID path string true "Run UUID" +// @Param body body httpAgentInvokeBody true "Invocation payload" +// @Success 200 {object} map[string]interface{} "Run result" +// @Failure 400 {object} map[string]string "Invalid body or UUID" +// @Failure 404 {object} map[string]string "Run not found" +// @Router /dev/http-agent/runs/{runID}/invoke [post] +func invokeHTTPAgentRun(runtimeStore RuntimeStore, client *http.Client, deps *httprunner.Deps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed http agent connector is disabled"}) + return + } + + runID := strings.TrimSpace(chi.URLParam(r, "runID")) + if !isUUID(runID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "run_id must be a valid uuid"}) + return + } + + var req httpAgentInvokeBody + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + } + + runHTTPAgentInvocation(w, r, runtimeStore, client, runID, req, deps) + } +} + +// runHTTPAgentOnce runs an HTTP-agent workload once, synchronously. +// +// @Summary Run an HTTP agent once +// @Description Executes a single HTTP-agent invocation synchronously and returns the result. Development helper. +// @Tags dev +// @ID runDevHTTPAgentOnce +// @Accept json +// @Produce json +// @Param body body map[string]interface{} true "Run payload" +// @Success 200 {object} map[string]interface{} "Run result" +// @Failure 400 {object} map[string]string "Invalid body" +// @Router /dev/http-agent/runner/run-once [post] +func runHTTPAgentOnce(runtimeStore RuntimeStore, client *http.Client, deps *httprunner.Deps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed http runner is disabled"}) + return + } + + result, err := httprunner.RunOnce(r.Context(), runtimeStore, client, deps) + if err != nil { + switch { + case errors.Is(err, store.ErrUnknownAgentRun): + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + case errors.Is(err, store.ErrInvalidHTTPConnector), errors.Is(err, store.ErrAgentRunNotCompletable), errors.Is(err, store.ErrInvalidAgent): + writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) + case errors.Is(err, httprunner.ErrInvalidEndpoint): + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + case errors.Is(err, httprunner.ErrRequestFailed), errors.Is(err, httprunner.ErrNon2xx), errors.Is(err, httprunner.ErrInvalidJSON): + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + default: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to run http agent once"}) + } + return + } + writeJSON(w, http.StatusOK, result) + } +} + +func runHTTPAgentInvocation(w http.ResponseWriter, r *http.Request, runtimeStore RuntimeStore, client *http.Client, runID string, req httpAgentInvokeBody, deps *httprunner.Deps) { + result, err := httprunner.Invoke(r.Context(), runtimeStore, client, httprunner.InvokeInput{RunID: runID, Endpoint: req.Endpoint, Headers: req.Headers}, deps) + if err != nil { + switch { + case errors.Is(err, store.ErrUnknownAgentRun): + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + case errors.Is(err, store.ErrInvalidHTTPConnector), errors.Is(err, store.ErrAgentRunNotCompletable), errors.Is(err, store.ErrInvalidAgent): + writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) + case errors.Is(err, httprunner.ErrInvalidEndpoint): + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + case errors.Is(err, httprunner.ErrRequestFailed), errors.Is(err, httprunner.ErrNon2xx), errors.Is(err, httprunner.ErrInvalidJSON): + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + default: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to run http agent"}) + } + return + } + writeJSON(w, http.StatusOK, result) +} + +// E2B sandbox smoke handler. One-off API key from request body or +// `E2B_API_KEY` env; sandbox is created, command runs, and (unless +// keep_alive=true) the sandbox is killed. Errors bubble up via +// `sanitizeE2BSmokeError` which redacts the API key. + +// defaultDevOpenCodeSandboxTemplate is the E2B template id the dev +// smoke endpoints default to. `parsar-opencode-base` (infra/e2b- +// templates/opencode/) preinstalls the opencode CLI, Node 22, and the +// four ai-sdk provider adapters, avoiding a ~30s npm install round. +// Callers can override via `template`; non-dev callers set TemplateID +// on BuildSandboxRunnerOptions directly. +const defaultDevOpenCodeSandboxTemplate = "parsar-opencode-base" + +type e2bSmokeRequest struct { + APIKey string `json:"api_key"` + APIBaseURL string `json:"api_base_url"` + SandboxHost string `json:"sandbox_host"` + SandboxBaseURL string `json:"sandbox_base_url"` + Template string `json:"template"` + Command string `json:"command"` + TimeoutSeconds int `json:"timeout_seconds"` + CommandTimeoutSeconds int `json:"command_timeout_seconds"` + KeepAlive bool `json:"keep_alive"` + Env map[string]string `json:"env"` +} + +type e2bSmokeResponse struct { + SandboxID string `json:"sandbox_id"` + TemplateID string `json:"template_id"` + Killed bool `json:"killed"` + Command e2bsandbox.CommandResult `json:"command"` +} + +func smokeE2BSandbox(w http.ResponseWriter, r *http.Request) { + var req e2bSmokeRequest + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + } + apiKey := strings.TrimSpace(req.APIKey) + if apiKey == "" { + apiKey = strings.TrimSpace(os.Getenv("E2B_API_KEY")) + } + if apiKey == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "api_key or E2B_API_KEY is required"}) + return + } + template := strings.TrimSpace(req.Template) + if template == "" { + template = defaultDevOpenCodeSandboxTemplate + } + command := strings.TrimSpace(req.Command) + if command == "" { + command = `printf "hello from parsar e2b smoke\n"` + } + timeoutSeconds := req.TimeoutSeconds + if timeoutSeconds <= 0 { + timeoutSeconds = 120 + } + commandTimeout := time.Duration(req.CommandTimeoutSeconds) * time.Second + if commandTimeout <= 0 { + commandTimeout = 60 * time.Second + } + secure := true + client := &e2bsandbox.Client{ + HTTPClient: http.DefaultClient, + APIBaseURL: req.APIBaseURL, + SandboxHost: req.SandboxHost, + SandboxBaseURL: req.SandboxBaseURL, + APIKey: apiKey, + } + sandbox, err := client.Create(r.Context(), e2bsandbox.CreateInput{ + TemplateID: template, + TimeoutSeconds: timeoutSeconds, + Secure: &secure, + Env: req.Env, + Metadata: map[string]string{ + "source": "parsar_dev_smoke", + }, + }) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": sanitizeE2BSmokeError(err, apiKey)}) + return + } + killed := false + cleanupNeeded := !req.KeepAlive + defer func() { + if !cleanupNeeded { + return + } + killCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := client.Kill(killCtx, sandbox.SandboxID); err == nil { + killed = true + } + }() + result, err := client.RunCommand(r.Context(), e2bsandbox.RunCommandInput{ + Sandbox: sandbox, + Command: command, + Timeout: commandTimeout, + }) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{ + "error": sanitizeE2BSmokeError(err, apiKey), + "sandbox_id": sandbox.SandboxID, + "template_id": sandbox.TemplateID, + "keep_alive": req.KeepAlive, + }) + return + } + if !req.KeepAlive { + killCtx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + killErr := client.Kill(killCtx, sandbox.SandboxID) + cancel() + killed = killErr == nil + cleanupNeeded = false + } + writeJSON(w, http.StatusOK, e2bSmokeResponse{ + SandboxID: sandbox.SandboxID, + TemplateID: sandbox.TemplateID, + Killed: killed, + Command: result, + }) +} + +func sanitizeE2BSmokeError(err error, apiKey string) string { + if err == nil { + return "" + } + return e2bsandbox.RedactSecret(err.Error(), apiKey) +} diff --git a/server/internal/dev/routes_feishu.go b/server/internal/dev/routes_feishu.go new file mode 100644 index 0000000..6f100c5 --- /dev/null +++ b/server/internal/dev/routes_feishu.go @@ -0,0 +1,878 @@ +package dev + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + + "net/http" + "os" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + + authfeishu "github.com/MiniMax-AI-Dev/parsar/server/internal/auth/feishu" + gatewaypkg "github.com/MiniMax-AI-Dev/parsar/server/internal/gateway" + "github.com/MiniMax-AI-Dev/parsar/server/internal/gateway/router" + "github.com/MiniMax-AI-Dev/parsar/server/internal/secrets" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" + "github.com/go-chi/chi/v5" +) + +// createFeishuMessageEvent receives Feishu event webhooks. +// +// @Summary Receive a Feishu event webhook +// @Description Receives inbound Feishu message-event webhooks. Handles URL verification challenge and event dispatch to the runtime. +// @Tags feishu +// @ID receiveFeishuMessageEvent +// @Accept json +// @Produce json +// @Param body body map[string]interface{} true "Feishu event envelope" +// @Success 200 {object} map[string]interface{} "Acknowledged event" +// @Failure 400 {object} map[string]string "Invalid event body or signature" +// @Router /api/v1/feishu/events/message [post] +func createFeishuMessageEvent(runtimeStore RuntimeStore, webhook feishuWebhookConfig, joinURLBuilder func(workspaceID string) string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + if webhook.Enabled && !webhook.MockEnabled { + decoded, isChallenge, challenge, err := verifyFeishuWebhookEvent(r.Context(), runtimeStore, body, webhook) + if err != nil { + switch { + case errors.Is(err, authfeishu.ErrWebhookTokenMismatch): + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid feishu verification token"}) + case errors.Is(err, authfeishu.ErrWebhookDecryptFailed): + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to decrypt feishu event"}) + default: + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + } + return + } + if isChallenge { + writeJSON(w, http.StatusOK, map[string]string{"challenge": challenge}) + return + } + body = decoded + } + event, err := gatewaypkg.FeishuInboundEventFromWebhook(body) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + + // Tests and legacy dev shims may mount the Feishu endpoint without + // a DB-backed store or may send the pre-v2 shape without header.app_id. + // Keep the old normalization fallback there; real deployments go + // through app_id -> Agent routing below. + if runtimeStore == nil || strings.TrimSpace(event.AppID) == "" { + var legacy gatewaypkg.FeishuMessageEvent + if err := json.Unmarshal(body, &legacy); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + inbound := gatewaypkg.NormalizeFeishuInbound(legacy) + createGatewayInboundFromRequest(w, r, runtimeStore, gatewayInboundRequest{ + Gateway: inbound.Gateway, + Message: gatewayMessage{ID: inbound.Message.ID, Text: inbound.Message.Text}, + Actor: gatewayActor{ID: inbound.Actor.ID, Email: inbound.Actor.Email}, + ConversationRef: gatewayConversation{ID: inbound.ConversationRef.ID, Title: inbound.ConversationRef.Title, ThreadID: inbound.ConversationRef.ThreadID}, + Metadata: inbound.Metadata, + }) + return + } + + route := feishuRuntimeRouter{store: runtimeStore} + host, err := route.GetAgentByFeishuAppID(r.Context(), event.AppID) + if err != nil { + switch { + case errors.Is(err, gatewaypkg.ErrFeishuRouterUnknownAgent): + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown feishu app_id"}) + default: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to route feishu inbound"}) + } + return + } + if isFeishuSelfMessage(host.Config, event.SenderOpenID) { + writeJSON(w, http.StatusOK, map[string]any{ + "gateway": "feishu", + "accepted": false, + "reason": "bot_self_message", + }) + return + } + if isFeishuGroupMessageWithoutBotMention(r.Context(), runtimeStore, host.Config, event) { + writeJSON(w, http.StatusOK, map[string]any{ + "gateway": "feishu", + "accepted": false, + "reason": "group_without_bot_mention", + }) + return + } + hostCfg, ok, err := gatewaypkg.DecodeFeishuConnectorConfig(host.Config) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to decode feishu connector"}) + return + } + if ok && router.IsSharedRoutingMode(hostCfg.RoutingMode) { + reply := func(ctx context.Context, agent gatewaypkg.FeishuRouteAgent, _ gatewaypkg.InboundEvent, text string) error { + return sendFeishuImmediateText(ctx, runtimeStore, agent, event, text) + } + outcome, err := router.HandleInbound(r.Context(), runtimeStore, host, gatewaypkg.NeutralFromFeishuEvent(event), reply, nil, gatewaypkg.GateConfig{JoinURLBuilder: joinURLBuilder}) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to handle shared feishu bot inbound"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "gateway": "feishu", + "shared": true, + "accepted": outcome.Accepted, + "replied": outcome.Replied, + "reason": outcome.Reason, + "agent_id": outcome.AgentID, + }) + return + } + + decision, err := gatewaypkg.RouteInboundToAgent(r.Context(), route, gatewaypkg.NeutralFromFeishuEvent(event), host, gatewaypkg.GateConfig{JoinURLBuilder: joinURLBuilder}) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to route feishu inbound"}) + return + } + if !decision.Decision.Allowed { + replied := false + if decision.Decision.ReplyHint != "" { + if err := sendFeishuImmediateText(r.Context(), runtimeStore, decision.Agent, event, decision.Decision.ReplyHint); err != nil { + log.Bg().Warn("feishu inbound rejection reply failed", "app_id", event.AppID, "chat_id", event.ChatID, "err", err) + } else { + replied = true + } + } + writeJSON(w, http.StatusOK, map[string]any{ + "gateway": "feishu", + "accepted": false, + "replied": replied, + "reply_hint": decision.Decision.ReplyHint, + "reason": decision.Decision.Reason, + }) + return + } + + externalUserID := strings.TrimSpace(event.SenderUnionID) + if externalUserID == "" { + externalUserID = strings.TrimSpace(event.SenderOpenID) + } + conversationForm := "group" + if strings.EqualFold(strings.TrimSpace(event.ChatType), "p2p") { + conversationForm = "dm" + } + metadata := map[string]any{ + "chat_type": event.ChatType, + "tenant_key": event.TenantKey, + "sender_state": decision.SenderState, + "message_type": event.MessageType, + "raw_content": event.RawContent, + "root_id": event.RootID, + "parent_id": event.ParentID, + "thread_id": event.ThreadID, + } + for key, value := range event.Metadata { + if strings.TrimSpace(key) == "" || value == nil { + continue + } + metadata[key] = value + } + if decision.Decision.GuestReplyHint != "" { + metadata["guest_reply_hint"] = decision.Decision.GuestReplyHint + } + createGatewayInboundFromRequest(w, r, runtimeStore, gatewayInboundRequest{ + Gateway: "feishu", + Conversation: router.ConversationTitle(decision.NormalizedText), + ConversationForm: conversationForm, + Text: decision.NormalizedText, + ExternalChatID: event.ChatID, + // ThreadKey (not ReplyAnchorMessageID): every inbound in + // the same Feishu thread lands in the same Parsar + // conversation. Mirrors gateway/router/router.go. + ExternalThreadID: event.ThreadKey(), + ExternalMessageID: event.MessageID, + ExternalUserID: externalUserID, + TargetAgentID: decision.Agent.AgentID, + SourceAppID: event.AppID, + Metadata: metadata, + }) + } +} + +type feishuRuntimeRouter struct { + store RuntimeStore +} + +func (r feishuRuntimeRouter) GetAgentByFeishuAppID(ctx context.Context, appID string) (gatewaypkg.FeishuRouteAgent, error) { + route, err := r.store.GetAgentByFeishuAppID(ctx, appID) + if err != nil { + if errors.Is(err, store.ErrUnknownFeishuAgent) { + return gatewaypkg.FeishuRouteAgent{}, gatewaypkg.ErrFeishuRouterUnknownAgent + } + return gatewaypkg.FeishuRouteAgent{}, err + } + return gatewaypkg.FeishuRouteAgent{ + AgentID: route.AgentID, + WorkspaceID: route.WorkspaceID, + WorkspaceName: route.WorkspaceName, + AgentName: route.AgentName, + AgentSlug: route.AgentSlug, + Visibility: gatewaypkg.Visibility(route.Visibility), + Config: route.Config, + }, nil +} + +func (r feishuRuntimeRouter) GetAgentByID(ctx context.Context, agentID string) (gatewaypkg.FeishuRouteAgent, error) { + route, err := r.store.GetAgentByID(ctx, agentID) + if err != nil { + if errors.Is(err, store.ErrUnknownFeishuAgent) { + return gatewaypkg.FeishuRouteAgent{}, gatewaypkg.ErrFeishuRouterUnknownAgent + } + return gatewaypkg.FeishuRouteAgent{}, err + } + return gatewaypkg.FeishuRouteAgent{ + AgentID: route.AgentID, + WorkspaceID: route.WorkspaceID, + WorkspaceName: route.WorkspaceName, + AgentName: route.AgentName, + AgentSlug: route.AgentSlug, + Visibility: gatewaypkg.Visibility(route.Visibility), + Config: route.Config, + }, nil +} + +func (r feishuRuntimeRouter) FindUserIDByPlatformSubject(ctx context.Context, platform, subject string) (string, error) { + userID, err := r.store.FindUserIDByPlatformSubject(ctx, platform, subject) + if err != nil { + if errors.Is(err, store.ErrUnknownPlatformUser) { + return "", gatewaypkg.ErrRouterUnknownUser + } + return "", err + } + return userID, nil +} + +func (r feishuRuntimeRouter) IsActiveWorkspaceMember(ctx context.Context, workspaceID, userID string) (bool, error) { + return r.store.IsActiveWorkspaceMember(ctx, workspaceID, userID) +} + +func (r feishuRuntimeRouter) GetWorkspaceVisibility(ctx context.Context, workspaceID string) (string, error) { + return r.store.GetWorkspaceVisibility(ctx, workspaceID) +} + +func (r feishuRuntimeRouter) ListWorkspaceOwnerNames(ctx context.Context, workspaceID string, limit int32) ([]string, error) { + return r.store.ListActiveWorkspaceOwnerNames(ctx, workspaceID, limit) +} + +func verifyFeishuWebhookEvent(ctx context.Context, runtimeStore RuntimeStore, body []byte, webhook feishuWebhookConfig) ([]byte, bool, string, error) { + decoded, isChallenge, challenge, err := authfeishu.VerifyAndDecodeEvent(body, webhook.VerificationToken, webhook.EncryptKey) + if err == nil || !errors.Is(err, authfeishu.ErrWebhookTokenMismatch) { + return decoded, isChallenge, challenge, err + } + if runtimeStore == nil || feishuEnvelopeEncrypted(body) { + return nil, false, "", err + } + event, parseErr := gatewaypkg.FeishuInboundEventFromWebhook(body) + if parseErr != nil || strings.TrimSpace(event.AppID) == "" { + return nil, false, "", err + } + route, routeErr := runtimeStore.GetAgentByFeishuAppID(ctx, event.AppID) + if routeErr != nil { + return nil, false, "", err + } + cfg, ok, cfgErr := gatewaypkg.DecodeFeishuConnectorConfig(route.Config) + if cfgErr != nil || !ok || !cfg.Enabled || strings.TrimSpace(cfg.VerificationTokenRef) == "" { + return nil, false, "", err + } + verifyToken, tokenErr := loadFeishuSecretString(ctx, runtimeStore, route.WorkspaceID, cfg.VerificationTokenRef, "verification_token", "token", "value", "api_key") + if tokenErr != nil { + return nil, false, "", err + } + return authfeishu.VerifyAndDecodeEvent(body, verifyToken, "") +} + +func feishuEnvelopeEncrypted(body []byte) bool { + var envelope struct { + Encrypt string `json:"encrypt"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return false + } + return strings.TrimSpace(envelope.Encrypt) != "" +} + +func isFeishuSelfMessage(rawConfig []byte, senderOpenID string) bool { + senderOpenID = strings.TrimSpace(senderOpenID) + if senderOpenID == "" { + return false + } + cfg, ok, err := gatewaypkg.DecodeFeishuConnectorConfig(rawConfig) + if err != nil || !ok { + return false + } + return strings.TrimSpace(cfg.BotOpenID) != "" && strings.TrimSpace(cfg.BotOpenID) == senderOpenID +} + +// feishuThreadHistoryLookup is the narrow store surface +// isFeishuGroupMessageWithoutBotMention needs to support thread follow-up. +// It is satisfied by RuntimeStore (production) and by the +// feishuSecretRouteStore test double. +type feishuThreadHistoryLookup interface { + HasFeishuThreadInboundHistory(ctx context.Context, externalChatID, threadID string) (bool, error) +} + +// isFeishuGroupMessageWithoutBotMention decides whether a group-chat +// inbound should be silently dropped before any routing / storage work. +// +// Decision order (true = drop, false = let it through): +// 1. p2p chat → false. +// 2. mentions present: include bot_open_id → false; else → true. +// 3. no mentions in a group: if (chat_id, thread_id) has prior bot +// history → false (thread follow-up doesn't need re-@); else → true. +// +// bot_open_id missing → bot defaults to refusing all group messages; +// operator must configure via the connector panel or provisioning. +func isFeishuGroupMessageWithoutBotMention(ctx context.Context, store feishuThreadHistoryLookup, rawConfig []byte, event gatewaypkg.FeishuInboundEvent) bool { + chatType := strings.ToLower(strings.TrimSpace(event.ChatType)) + if chatType == "p2p" || chatType == "" { + return false + } + // Other Feishu apps/bots post interactive cards whose "@bot" text + // lives in the card body, never in message.mentions. Treat any + // non-user sender as already-targeted at us. + if event.IsBotSender() { + return false + } + botOpenID := "" + if cfg, ok, err := gatewaypkg.DecodeFeishuConnectorConfig(rawConfig); err == nil && ok { + botOpenID = strings.TrimSpace(cfg.BotOpenID) + } + // Step 2 — mentions present. + if len(event.MentionOpenIDs) > 0 { + if botOpenID == "" { + return true + } + for _, mentionedOpenID := range event.MentionOpenIDs { + if strings.TrimSpace(mentionedOpenID) == botOpenID { + return false + } + } + // Mentions exist but bot is not among them — message is aimed + // at another participant, do not respond. + return true + } + // Step 3 — no mentions in a group. Check thread participation via + // ThreadKey (thread_id → root_id → message_id fallback). ThreadKey + // == MessageID for non-thread inbounds; brand-new top-level + // messages have no conversation yet, so that branch is a no-op. + threadKey := strings.TrimSpace(event.ThreadKey()) + if threadKey != "" && store != nil { + hasHistory, err := store.HasFeishuThreadInboundHistory(ctx, strings.TrimSpace(event.ChatID), threadKey) + if err == nil && hasHistory { + return false + } + // Fail closed on lookup error: drop. The next @mention recovers. + } + return true +} + +func sendFeishuImmediateText(ctx context.Context, runtimeStore RuntimeStore, agent gatewaypkg.FeishuRouteAgent, event gatewaypkg.FeishuInboundEvent, text string) error { + if runtimeStore == nil { + return errors.New("runtime store is not configured") + } + cfg, ok, err := gatewaypkg.DecodeFeishuConnectorConfig(agent.Config) + if err != nil { + return err + } + if !ok || !cfg.Enabled || strings.TrimSpace(cfg.AppSecretRef) == "" { + return errors.New("feishu connector missing app_secret_ref") + } + appSecret, err := loadFeishuSecretString(ctx, runtimeStore, agent.WorkspaceID, cfg.AppSecretRef, "app_secret", "secret", "value", "api_key") + if err != nil { + return err + } + content, err := gatewaypkg.BuildFeishuInteractiveContent(text) + if err != nil { + return err + } + client, err := gatewaypkg.NewFeishuTenantClient(gatewaypkg.FeishuTenantClientOptions{ + AppID: cfg.AppID, + BaseURL: strings.TrimSpace(os.Getenv("PARSAR_FEISHU_OPENAPI_BASE_URL")), + }) + if err != nil { + return err + } + sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if replyAnchor := event.ReplyAnchorMessageID(); replyAnchor != "" { + _, err = client.ReplyMessage(sendCtx, appSecret, replyAnchor, gatewaypkg.FeishuMessageReplyRequest{ + MsgType: "interactive", + Content: content, + ReplyInThread: true, + }) + return err + } + chatID := strings.TrimSpace(event.ChatID) + if chatID == "" { + return errors.New("feishu inbound missing chat_id for immediate reply") + } + _, err = client.SendMessage(sendCtx, appSecret, gatewaypkg.FeishuMessageSendRequest{ + ReceiveIDType: "chat_id", + ReceiveID: chatID, + MsgType: "interactive", + Content: content, + }) + return err +} + +func loadFeishuSecretString(ctx context.Context, runtimeStore RuntimeStore, workspaceID, secretID string, keys ...string) (string, error) { + secretID = strings.TrimSpace(secretID) + if secretID == "" { + return "", errors.New("secret id is required") + } + payload, err := runtimeStore.GetSecretPayload(ctx, workspaceID, secretID) + if err != nil { + return "", err + } + masterKey := strings.TrimSpace(os.Getenv("PARSAR_MASTER_KEY")) + if masterKey == "" { + return "", errors.New("PARSAR_MASTER_KEY env not set") + } + secretService, err := secrets.New(masterKey) + if err != nil { + return "", err + } + decoded, err := secretService.Decrypt(payload.EncryptedPayload) + if err != nil { + return "", err + } + for _, key := range keys { + if raw, ok := decoded[key].(string); ok && strings.TrimSpace(raw) != "" { + return strings.TrimSpace(raw), nil + } + } + return "", fmt.Errorf("secret %s payload missing expected string field", secretID) +} + +// updateAgentFeishuConnectorBody is the request body for +// PATCH /api/v1/agents/{agentID}/connector/feishu. +type updateAgentFeishuConnectorBody struct { + Enabled bool `json:"enabled"` + AppID string `json:"app_id"` + AppSecretRef string `json:"app_secret_ref"` + VerificationTokenRef string `json:"verification_token_ref"` + EncryptKeyRef string `json:"encrypt_key_ref"` + BotOpenID string `json:"bot_open_id"` + EventMode string `json:"event_mode"` + RoutingMode string `json:"routing_mode"` +} + +type pollAgentFeishuProvisioningBody struct { + DeviceCode string `json:"device_code"` + IntervalSec int `json:"interval_sec"` + TenantBrand string `json:"tenant_brand"` +} + +type feishuProvisioningResponse struct { + Status string `json:"status"` + Begin *gatewaypkg.FeishuAppRegistrationBeginResult `json:"begin,omitempty"` + NextIntervalSec int `json:"next_interval_sec,omitempty"` + Error string `json:"error,omitempty"` + Description string `json:"description,omitempty"` + AppID string `json:"app_id,omitempty"` + AppSecretRef string `json:"app_secret_ref,omitempty"` + BotOpenID string `json:"bot_open_id,omitempty"` + BotName string `json:"bot_name,omitempty"` + FeishuConnector *store.AgentFeishuConnectorChange `json:"feishu_connector,omitempty"` +} + +// getAgentFeishuConnectorDiagnostics returns a read-only Feishu Bot +// observation snapshot for admins and workspace members. Unlike the +// write path, this is a read/debug surface and never exposes secret refs. +// getAgentFeishuConnectorDiagnostics returns Feishu connector health. +// +// @Summary Get an agent's Feishu connector diagnostics +// @Description Returns diagnostic snapshots for the agent's Feishu connector (webhook subscription, event verify, bot token). +// @Tags feishu +// @ID getDevAgentFeishuDiagnostics +// @Produce json +// @Param agentID path string true "Agent UUID" +// @Success 200 {object} map[string]interface{} "Diagnostics snapshot" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 404 {object} map[string]string "Agent not found" +// @Router /api/v1/agents/{agentID}/connector/feishu/diagnostics [get] +func getAgentFeishuConnectorDiagnostics(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) + if runtimeStore == nil || !isUUID(agentID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) + return + } + agent, err := runtimeStore.GetAgent(r.Context(), agentID) + if err != nil { + writeStoreAgentError(w, err) + return + } + if err := requireWorkspaceMember(r, runtimeStore, agent.WorkspaceID); err != nil { + writeRBACError(w, err) + return + } + diagnostics, err := runtimeStore.GetFeishuConnectorDiagnostics(r.Context(), agentID) + if err != nil { + writeStoreAgentError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"diagnostics": diagnostics}) + } +} + +// updateAgentFeishuConnector binds (or rebinds) an Agent to a Feishu +// Bot self-built app — writes agents.config.connectors.feishu so the +// inbound router and outbound worker can resolve this Agent. +// RBAC: workspace owner / admin (a misconfigured Bot can leak the +// workspace to the internet). +// updateAgentFeishuConnector updates the agent's Feishu connector config. +// +// @Summary Update an agent's Feishu connector +// @Description Updates the agent's Feishu connector credentials/config. Owner/admin only. +// @Tags feishu +// @ID updateDevAgentFeishuConnector +// @Accept json +// @Produce json +// @Param agentID path string true "Agent UUID" +// @Param body body updateAgentFeishuConnectorBody true "Connector payload" +// @Success 200 {object} map[string]interface{} "Updated agent" +// @Failure 400 {object} map[string]string "Invalid body or UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 404 {object} map[string]string "Agent not found" +// @Router /api/v1/agents/{agentID}/connector/feishu [patch] +func updateAgentFeishuConnector(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) + if runtimeStore == nil || !isUUID(agentID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) + return + } + agent, err := runtimeStore.GetAgent(r.Context(), agentID) + if err != nil { + writeStoreAgentError(w, err) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, agent.WorkspaceID); err != nil { + writeRBACError(w, err) + return + } + var req updateAgentFeishuConnectorBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + botOpenID, err := resolveFeishuBotOpenID(r.Context(), runtimeStore, agent.WorkspaceID, req.AppID, req.AppSecretRef, req.BotOpenID) + if err != nil { + log.Bg().Warn("feishu bot open_id auto-resolve failed", "agent_id", agentID, "app_id", req.AppID, "err", err) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "feishu_bot_open_id_resolve_failed", "detail": err.Error()}) + return + } + change, err := runtimeStore.UpdateAgentFeishuConnector(r.Context(), store.UpdateAgentFeishuConnectorInput{ + AgentID: agentID, + Enabled: req.Enabled, + AppID: req.AppID, + AppSecretRef: req.AppSecretRef, + VerificationTokenRef: req.VerificationTokenRef, + EncryptKeyRef: req.EncryptKeyRef, + BotOpenID: botOpenID, + EventMode: req.EventMode, + RoutingMode: req.RoutingMode, + }, actorIDFromRequest(r)) + if err != nil { + switch { + case errors.Is(err, store.ErrFeishuConnectorIncomplete): + // api-client.ts copies the JSON `error` field into + // both envelope.code and .message on the frontend, so + // the discriminator lives in `error`. `detail` carries + // human-readable text for logs/devtools. + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{ + "error": "feishu_connector_incomplete", + "detail": err.Error(), + }) + return + case errors.Is(err, store.ErrFeishuAppIDInUse): + writeJSON(w, http.StatusConflict, map[string]string{ + "error": "feishu_app_id_in_use", + "detail": err.Error(), + }) + return + } + writeStoreAgentError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"feishu_connector": change}) + } +} + +// beginAgentFeishuProvisioning starts the async Feishu app provisioning. +// +// @Summary Begin Feishu provisioning +// @Description Starts the async Feishu app provisioning flow for the agent's connector. Returns a session handle to poll. +// @Tags feishu +// @ID beginDevAgentFeishuProvisioning +// @Produce json +// @Param agentID path string true "Agent UUID" +// @Success 200 {object} map[string]interface{} "Provisioning session" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 404 {object} map[string]string "Agent not found" +// @Router /api/v1/agents/{agentID}/connector/feishu/provision/begin [post] +func beginAgentFeishuProvisioning(runtimeStore RuntimeStore, cfg feishuRegistrationConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if cfg.Client == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "feishu_app_registration_not_configured"}) + return + } + if _, ok := agentForFeishuConnectorWrite(w, r, runtimeStore); !ok { + return + } + begin, err := cfg.Client.Begin(r.Context()) + if err != nil { + log.Bg().Warn("feishu app registration begin failed", "err", err) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "feishu_app_registration_begin_failed"}) + return + } + writeJSON(w, http.StatusOK, feishuProvisioningResponse{Status: "pending", Begin: &begin, NextIntervalSec: begin.Interval}) + } +} + +// pollAgentFeishuProvisioning polls the async Feishu provisioning flow. +// +// @Summary Poll Feishu provisioning status +// @Description Polls the async Feishu provisioning flow. Returns the current step and any completion payload. +// @Tags feishu +// @ID pollDevAgentFeishuProvisioning +// @Accept json +// @Produce json +// @Param agentID path string true "Agent UUID" +// @Param body body pollAgentFeishuProvisioningBody true "Poll payload" +// @Success 200 {object} map[string]interface{} "Provisioning status" +// @Failure 400 {object} map[string]string "Invalid body or UUID" +// @Failure 404 {object} map[string]string "Provisioning session not found" +// @Router /api/v1/agents/{agentID}/connector/feishu/provision/poll [post] +func pollAgentFeishuProvisioning(runtimeStore RuntimeStore, cfg feishuRegistrationConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if cfg.Client == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "feishu_app_registration_not_configured"}) + return + } + agent, ok := agentForFeishuConnectorWrite(w, r, runtimeStore) + if !ok { + return + } + var req pollAgentFeishuProvisioningBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + if strings.TrimSpace(req.DeviceCode) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "device_code is required"}) + return + } + status, err := cfg.Client.Poll(r.Context(), req.DeviceCode, req.IntervalSec, req.TenantBrand) + if err != nil { + log.Bg().Warn("feishu app registration poll failed", "err", err) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "feishu_app_registration_poll_failed"}) + return + } + switch status.Kind { + case gatewaypkg.FeishuAppRegistrationPollPending: + writeJSON(w, http.StatusOK, feishuProvisioningResponse{Status: "pending", NextIntervalSec: status.NextIntervalSec}) + return + case gatewaypkg.FeishuAppRegistrationPollError: + writeJSON(w, http.StatusOK, feishuProvisioningResponse{Status: "error", Error: status.Error, Description: status.Description}) + return + case gatewaypkg.FeishuAppRegistrationPollSuccess: + // proceed below + default: + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "feishu_app_registration_unknown_status"}) + return + } + + appID := strings.TrimSpace(status.ClientID) + appSecret := strings.TrimSpace(status.ClientSecret) + if appID == "" || appSecret == "" { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "feishu_app_registration_missing_credentials"}) + return + } + if ok := assertFeishuAppIDAvailableForAgent(w, r.Context(), runtimeStore, appID, agent.ID); !ok { + return + } + + botInfo, err := validateProvisionedFeishuBot(r.Context(), appID, appSecret, feishuOpenAPIBaseURL(cfg.OpenAPIBaseURL)) + if err != nil { + log.Bg().Warn("feishu provisioned bot validation failed", "app_id", appID, "err", err) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "feishu_bot_validation_failed"}) + return + } + secret, err := createFeishuAppSecretFromProvisioning(r.Context(), runtimeStore, agent.WorkspaceID, agent.Name, appID, appSecret, actorIDFromRequest(r)) + if err != nil { + log.Bg().Warn("feishu provisioned app secret write failed", "app_id", appID, "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed_to_store_feishu_app_secret"}) + return + } + change, err := runtimeStore.UpdateAgentFeishuConnector(r.Context(), store.UpdateAgentFeishuConnectorInput{ + AgentID: agent.ID, + Enabled: true, + AppID: appID, + AppSecretRef: secret.ID, + BotOpenID: botInfo.OpenID, + EventMode: "websocket", + }, actorIDFromRequest(r)) + if err != nil { + switch { + case errors.Is(err, store.ErrFeishuAppIDInUse): + writeJSON(w, http.StatusConflict, map[string]string{"error": "feishu_app_id_in_use", "detail": err.Error()}) + case errors.Is(err, store.ErrFeishuConnectorIncomplete): + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "feishu_connector_incomplete", "detail": err.Error()}) + default: + writeStoreAgentError(w, err) + } + return + } + writeJSON(w, http.StatusOK, feishuProvisioningResponse{ + Status: "success", + AppID: appID, + AppSecretRef: secret.ID, + BotOpenID: botInfo.OpenID, + BotName: botInfo.AppName, + FeishuConnector: &change, + }) + } +} + +func agentForFeishuConnectorWrite(w http.ResponseWriter, r *http.Request, runtimeStore RuntimeStore) (store.AgentSummary, bool) { + agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) + if runtimeStore == nil || !isUUID(agentID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) + return store.AgentSummary{}, false + } + agent, err := runtimeStore.GetAgent(r.Context(), agentID) + if err != nil { + writeStoreAgentError(w, err) + return store.AgentSummary{}, false + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, agent.WorkspaceID); err != nil { + writeRBACError(w, err) + return store.AgentSummary{}, false + } + return agent, true +} + +func assertFeishuAppIDAvailableForAgent(w http.ResponseWriter, ctx context.Context, runtimeStore RuntimeStore, appID, agentID string) bool { + existing, err := runtimeStore.GetAgentByFeishuAppID(ctx, appID) + switch { + case err == nil: + if existing.AgentID != agentID { + writeJSON(w, http.StatusConflict, map[string]string{"error": "feishu_app_id_in_use"}) + return false + } + case errors.Is(err, store.ErrUnknownFeishuAgent): + return true + default: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed_to_check_feishu_app_id"}) + return false + } + return true +} + +func validateProvisionedFeishuBot(ctx context.Context, appID, appSecret, openAPIBaseURL string) (gatewaypkg.FeishuBotInfo, error) { + client, err := gatewaypkg.NewFeishuTenantClient(gatewaypkg.FeishuTenantClientOptions{ + AppID: appID, + BaseURL: openAPIBaseURL, + }) + if err != nil { + return gatewaypkg.FeishuBotInfo{}, err + } + validateCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + return client.BotInfo(validateCtx, appSecret) +} + +// resolveFeishuBotOpenID fills in the bot's open_id when the caller left it +// blank. The value is derivable from the app credentials (bot/v3/info returns +// it), so the bind form no longer needs it as a manual field. When botOpenID is +// already set it is returned untouched; when app_id or app_secret_ref is absent +// there is nothing to derive from, so the blank is passed through and the store's +// own completeness check decides whether that is acceptable. +func resolveFeishuBotOpenID(ctx context.Context, runtimeStore RuntimeStore, workspaceID, appID, appSecretRef, botOpenID string) (string, error) { + botOpenID = strings.TrimSpace(botOpenID) + if botOpenID != "" { + return botOpenID, nil + } + appID = strings.TrimSpace(appID) + appSecretRef = strings.TrimSpace(appSecretRef) + if appID == "" || appSecretRef == "" { + return "", nil + } + appSecret, err := loadFeishuSecretString(ctx, runtimeStore, workspaceID, appSecretRef, "app_secret", "secret", "value", "api_key") + if err != nil { + return "", err + } + info, err := validateProvisionedFeishuBot(ctx, appID, appSecret, feishuOpenAPIBaseURL("")) + if err != nil { + return "", err + } + return strings.TrimSpace(info.OpenID), nil +} + +func createFeishuAppSecretFromProvisioning(ctx context.Context, runtimeStore RuntimeStore, workspaceID, agentName, appID, appSecret, actorID string) (store.SecretRead, error) { + masterKey := strings.TrimSpace(os.Getenv("PARSAR_MASTER_KEY")) + if masterKey == "" { + return store.SecretRead{}, errors.New("PARSAR_MASTER_KEY env not set") + } + secretService, err := secrets.New(masterKey) + if err != nil { + return store.SecretRead{}, err + } + payload := map[string]any{ + "app_id": appID, + "app_secret": appSecret, + "source": "feishu_qr_provisioning", + } + encrypted, err := secretService.Encrypt(payload) + if err != nil { + return store.SecretRead{}, err + } + name := "Feishu Bot App Secret" + if strings.TrimSpace(agentName) != "" { + name = fmt.Sprintf("%s Feishu Bot App Secret", strings.TrimSpace(agentName)) + } + return runtimeStore.CreateSecret(ctx, store.CreateSecretInput{ + WorkspaceID: workspaceID, + Name: name, + Kind: "feishu_app_secret", + Provider: "feishu", + AuthType: "app_secret", + Payload: payload, + Masked: secrets.MaskPayload(payload), + CreatedBy: actorID, + }, encrypted) +} + +func feishuOpenAPIBaseURL(configured string) string { + if strings.TrimSpace(configured) != "" { + return strings.TrimSpace(configured) + } + if v := strings.TrimSpace(os.Getenv("PARSAR_FEISHU_OPENAPI_BASE_URL")); v != "" { + return v + } + return strings.TrimSpace(os.Getenv(authfeishu.EnvAPIBase)) +} diff --git a/server/internal/dev/routes_gateway.go b/server/internal/dev/routes_gateway.go new file mode 100644 index 0000000..12f397b --- /dev/null +++ b/server/internal/dev/routes_gateway.go @@ -0,0 +1,278 @@ +package dev + +import ( + "encoding/json" + "errors" + "fmt" + "io" + + "net/http" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" + "github.com/go-chi/chi/v5" +) + +type gatewayInboundRequest struct { + Gateway string `json:"gateway"` + Conversation string `json:"conversation"` + Sender string `json:"sender"` + Text string `json:"text"` + ExternalChatID string `json:"external_chat_id"` + ExternalUserID string `json:"external_user_id"` + ExternalThreadID string `json:"external_thread_id"` + ExternalMessageID string `json:"external_message_id"` + TargetAgentID string `json:"target_agent_id"` + SourceAppID string `json:"source_app_id"` + ConversationForm string `json:"conversation_form"` + Message gatewayMessage `json:"message"` + Actor gatewayActor `json:"actor"` + ConversationRef gatewayConversation `json:"conversation_ref"` + Metadata map[string]any `json:"metadata"` +} + +type gatewayMessage struct { + ID string `json:"id"` + Text string `json:"text"` +} + +type gatewayActor struct { + ID string `json:"id"` + Email string `json:"email"` +} + +type gatewayConversation struct { + ID string `json:"id"` + Title string `json:"title"` + ThreadID string `json:"thread_id"` +} + +// createGatewayInbound writes an inbound gateway envelope to storage. +// +// @Summary Create an inbound gateway envelope +// @Description Writes an inbound gateway envelope from the connector daemon into storage for dispatch. Development helper. +// @Tags gateway +// @ID createDevGatewayInbound +// @Accept json +// @Produce json +// @Param body body gatewayInboundRequest true "Gateway inbound payload" +// @Success 201 {object} map[string]interface{} "Created row" +// @Failure 400 {object} map[string]string "Invalid body" +// @Router /dev/gateway/inbound [post] +func createGatewayInbound(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req gatewayInboundRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + createGatewayInboundFromRequest(w, r, runtimeStore, req) + } +} + +func createGatewayInboundFromRequest(w http.ResponseWriter, r *http.Request, runtimeStore RuntimeStore, req gatewayInboundRequest) { + if strings.TrimSpace(req.Gateway) == "" { + req.Gateway = "dev" + } + normalizeGatewayInbound(&req) + if strings.TrimSpace(req.Conversation) == "" && strings.TrimSpace(req.ExternalChatID) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "conversation or external_chat_id is required"}) + return + } + if strings.TrimSpace(req.Sender) == "" && strings.TrimSpace(req.ExternalUserID) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "sender or external_user_id is required"}) + return + } + if strings.TrimSpace(req.Text) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "text is required"}) + return + } + + mentions := mentionPattern.FindAllString(req.Text, -1) + if runtimeStore == nil { + writeJSON(w, http.StatusCreated, map[string]any{ + "gateway": req.Gateway, + "message_id": fmt.Sprintf("gateway_msg_%d", time.Now().UnixNano()), + "run_ids": []string{}, + "mentions": mentions, + "created_at": time.Now().UTC(), + }) + return + } + + result, err := runtimeStore.CreateInboundIMMessage(r.Context(), store.CreateInboundIMMessageInput{ + ConversationTitle: req.Conversation, + SenderEmail: req.Sender, + Text: req.Text, + Mentions: mentions, + Source: "gateway", + Gateway: req.Gateway, + ExternalUserID: req.ExternalUserID, + ExternalChatID: req.ExternalChatID, + ExternalThreadID: req.ExternalThreadID, + ExternalMessageID: req.ExternalMessageID, + TargetAgentID: req.TargetAgentID, + SourceAppID: req.SourceAppID, + ConversationForm: req.ConversationForm, + Metadata: req.Metadata, + }) + if err != nil { + if errors.Is(err, store.ErrUnknownMention) || errors.Is(err, store.ErrUnknownConversation) || errors.Is(err, store.ErrUnknownSender) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create gateway inbound message"}) + return + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "gateway": req.Gateway, + "message_id": result.MessageID, + "run_ids": result.RunIDs, + "mentions": result.Mentions, + "created_at": result.CreatedAt, + }) +} + +func normalizeGatewayInbound(req *gatewayInboundRequest) { + if strings.TrimSpace(req.Text) == "" { + req.Text = req.Message.Text + } + if strings.TrimSpace(req.ExternalMessageID) == "" { + req.ExternalMessageID = req.Message.ID + } + if strings.TrimSpace(req.Sender) == "" { + req.Sender = req.Actor.Email + } + if strings.TrimSpace(req.ExternalUserID) == "" { + req.ExternalUserID = req.Actor.ID + } + if strings.TrimSpace(req.Conversation) == "" { + req.Conversation = req.ConversationRef.Title + } + if strings.TrimSpace(req.ExternalChatID) == "" { + req.ExternalChatID = req.ConversationRef.ID + } + if strings.TrimSpace(req.ExternalThreadID) == "" { + req.ExternalThreadID = req.ConversationRef.ThreadID + } +} + +type markGatewayOutboundDeliveredBody struct { + DeliveryID string `json:"delivery_id"` +} + +// listGatewayOutbound dumps a slice of the inflight-card driver state +// for ops / smoke tests. Each row carries conversation_id / agent_run_id +// so ops can correlate against agent_runs + conversations. +// listGatewayOutbound lists pending outbound gateway messages. +// +// @Summary List outbound gateway messages +// @Description Returns outbound gateway messages, optionally filtered by connector or delivery status. +// @Tags gateway +// @ID listDevGatewayOutbound +// @Produce json +// @Success 200 {object} map[string]interface{} "Outbound message rows" +// @Router /dev/gateway/outbound [get] +func listGatewayOutbound(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed gateway outbound is disabled"}) + return + } + gateway := strings.TrimSpace(r.URL.Query().Get("gateway")) + // inflightCutoffWindow is ~5m; debug surface uses a longer + // look-back, with the limit capping response size. + cutoff := time.Now().UTC().Add(-1 * time.Hour) + convs, err := runtimeStore.ListActiveFeishuInflightConversations(r.Context(), cutoff, parseLimit(r, 100)) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list gateway inflight conversations"}) + return + } + if gateway == "" { + gateway = "feishu" + } + writeJSON(w, http.StatusOK, map[string]any{"gateway": gateway, "inflight": inflightDeliveries(convs)}) + } +} + +func inflightDeliveries(convs []store.FeishuInflightConversation) []map[string]any { + deliveries := make([]map[string]any, 0, len(convs)) + for _, c := range convs { + row := map[string]any{ + "conversation_id": c.ConversationID, + "workspace_id": c.WorkspaceID, + "agent_run_id": c.AgentRunID, + "external_chat_id": c.ExternalChatID, + "external_thread_id": c.ExternalThreadID, + "source_app_id": c.SourceAppID, + "run_status": c.RunStatus, + "max_seq": c.MaxEventSequence, + } + // Pull the working slot (msg id / retry triad / seq_emitted) + // out of conversation gateway_inflight metadata. + if inflight, ok := c.ConversationMetadata["gateway_inflight"].(map[string]any); ok { + if working, ok := inflight["working"].(map[string]any); ok { + row["working_msg_id"] = working["external_msg_id"] + row["seq_emitted"] = working["seq_emitted"] + if v, ok := working["attempts"]; ok { + row["working_attempts"] = v + } + if v, ok := working["last_error"]; ok { + row["working_last_error"] = v + } + if v, ok := working["next_retry_at"]; ok { + row["working_next_retry_at"] = v + } + } + } + deliveries = append(deliveries, row) + } + return deliveries +} + +// markGatewayOutboundDelivered marks an outbound message as delivered. +// +// @Summary Mark an outbound gateway message delivered +// @Description Marks a gateway outbound message row as delivered. Used by the connector daemon to close the loop. +// @Tags gateway +// @ID markDevGatewayOutboundDelivered +// @Accept json +// @Produce json +// @Param messageID path string true "Message ID" +// @Param body body markGatewayOutboundDeliveredBody true "Delivery payload" +// @Success 200 {object} map[string]interface{} "Updated row" +// @Failure 400 {object} map[string]string "Invalid body or ID" +// @Failure 404 {object} map[string]string "Message not found" +// @Router /dev/gateway/outbound/{messageID}/delivered [post] +func markGatewayOutboundDelivered(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed gateway outbound is disabled"}) + return + } + messageID := strings.TrimSpace(chi.URLParam(r, "messageID")) + if !isUUID(messageID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "message_id must be a valid uuid"}) + return + } + var req markGatewayOutboundDeliveredBody + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + } + result, err := runtimeStore.MarkGatewayOutboundDelivered(r.Context(), store.MarkGatewayOutboundDeliveredInput{MessageID: messageID, DeliveryID: req.DeliveryID}) + if err != nil { + if errors.Is(err, store.ErrUnknownMessage) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to mark gateway outbound delivered"}) + return + } + writeJSON(w, http.StatusOK, result) + } +} diff --git a/server/internal/dev/routes_helpers.go b/server/internal/dev/routes_helpers.go new file mode 100644 index 0000000..94330f5 --- /dev/null +++ b/server/internal/dev/routes_helpers.go @@ -0,0 +1,238 @@ +package dev + +import ( + "context" + "encoding/json" + "errors" + "io" + + "net/http" + "strconv" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/auth" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" + "github.com/go-chi/chi/v5" +) + +// devActorID returns the authenticated caller for dev writes. Require +// middleware should have populated the context before handlers run. +func devActorID(w http.ResponseWriter, r *http.Request) (string, bool) { + userID := strings.TrimSpace(auth.UserIDFromContext(r.Context())) + if userID == "" { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "authenticated user missing from request context"}) + return "", false + } + if !isUUID(userID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id must be a valid uuid"}) + return "", false + } + return userID, true +} + +func parseLimit(r *http.Request, fallback int32) int32 { + raw := strings.TrimSpace(r.URL.Query().Get("limit")) + if raw == "" { + return fallback + } + limit, err := strconv.Atoi(raw) + if err != nil || limit <= 0 { + return fallback + } + if limit > 500 { + return 500 + } + return int32(limit) +} + +// parseOffset reads ?offset=N for paginated endpoints. Defaults to 0; +// clamps negative inputs to 0 (pagination underflow is meaningless). +func parseOffset(r *http.Request) int32 { + raw := strings.TrimSpace(r.URL.Query().Get("offset")) + if raw == "" { + return 0 + } + offset, err := strconv.Atoi(raw) + if err != nil || offset < 0 { + return 0 + } + return int32(offset) +} + +func isUUID(value string) bool { + if len(value) != 36 { + return false + } + for i, c := range value { + switch i { + case 8, 13, 18, 23: + if c != '-' { + return false + } + default: + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + } + return true +} + +func writeReadError(w http.ResponseWriter, err error, fallback string) { + switch { + case errors.Is(err, store.ErrUnknownWorkspace), errors.Is(err, store.ErrUnknownConversationForRead), errors.Is(err, store.ErrUnknownAgentRun), errors.Is(err, store.ErrUnknownConversation): + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + case errors.Is(err, store.ErrDuplicateWorkspaceSlug): + writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) + case errors.Is(err, store.ErrMarketplaceDependents): + writeJSON(w, http.StatusConflict, map[string]string{"error": "has_marketplace_dependents", "message": err.Error()}) + case errors.Is(err, store.ErrInvalidWorkspaceInput), errors.Is(err, store.ErrInvalidInput): + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + default: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fallback}) + } +} + +func writeStoreAgentError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, store.ErrDuplicateAgentSlug): + suggested := "" + parts := strings.Split(err.Error(), ": ") + if len(parts) > 1 { + suggested = parts[len(parts)-1] + } + writeJSON(w, http.StatusConflict, map[string]string{"error": "slug_conflict", "suggested": suggested}) + case errors.Is(err, store.ErrUnknownCapability): + invalid := []string{} + parts := strings.Split(err.Error(), ": ") + if len(parts) > 1 && strings.TrimSpace(parts[len(parts)-1]) != "" { + invalid = strings.Split(parts[len(parts)-1], ",") + } + writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "unknown_capability", "invalid": invalid}) + case errors.Is(err, store.ErrUnknownCapabilityVersion): + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + case errors.Is(err, store.ErrMarketplaceCapabilityUnavailable): + writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) + case errors.Is(err, store.ErrUnknownAgent), errors.Is(err, store.ErrUnknownAgent), errors.Is(err, store.ErrUnknownWorkspace), errors.Is(err, store.ErrUnknownWorkspace): + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + case errors.Is(err, store.ErrInvalidConnectorType), errors.Is(err, store.ErrInvalidInput), errors.Is(err, store.ErrInvalidAgentVisibility): + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + default: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "agent operation failed"}) + } +} + +func decodeJSONWithField(r *http.Request, target any, field string) (bool, error) { + fields, err := decodeJSONWithFields(r, target) + if err != nil { + return false, err + } + return fields[field], nil +} + +func decodeJSONWithFields(r *http.Request, target any) (map[string]bool, error) { + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + return nil, err + } + if err := json.Unmarshal(body, target); err != nil { + return nil, err + } + fields := make(map[string]bool, len(raw)) + for field := range raw { + fields[field] = true + } + return fields, nil +} + +func actorIDFromRequest(r *http.Request) string { + if userID := auth.UserIDFromContext(r.Context()); userID != "" { + return userID + } + return store.DefaultDevFixtureIDs().UserID +} + +func requestContextForRBAC(r *http.Request) context.Context { + if auth.UserIDFromContext(r.Context()) != "" { + return r.Context() + } + return auth.WithUserID(r.Context(), store.DefaultDevFixtureIDs().UserID) +} + +func requireWorkspaceOwnerOrAdmin(r *http.Request, runtimeStore RuntimeStore, workspaceID string) error { + return auth.RequireWorkspaceRole(requestContextForRBAC(r), runtimeStore, workspaceID, "owner", "admin") +} + +// requireWorkspaceMember gates read endpoints scoped to a workspace. +// Returns ErrNotMember when the caller is not an active member of the +// workspace. +func requireWorkspaceMember(r *http.Request, runtimeStore RuntimeStore, workspaceID string) error { + return auth.RequireWorkspaceRole(requestContextForRBAC(r), runtimeStore, workspaceID, "owner", "admin", "member", "viewer") +} + +// requireWorkspaceMemberNotViewer gates write endpoints that any +// non-viewer member of the workspace can perform — creating a +// conversation, triggering a run, editing one's own conversation +// title, etc. viewer is read-only. +func requireWorkspaceMemberNotViewer(r *http.Request, runtimeStore RuntimeStore, workspaceID string) error { + return auth.RequireWorkspaceRole(requestContextForRBAC(r), runtimeStore, workspaceID, "owner", "admin", "member") +} + +// gateWorkspaceMember wraps a handler whose URL is +// /workspaces/{workspaceID}/... and rejects callers that aren't an +// active member. Used by sandbox admin endpoints whose handlers don't +// carry runtimeStore — wrapping at register-time avoids polluting +// sandboxAdminDeps. +// +// Returns 503 when runtimeStore is nil (local-mode server without +// DB) so the response surface matches the other DB-backed endpoints +// instead of silently bypassing RBAC. +func gateWorkspaceMember(runtimeStore RuntimeStore, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + next(w, r) + } +} + +// gateWorkspaceOwnerOrAdmin gates sandbox kill / rebuild on owner+admin +// only. Mid-run kill interrupts an Agent task in flight. +func gateWorkspaceOwnerOrAdmin(runtimeStore RuntimeStore, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + next(w, r) + } +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/server/internal/dev/routes_me.go b/server/internal/dev/routes_me.go new file mode 100644 index 0000000..bca0869 --- /dev/null +++ b/server/internal/dev/routes_me.go @@ -0,0 +1,121 @@ +package dev + +import ( + "net/http" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/auth" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +// searchUsers backs the platform-wide user picker. Returns at most 20 +// users matching q substring, optionally hiding users already in a +// workspace. RBAC: any authenticated user (add-member action still +// goes through workspace owner/admin gate). +// searchUsers looks up users by keyword. +// +// @Summary Search users +// @Description Searches directory users by name, email, or handle. Used for member invite pickers. +// @Tags me +// @ID searchDevUsers +// @Produce json +// @Param q query string false "Search keyword" +// @Success 200 {object} map[string]interface{} "User list" +// @Failure 401 {object} map[string]string "Caller is not authenticated" +// @Router /api/v1/users/search [get] +func searchUsers(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + q := strings.TrimSpace(r.URL.Query().Get("q")) + if q == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "q is required"}) + return + } + excludeWS := strings.TrimSpace(r.URL.Query().Get("exclude_workspace")) + // Silently ignore garbage UUIDs — the store treats unparseable + // input as "no filter" and the picker is read-only. + if excludeWS != "" && !isUUID(excludeWS) { + excludeWS = "" + } + + items, err := runtimeStore.SearchUsers(r.Context(), store.SearchUsersInput{ + Query: q, + ExcludeWorkspaceID: excludeWS, + Limit: 20, + }) + if err != nil { + writeReadError(w, err, "failed to search users") + return + } + + // Exclude the caller from results so the picker never offers + // "add yourself". + selfID := strings.TrimSpace(auth.UserIDFromContext(r.Context())) + out := make([]map[string]any, 0, len(items)) + for _, it := range items { + if selfID != "" && it.ID == selfID { + continue + } + out = append(out, map[string]any{ + "id": it.ID, + "email": it.Email, + "name": it.Name, + "avatar_url": it.AvatarURL, + "status": it.Status, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"items": out}) + } +} + +// listMyWorkspaces returns the workspaces the authenticated caller belongs to. +// Platform admins (auth.IsPlatformAdmin) get the full list of active +// workspaces with role=owner so they can drop into any tenant. +// listMyWorkspaces returns workspaces the caller belongs to. +// +// @Summary List my workspaces +// @Description Returns workspaces the caller is a member of. +// @Tags me +// @ID listDevMyWorkspaces +// @Produce json +// @Success 200 {object} map[string]interface{} "Workspace list" +// @Failure 401 {object} map[string]string "Caller is not authenticated" +// @Router /api/v1/me/workspaces [get] +func listMyWorkspaces(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + userID := strings.TrimSpace(auth.UserIDFromContext(r.Context())) + if userID == "" { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "authenticated user missing from request context"}) + return + } + if !isUUID(userID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id must be a valid uuid"}) + return + } + limit := parseLimit(r, 50) + var ( + workspaces []store.UserWorkspaceRead + err error + ) + if auth.IsPlatformAdmin(userID) { + workspaces, err = runtimeStore.ListAllActiveWorkspaces(r.Context(), limit) + } else { + workspaces, err = runtimeStore.ListUserWorkspaces(r.Context(), userID, limit) + } + if err != nil { + writeReadError(w, err, "failed to list user workspaces") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "user_id": userID, + "workspaces": workspaces, + }) + } +} diff --git a/server/internal/dev/routes_models.go b/server/internal/dev/routes_models.go new file mode 100644 index 0000000..0140523 --- /dev/null +++ b/server/internal/dev/routes_models.go @@ -0,0 +1,586 @@ +package dev + +import ( + "encoding/json" + "errors" + "fmt" + "io" + + "net/http" + "os" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/secrets" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" + "github.com/go-chi/chi/v5" +) + +// createModelBody is the request body for POST /models in the new +// shared catalog. Provider info is inlined (no more model_providers +// table). Credential binding is one-of: +// - credential_mode="inline_secret" + secret_id → shared credential +// - credential_mode="credential_ref" + credential_kind_code → per-user +// +// Capabilities/limits are accepted as optional top-level convenience +// fields and folded into config server-side. +type createModelBody struct { + Name string `json:"name"` + ProviderType string `json:"provider_type"` + Adapter string `json:"adapter"` + BaseURL string `json:"base_url"` + ModelKey string `json:"model_key"` + CredentialMode string `json:"credential_mode"` + SecretID string `json:"secret_id"` + CredentialKindCode string `json:"credential_kind_code"` + Capabilities map[string]any `json:"capabilities"` + Limits map[string]any `json:"limits"` + Config map[string]any `json:"config"` +} + +// updateModelBody is the request body for PATCH /models/{id}. +// CredentialMode / ProviderType / Adapter are NOT editable here — to +// change them, create a new model. +type updateModelBody struct { + Name string `json:"name"` + ModelKey string `json:"model_key"` + BaseURL string `json:"base_url"` + SecretID string `json:"secret_id"` + CredentialKindCode string `json:"credential_kind_code"` + Capabilities map[string]any `json:"capabilities"` + Limits map[string]any `json:"limits"` + Config map[string]any `json:"config"` +} + +// foldModelConfig merges capabilities / limits into the config bag. Empty +// inputs are skipped so a caller that already nested them under config (or +// omitted them) is not clobbered with empty objects. +func foldModelConfig(config, capabilities, limits map[string]any) map[string]any { + merged := map[string]any{} + for k, v := range config { + merged[k] = v + } + if len(capabilities) > 0 { + merged["capabilities"] = capabilities + } + if len(limits) > 0 { + merged["limits"] = limits + } + return merged +} + +// ============================================================ +// createModel creates a model row in a workspace. +// +// @Summary Create a model in a workspace +// @Description Creates a new model row bound to a provider and credential. Owner/admin only. +// @Tags models +// @ID createDevModel +// @Accept json +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param body body createModelBody true "Model create payload" +// @Success 201 {object} map[string]interface{} "Created model" +// @Failure 400 {object} map[string]string "Invalid body or UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Router /api/v1/workspaces/{workspaceID}/models [post] +func createModel(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed model registry is disabled"}) + return + } + // Model catalog is org-global; URL workspaceID is only used + // for RBAC. The created model is NOT scoped to it. + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + var req createModelBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + mode := strings.TrimSpace(req.CredentialMode) + if mode == "" { + mode = "inline_secret" + } + model, err := runtimeStore.CreateModel(r.Context(), store.CreateModelInput{ + Name: req.Name, + ProviderType: req.ProviderType, + Adapter: req.Adapter, + BaseURL: req.BaseURL, + ModelKey: req.ModelKey, + CredentialMode: mode, + SecretID: req.SecretID, + CredentialKindCode: req.CredentialKindCode, + Config: foldModelConfig(req.Config, req.Capabilities, req.Limits), + CreatedBy: actorIDFromRequest(r), + }) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create model"}) + return + } + writeJSON(w, http.StatusCreated, model) + } +} + +// disableModel marks a model row as disabled. +// +// @Summary Disable a model +// @Description Marks the model as disabled. Owner/admin only. Disabled models are hidden from selectors. +// @Tags models +// @ID disableDevModel +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param modelID path string true "Model UUID" +// @Success 200 {object} map[string]interface{} "Disabled model" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 404 {object} map[string]string "Model not found" +// @Router /api/v1/workspaces/{workspaceID}/models/{modelID}/disable [post] +func disableModel(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed model registry is disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + modelID := strings.TrimSpace(chi.URLParam(r, "modelID")) + if !isUUID(modelID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "model_id must be a valid uuid"}) + return + } + model, err := runtimeStore.DisableModel(r.Context(), workspaceID, modelID) + if err != nil { + if errors.Is(err, store.ErrUnknownModel) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to disable model"}) + return + } + writeJSON(w, http.StatusOK, model) + } +} + +// updateModel applies a partial update to a model row. +// +// @Summary Update a model +// @Description Partially updates a model's mutable fields. Owner/admin only. +// @Tags models +// @ID updateDevModel +// @Accept json +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param modelID path string true "Model UUID" +// @Param body body updateModelBody true "Model update payload" +// @Success 200 {object} map[string]interface{} "Updated model" +// @Failure 400 {object} map[string]string "Invalid body or UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 404 {object} map[string]string "Model not found" +// @Router /api/v1/workspaces/{workspaceID}/models/{modelID} [patch] +func updateModel(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed model registry is disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + modelID := strings.TrimSpace(chi.URLParam(r, "modelID")) + if !isUUID(modelID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "model_id must be a valid uuid"}) + return + } + var req updateModelBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + if strings.TrimSpace(req.Name) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) + return + } + if strings.TrimSpace(req.ModelKey) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "model_key is required"}) + return + } + model, err := runtimeStore.UpdateModel(r.Context(), store.UpdateModelInput{ + ModelID: modelID, + Name: req.Name, + ModelKey: req.ModelKey, + BaseURL: req.BaseURL, + SecretID: req.SecretID, + CredentialKindCode: req.CredentialKindCode, + Config: foldModelConfig(req.Config, req.Capabilities, req.Limits), + }) + if err != nil { + if errors.Is(err, store.ErrUnknownModel) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to update model"}) + return + } + writeJSON(w, http.StatusOK, model) + } +} + +// testModelHTTPClient is overridable by tests so the unit tests can +// point the connectivity check at a httptest.Server instead of +// reaching the real upstream. +var testModelHTTPClient = &http.Client{Timeout: 15 * time.Second} + +func isOpenAIChatCompletionsAdapter(adapter string) bool { + switch strings.TrimSpace(adapter) { + case "openai", "openai_compatible", "openai-compatible", "@ai-sdk/openai", "@ai-sdk/openai-compatible": + return true + default: + return false + } +} + +func isAnthropicMessagesAdapter(adapter string) bool { + switch strings.TrimSpace(adapter) { + case "anthropic", "anthropic_compatible", "anthropic-compatible", "@ai-sdk/anthropic": + return true + default: + return false + } +} + +func anthropicMessagesURL(baseURL string) string { + base := strings.TrimRight(strings.TrimSpace(baseURL), "/") + switch { + case strings.HasSuffix(base, "/v1/messages"), strings.HasSuffix(base, "/messages"): + return base + case strings.HasSuffix(base, "/v1"): + return base + "/messages" + default: + return base + "/v1/messages" + } +} + +type connectivityTestResponse struct { + Supported bool `json:"supported"` + Success bool `json:"success"` + LatencyMS int64 `json:"latency_ms"` + Status int `json:"http_status,omitempty"` + Error string `json:"error,omitempty"` + Sample string `json:"sample,omitempty"` +} + +// testModelConnectivity sends a minimal request to the upstream +// provider so the admin can verify base_url + api_key + custom headers +// + model_key without driving a full Agent Run. OpenAI-shaped adapters +// use chat-completions; Anthropic-shaped use Messages. Other protocols +// return supported=false. +// testModelConnectivity issues a live probe against a model row's credentials. +// +// @Summary Test a model's connectivity +// @Description Runs a live probe against the model's configured provider and credentials. Owner/admin only. +// @Tags models +// @ID testDevModelConnectivity +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param modelID path string true "Model UUID" +// @Success 200 {object} map[string]interface{} "Probe result" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 404 {object} map[string]string "Model not found" +// @Router /api/v1/workspaces/{workspaceID}/models/{modelID}/test [post] +func testModelConnectivity(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed model registry is disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + modelID := strings.TrimSpace(chi.URLParam(r, "modelID")) + if !isUUID(modelID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "model_id must be a valid uuid"}) + return + } + + // ResolveModelRuntimeForUser handles both modes in one shot. + // For credential_ref mode passing "" returns ErrModelDisabled, + // which we surface as supported=false below. + callerUserID := actorIDFromRequest(r) + if callerUserID == "" { + callerUserID = "" + } + mr, err := runtimeStore.ResolveModelRuntimeForUser(r.Context(), modelID, callerUserID) + if err != nil { + if errors.Is(err, store.ErrUnknownModel) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + if errors.Is(err, store.ErrModelDisabled) { + writeJSON(w, http.StatusOK, connectivityTestResponse{ + Supported: true, + Success: false, + Error: err.Error(), + }) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to resolve model"}) + return + } + + isOpenAICompatible := isOpenAIChatCompletionsAdapter(mr.Adapter) + isAnthropicCompatible := isAnthropicMessagesAdapter(mr.Adapter) + + if !isOpenAICompatible && !isAnthropicCompatible { + writeJSON(w, http.StatusOK, connectivityTestResponse{ + Supported: false, + Error: "connectivity test only supports OpenAI chat-completions and Anthropic messages compatible providers", + }) + return + } + + // Pick the encrypted payload: + // inline_secret → fetched via secret_id below. + // credential_ref → filled from the caller's user_credentials. + var encryptedPayload []byte + if mr.CredentialMode == "credential_ref" { + encryptedPayload = mr.EncryptedPayload + } else { + if mr.SecretID == "" { + writeJSON(w, http.StatusOK, connectivityTestResponse{ + Supported: true, + Success: false, + Error: "no API key bound to this model", + }) + return + } + sp, err := runtimeStore.GetSecretPayload(r.Context(), workspaceID, mr.SecretID) + if err != nil { + writeJSON(w, http.StatusOK, connectivityTestResponse{ + Supported: true, + Success: false, + Error: fmt.Sprintf("failed to fetch secret: %v", err), + }) + return + } + encryptedPayload = sp.EncryptedPayload + } + + secretService, err := secrets.New(os.Getenv("PARSAR_MASTER_KEY")) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "secrets service unavailable: " + err.Error()}) + return + } + payload, err := secretService.Decrypt(encryptedPayload) + if err != nil { + writeJSON(w, http.StatusOK, connectivityTestResponse{ + Supported: true, + Success: false, + Error: "failed to decrypt credential: " + err.Error(), + }) + return + } + // Two payload shapes coexist: + // `secrets` rows (inline_secret) carry {api_key: "..."} + // `user_credentials` rows (credential_ref) carry {value: "..."} + // Both encode an upstream-provider API key; accept either. + apiKey, _ := payload["api_key"].(string) + if strings.TrimSpace(apiKey) == "" { + if v, _ := payload["value"].(string); strings.TrimSpace(v) != "" { + apiKey = v + } + } + if strings.TrimSpace(apiKey) == "" { + writeJSON(w, http.StatusOK, connectivityTestResponse{ + Supported: true, + Success: false, + Error: "credential payload missing api_key / value field", + }) + return + } + + // Build a minimal protocol-shaped request. + url := "" + body := map[string]any{} + if isAnthropicCompatible { + url = anthropicMessagesURL(mr.BaseURL) + body = map[string]any{ + "model": mr.ModelKey, + "messages": []map[string]any{{"role": "user", "content": "ping"}}, + "max_tokens": 16, + } + } else { + url = strings.TrimRight(mr.BaseURL, "/") + "/chat/completions" + body = map[string]any{ + "model": mr.ModelKey, + "messages": []map[string]any{{"role": "user", "content": "ping"}}, + "max_tokens": 16, + } + } + bodyBytes, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, url, strings.NewReader(string(bodyBytes))) + if err != nil { + writeJSON(w, http.StatusOK, connectivityTestResponse{ + Supported: true, + Success: false, + Error: "failed to build request: " + err.Error(), + }) + return + } + req.Header.Set("Content-Type", "application/json") + if isAnthropicCompatible { + req.Header.Set("x-api-key", apiKey) + req.Header.Set("anthropic-version", "2023-06-01") + } else { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + // Provider-level custom headers (e.g. X-Sub-Module for an internal gateway). + if hdrs, ok := mr.ProviderConfig["headers"].(map[string]any); ok { + for k, v := range hdrs { + if s, ok := v.(string); ok { + req.Header.Set(k, s) + } + } + } + + start := time.Now() + resp, err := testModelHTTPClient.Do(req) + latency := time.Since(start).Milliseconds() + if err != nil { + writeJSON(w, http.StatusOK, connectivityTestResponse{ + Supported: true, + Success: false, + LatencyMS: latency, + Error: "request failed: " + err.Error(), + }) + return + } + defer resp.Body.Close() + respBytes, _ := io.ReadAll(resp.Body) + + var parsed map[string]any + _ = json.Unmarshal(respBytes, &parsed) + + // HTTP 200 != business success — many gateways respond 200 with + // an `error` object inside the body. Treat non-2xx OR + // `error` field in body as failure. + if resp.StatusCode < 200 || resp.StatusCode >= 300 || parsed["error"] != nil { + msg := strings.TrimSpace(string(respBytes)) + if eo, ok := parsed["error"].(map[string]any); ok { + if m, ok := eo["message"].(string); ok { + msg = m + } + } + if len(msg) > 500 { + msg = msg[:500] + "…" + } + writeJSON(w, http.StatusOK, connectivityTestResponse{ + Supported: true, + Success: false, + LatencyMS: latency, + Status: resp.StatusCode, + Error: msg, + }) + return + } + + // Pull a sample first message content for the UI to display. + sample := "" + if isAnthropicCompatible { + if content, ok := parsed["content"].([]any); ok { + for _, part := range content { + if p, ok := part.(map[string]any); ok { + if text, ok := p["text"].(string); ok && strings.TrimSpace(text) != "" { + sample = strings.TrimSpace(text) + break + } + } + } + } else if content, ok := parsed["content"].(string); ok { + sample = strings.TrimSpace(content) + } + } else if choices, ok := parsed["choices"].([]any); ok && len(choices) > 0 { + if first, ok := choices[0].(map[string]any); ok { + if msg, ok := first["message"].(map[string]any); ok { + if c, ok := msg["content"].(string); ok { + sample = strings.TrimSpace(c) + } + } + } + } + if len(sample) > 200 { + sample = sample[:200] + "…" + } + writeJSON(w, http.StatusOK, connectivityTestResponse{ + Supported: true, + Success: true, + LatencyMS: latency, + Status: resp.StatusCode, + Sample: sample, + }) + } +} + +// listModels lists model rows for a workspace. +// +// @Summary List workspace models +// @Description Returns model rows for the workspace. Caller must be a workspace member. +// @Tags models +// @ID listDevModels +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Success 200 {object} map[string]interface{} "Model list" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller is not a workspace member" +// @Router /api/v1/workspaces/{workspaceID}/models [get] +func listModels(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed model registry is disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + models, err := runtimeStore.ListModels(r.Context(), workspaceID, parseLimit(r, 100)) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list models"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"models": models}) + } +} diff --git a/server/internal/dev/routes_runs.go b/server/internal/dev/routes_runs.go new file mode 100644 index 0000000..3f883f7 --- /dev/null +++ b/server/internal/dev/routes_runs.go @@ -0,0 +1,203 @@ +package dev + +import ( + "encoding/json" + "errors" + "io" + + "net/http" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/auth" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" + "github.com/go-chi/chi/v5" +) + +type requeueAgentRunBody struct { + Reason string `json:"reason"` +} + +// requeueAgentRun re-schedules a failed or stalled agent run. +// +// @Summary Re-queue an agent run +// @Description Re-schedules a failed or stalled agent run. Owner/admin only. +// @Tags agent-runs +// @ID requeueDevAgentRun +// @Accept json +// @Produce json +// @Param runID path string true "Agent run UUID" +// @Param body body requeueAgentRunBody true "Requeue payload" +// @Success 200 {object} map[string]interface{} "Requeued run" +// @Failure 400 {object} map[string]string "Invalid body or UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 404 {object} map[string]string "Run not found" +// @Router /api/v1/agent-runs/{runID}/requeue [post] +func requeueAgentRun(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed run retry is disabled"}) + return + } + runID := strings.TrimSpace(chi.URLParam(r, "runID")) + if !isUUID(runID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "run_id must be a valid uuid"}) + return + } + + var req requeueAgentRunBody + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + } + run, err := runtimeStore.GetAgentRun(r.Context(), runID) + if err != nil { + writeReadError(w, err, "failed to get agent run") + return + } + if err := requireWorkspaceMemberNotViewer(r, runtimeStore, run.WorkspaceID); err != nil { + if errors.Is(err, auth.ErrNotMember) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"}) + return + } + writeRBACError(w, err) + return + } + reason := strings.TrimSpace(req.Reason) + if reason == "" { + reason = "manual_retry" + } + result, err := runtimeStore.RequeueFailedAgentRun(r.Context(), store.RequeueAgentRunInput{RunID: runID, Source: "dev_retry", Reason: reason}) + if err != nil { + switch { + case errors.Is(err, store.ErrUnknownAgentRun): + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + case errors.Is(err, store.ErrAgentRunNotCompletable): + writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) + default: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to requeue agent run"}) + } + return + } + if recorder, ok := runtimeStore.(runLifecycleEventRecorder); ok { + recordRunLifecycleEvent(recorder, runID, "run.requeued", map[string]any{"source": "dev_retry", "reason": reason, "previous_status": run.Status}, time.Now().UTC()) + } + writeJSON(w, http.StatusOK, result) + } +} + +// getAgentRun returns details for a single agent run. +// +// @Summary Get an agent run +// @Description Returns the full record for a single agent run. Caller must belong to the run's workspace. +// @Tags agent-runs +// @ID getDevAgentRun +// @Produce json +// @Param runID path string true "Agent run UUID" +// @Success 200 {object} map[string]interface{} "Agent run" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller lacks permission" +// @Failure 404 {object} map[string]string "Run not found" +// @Router /api/v1/agent-runs/{runID} [get] +func getAgentRun(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + runID := strings.TrimSpace(chi.URLParam(r, "runID")) + if !isUUID(runID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "run_id must be a valid uuid"}) + return + } + + run, err := runtimeStore.GetAgentRun(r.Context(), runID) + if err != nil { + writeReadError(w, err, "failed to get agent run") + return + } + // Load first to discover the parent workspace, then gate. + if err := requireWorkspaceMember(r, runtimeStore, run.WorkspaceID); err != nil { + writeRBACError(w, err) + return + } + writeJSON(w, http.StatusOK, run) + } +} + +// listWorkspaceAgentRuns lists agent runs within a workspace. +// +// @Summary List workspace agent runs +// @Description Returns agent-run rows for the workspace. Caller must be a workspace member. +// @Tags agent-runs +// @ID listDevWorkspaceAgentRuns +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Success 200 {object} map[string]interface{} "Agent run rows" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller is not a workspace member" +// @Router /api/v1/workspaces/{workspaceID}/agent-runs [get] +func listWorkspaceAgentRuns(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + + // `?status=` accepts a comma-separated list so the admin + // "In progress" tab can union {running,queued} in one round-trip. + // Empty values are stripped. The SQL `cardinality(...) = 0` + // branch handles the no-filter case. + statuses := parseStatusList(r.URL.Query().Get("status")) + limit := parseLimit(r, 100) + offset := parseOffset(r) + + result, err := runtimeStore.ListWorkspaceAgentRuns(r.Context(), workspaceID, statuses, limit, offset) + if err != nil { + writeReadError(w, err, "failed to list agent runs") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "workspace_id": workspaceID, + "statuses": statuses, + "agent_runs": result.Runs, + "total": result.Total, + "limit": limit, + "offset": offset, + }) + } +} + +// parseStatusList splits `?status=a,b,c` into a trimmed, non-empty +// list. Returns nil for "no filter" (empty query string or all blanks) +// so handler code can pass it straight through to the store layer. +func parseStatusList(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + s := strings.TrimSpace(p) + if s == "" { + continue + } + out = append(out, s) + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/server/internal/dev/routes_runtimes.go b/server/internal/dev/routes_runtimes.go new file mode 100644 index 0000000..7f9ca50 --- /dev/null +++ b/server/internal/dev/routes_runtimes.go @@ -0,0 +1,129 @@ +package dev + +import ( + "encoding/json" + "errors" + + "net/http" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" + "github.com/go-chi/chi/v5" +) + +// getAgentRuntimeBinding returns the runtime currently bound to +// this agent. Empty runtime_id means the user hasn't picked one +// yet — the dispatcher surfaces "Please bind a Runtime" when a run starts. +// getAgentRuntimeBinding returns the agent's current runtime binding. +// +// @Summary Get an agent's runtime binding +// @Description Returns the runtime/device currently bound to the agent. +// @Tags runtimes +// @ID getDevAgentRuntimeBinding +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param agentID path string true "Agent UUID" +// @Success 200 {object} map[string]interface{} "Runtime binding" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 404 {object} map[string]string "Agent not found" +// @Router /api/v1/workspaces/{workspaceID}/agents/{agentID}/runtime [get] +func getAgentRuntimeBinding(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed agent runtime binding is disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if !isUUID(agentID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) + return + } + if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + binding, err := runtimeStore.GetAgentRuntimeBinding(r.Context(), workspaceID, agentID) + if err != nil { + if errors.Is(err, store.ErrUnknownAgent) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read runtime binding"}) + return + } + writeJSON(w, http.StatusOK, binding) + } +} + +// setAgentRuntimeBinding writes (or clears) the runtime an +// agent runs on. RuntimeID="" is a valid clear request that +// turns the agent back into an unbound state. Tenant guard: only +// workspace owners / admins can change the binding. +// setAgentRuntimeBinding binds an agent to a runtime/device. +// +// @Summary Bind an agent to a runtime +// @Description Binds the agent to a runtime/device. Empty runtime_id clears the binding. Owner/admin only. +// @Tags runtimes +// @ID setDevAgentRuntimeBinding +// @Accept json +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param agentID path string true "Agent UUID" +// @Param body body map[string]interface{} true "Runtime binding payload" +// @Success 200 {object} map[string]interface{} "Runtime binding" +// @Failure 400 {object} map[string]string "Invalid body or UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 404 {object} map[string]string "Agent not found" +// @Router /api/v1/workspaces/{workspaceID}/agents/{agentID}/runtime [post] +func setAgentRuntimeBinding(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed agent runtime binding is disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + agentID := strings.TrimSpace(chi.URLParam(r, "agentID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if !isUUID(agentID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + var body struct { + RuntimeID string `json:"runtime_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + if v := strings.TrimSpace(body.RuntimeID); v != "" && !isUUID(v) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "runtime_id must be a valid uuid or empty"}) + return + } + binding, err := runtimeStore.SetAgentRuntime(r.Context(), store.SetAgentRuntimeInput{ + WorkspaceID: workspaceID, + AgentID: agentID, + RuntimeID: strings.TrimSpace(body.RuntimeID), + }) + if err != nil { + if errors.Is(err, store.ErrUnknownAgent) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to set runtime binding"}) + return + } + writeJSON(w, http.StatusOK, binding) + } +} diff --git a/server/internal/dev/routes_secrets.go b/server/internal/dev/routes_secrets.go new file mode 100644 index 0000000..577a3a8 --- /dev/null +++ b/server/internal/dev/routes_secrets.go @@ -0,0 +1,176 @@ +package dev + +import ( + "encoding/json" + "errors" + + "net/http" + "os" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/secrets" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" + "github.com/go-chi/chi/v5" +) + +type createSecretBody struct { + Name string `json:"name"` + Kind string `json:"kind"` + Provider string `json:"provider"` + AuthType string `json:"auth_type"` + Payload map[string]any `json:"payload"` +} + +// createSecret adds a secret entry to a workspace's vault. +// +// @Summary Create a workspace secret +// @Description Adds a secret entry to the workspace vault. Owner/admin only. +// @Tags secrets +// @ID createDevSecret +// @Accept json +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param body body createSecretBody true "Secret create payload" +// @Success 201 {object} map[string]interface{} "Created secret" +// @Failure 400 {object} map[string]string "Invalid body or UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Router /api/v1/workspaces/{workspaceID}/secrets [post] +func createSecret(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed secret vault is disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + var req createSecretBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Provider) == "" || strings.TrimSpace(req.AuthType) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name, provider, and auth_type are required"}) + return + } + serverMasterKey := os.Getenv("PARSAR_MASTER_KEY") + if serverMasterKey == "" { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "server has no PARSAR_MASTER_KEY configured; refusing to create a secret"}) + return + } + secretService, err := secrets.New(serverMasterKey) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + encrypted, err := secretService.Encrypt(req.Payload) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to encrypt secret"}) + return + } + secret, err := runtimeStore.CreateSecret(r.Context(), store.CreateSecretInput{ + WorkspaceID: workspaceID, + Name: req.Name, + Kind: req.Kind, + Provider: req.Provider, + AuthType: req.AuthType, + Payload: req.Payload, + Masked: secrets.MaskPayload(req.Payload), + }, encrypted) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create secret"}) + return + } + writeJSON(w, http.StatusCreated, secret) + } +} + +// listSecrets lists secret rows for a workspace. +// +// @Summary List workspace secrets +// @Description Returns secret rows for the workspace. Values are redacted. Owner/admin only. +// @Tags secrets +// @ID listDevSecrets +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Success 200 {object} map[string]interface{} "Secret list" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Router /api/v1/workspaces/{workspaceID}/secrets [get] +func listSecrets(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed secret vault is disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + limit := parseLimit(r, 100) + secrets, err := runtimeStore.ListSecrets(r.Context(), workspaceID, limit) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list secrets"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"secrets": secrets}) + } +} + +// disableSecret marks a secret row as disabled. +// +// @Summary Disable a secret +// @Description Marks the secret as disabled so agents can no longer bind to it. Owner/admin only. +// @Tags secrets +// @ID disableDevSecret +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param secretID path string true "Secret UUID" +// @Success 200 {object} map[string]interface{} "Disabled secret" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 404 {object} map[string]string "Secret not found" +// @Router /api/v1/workspaces/{workspaceID}/secrets/{secretID}/disable [post] +func disableSecret(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed secret vault is disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + secretID := strings.TrimSpace(chi.URLParam(r, "secretID")) + if !isUUID(secretID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "secret_id must be a valid uuid"}) + return + } + secret, err := runtimeStore.DisableSecret(r.Context(), workspaceID, secretID) + if err != nil { + if errors.Is(err, store.ErrUnknownSecret) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to disable secret"}) + return + } + writeJSON(w, http.StatusOK, secret) + } +} diff --git a/server/internal/dev/routes_workspace_members.go b/server/internal/dev/routes_workspace_members.go new file mode 100644 index 0000000..64d8a7e --- /dev/null +++ b/server/internal/dev/routes_workspace_members.go @@ -0,0 +1,760 @@ +package dev + +import ( + "encoding/json" + "errors" + "io" + + "net/http" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/auth" + authinvite "github.com/MiniMax-AI-Dev/parsar/server/internal/auth/invite" + authpassword "github.com/MiniMax-AI-Dev/parsar/server/internal/auth/password" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +// listWorkspaceMembers lists members of a workspace. +// +// @Summary List workspace members +// @Description Returns members of the workspace. Caller must be a workspace member. +// @Tags workspace-members +// @ID listDevWorkspaceMembers +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Success 200 {object} map[string]interface{} "Member list" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller is not a workspace member" +// @Router /api/v1/workspaces/{workspaceID}/members [get] +func listWorkspaceMembers(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + members, err := runtimeStore.ListWorkspaceMembers(r.Context(), workspaceID, parseLimit(r, 100)) + if err != nil { + writeReadError(w, err, "failed to list workspace members") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "workspace_id": workspaceID, + "members": members, + }) + } +} + +type addWorkspaceMemberRequest struct { + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` +} + +type addWorkspaceMemberResponse struct { + Member store.WorkspaceMemberRead `json:"member"` + UserCreated bool `json:"user_created"` +} + +// addWorkspaceMember adds a user to a workspace. +// +// @Summary Add a workspace member +// @Description Adds a user to the workspace with the given role. Owner/admin only. +// @Tags workspace-members +// @ID addDevWorkspaceMember +// @Accept json +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param body body addWorkspaceMemberRequest true "Member payload" +// @Success 201 {object} map[string]interface{} "Added member" +// @Failure 400 {object} map[string]string "Invalid body or UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 409 {object} map[string]string "User is already a member" +// @Router /api/v1/workspaces/{workspaceID}/members [post] +func addWorkspaceMember(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + var req addWorkspaceMemberRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) + return + } + req.Email = strings.TrimSpace(req.Email) + req.Name = strings.TrimSpace(req.Name) + req.Role = strings.TrimSpace(req.Role) + if req.Email == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "email is required"}) + return + } + if !store.IsValidMemberRole(req.Role) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "role must be one of owner|admin|member|viewer"}) + return + } + + result, err := runtimeStore.AddWorkspaceMember(r.Context(), store.AddWorkspaceMemberInput{ + WorkspaceID: workspaceID, + Email: req.Email, + Name: req.Name, + Role: req.Role, + Now: time.Now().UTC(), + }) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to add workspace member"}) + return + } + + writeJSON(w, http.StatusCreated, addWorkspaceMemberResponse{ + Member: result.Member, + UserCreated: result.UserCreated, + }) + } +} + +// ── Invitation handlers ───────────────────────────────────────── + +type createInvitationRequest struct { + Email string `json:"email"` + Name string `json:"name,omitempty"` + Role string `json:"role"` +} + +type createInvitationResponse struct { + InvitationID string `json:"invitation_id"` + InviteLink string `json:"invite_link"` + Email string `json:"email"` + Role string `json:"role"` + ExpiresAt string `json:"expires_at"` +} + +func createInvitation(runtimeStore RuntimeStore, cfg *routerConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + var req createInvitationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) + return + } + req.Email = strings.TrimSpace(req.Email) + req.Role = strings.TrimSpace(req.Role) + if req.Email == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "email is required"}) + return + } + if !store.IsValidMemberRole(req.Role) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "role must be one of owner|admin|member|viewer"}) + return + } + + token, err := cfg.inviteSigner.Sign(workspaceID, req.Email, req.Role, authinvite.MaxLifetime) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to sign invite token"}) + return + } + + now := time.Now().UTC() + expiresAt := now.Add(authinvite.MaxLifetime) + invID := uuid.New().String() + + callerID := auth.UserIDFromContext(r.Context()) + if callerID == "" { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing caller identity"}) + return + } + + if err := runtimeStore.CreateInvitation(r.Context(), store.CreateInvitationInput{ + ID: invID, + TokenHash: authinvite.TokenHash(token), + WorkspaceID: workspaceID, + Email: req.Email, + Role: req.Role, + InvitedBy: callerID, + ExpiresAt: expiresAt, + Now: now, + }); err != nil { + if strings.Contains(err.Error(), "uk_workspace_invitations_pending_email") { + writeJSON(w, http.StatusConflict, map[string]string{"error": "an invitation is already pending for this email"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create invitation"}) + return + } + + link := cfg.publicURL + "/invite/" + token + writeJSON(w, http.StatusCreated, createInvitationResponse{ + InvitationID: invID, + InviteLink: link, + Email: store.NormalizeEmail(req.Email), + Role: req.Role, + ExpiresAt: expiresAt.Format(time.RFC3339), + }) + } +} + +func listInvitations(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + + rows, err := runtimeStore.ListPendingInvitations(r.Context(), workspaceID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list invitations"}) + return + } + writeJSON(w, http.StatusOK, rows) + } +} + +func revokeInvitation(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + invitationID := strings.TrimSpace(chi.URLParam(r, "invitationID")) + if !isUUID(workspaceID) || !isUUID(invitationID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id and invitation_id must be valid uuids"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + + rows, err := runtimeStore.RevokeInvitation(r.Context(), workspaceID, invitationID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to revoke invitation"}) + return + } + if rows == 0 { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "invitation not found or already consumed"}) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +type inviteInfoRequest struct { + Token string `json:"token"` +} + +type inviteInfoResponse struct { + WorkspaceName string `json:"workspace_name"` + Email string `json:"email"` + Role string `json:"role"` +} + +func getInviteInfo(runtimeStore RuntimeStore, cfg *routerConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req inviteInfoRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) + return + } + token := strings.TrimSpace(req.Token) + if token == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "token is required"}) + return + } + + claims, err := cfg.inviteSigner.Verify(token) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid or expired token"}) + return + } + + inv, err := runtimeStore.GetInvitationByTokenHash(r.Context(), authinvite.TokenHash(token)) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "invitation not found"}) + return + } + if inv.AcceptedAt != nil || inv.RevokedAt != nil || inv.ExpiresAt.Before(time.Now()) { + writeJSON(w, http.StatusGone, map[string]string{"error": "invitation has already been used or revoked"}) + return + } + + writeJSON(w, http.StatusOK, inviteInfoResponse{ + WorkspaceName: inv.WorkspaceName, + Email: claims.Email, + Role: claims.Role, + }) + } +} + +type acceptInvitationRequest struct { + Token string `json:"token"` + Password string `json:"password"` +} + +type acceptInvitationResponse struct { + UserID string `json:"user_id"` + Email string `json:"email"` + WorkspaceID string `json:"workspace_id"` +} + +func acceptInvitation(runtimeStore RuntimeStore, cfg *routerConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req acceptInvitationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) + return + } + req.Token = strings.TrimSpace(req.Token) + if req.Token == "" || req.Password == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "token and password are required"}) + return + } + + claims, err := cfg.inviteSigner.Verify(req.Token) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid or expired token"}) + return + } + + if err := authpassword.Validate(req.Password); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + + hash, err := authpassword.Hash(req.Password) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to hash password"}) + return + } + + now := time.Now().UTC() + result, err := runtimeStore.AcceptInvitation(r.Context(), store.AcceptInvitationInput{ + TokenHash: authinvite.TokenHash(req.Token), + Email: claims.Email, + Role: claims.Role, + WorkspaceID: claims.WorkspaceID, + PasswordHash: hash, + Now: now, + }) + if err != nil { + if errors.Is(err, store.ErrInvitationInvalid) { + writeJSON(w, http.StatusGone, map[string]string{"error": "invitation is invalid, expired, or already used"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to accept invitation"}) + return + } + + sid, err := cfg.inviteSessions.Create(r.Context(), auth.CreateSessionInput{ + UserID: result.Member.UserID, + UserAgent: r.UserAgent(), + IP: r.RemoteAddr, + }) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create session"}) + return + } + auth.IssueCookie(w, sid, 0, cfg.inviteCookieSecure) + + writeJSON(w, http.StatusOK, acceptInvitationResponse{ + UserID: result.Member.UserID, + Email: result.Member.UserEmail, + WorkspaceID: result.Member.WorkspaceID, + }) + } +} + +type updateWorkspaceMemberRoleRequest struct { + Role string `json:"role"` +} + +// updateWorkspaceMemberRole changes a member's role. +// +// @Summary Update a workspace member's role +// @Description Changes a member's role within the workspace. Owner/admin only. +// @Tags workspace-members +// @ID updateDevWorkspaceMemberRole +// @Accept json +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param userID path string true "User UUID" +// @Param body body updateWorkspaceMemberRoleRequest true "Role update payload" +// @Success 200 {object} map[string]interface{} "Updated member" +// @Failure 400 {object} map[string]string "Invalid body or UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Router /api/v1/workspaces/{workspaceID}/members/{userID} [patch] +func updateWorkspaceMemberRole(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + userID := strings.TrimSpace(chi.URLParam(r, "userID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if !isUUID(userID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + var req updateWorkspaceMemberRoleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) + return + } + req.Role = strings.TrimSpace(req.Role) + if !store.IsValidMemberRole(req.Role) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "role must be one of owner|admin|member|viewer"}) + return + } + // Prevent changing the owner's role — ownership is transferred, not edited. + targetRole, err := runtimeStore.GetWorkspaceMemberRole(r.Context(), workspaceID, userID) + if err != nil { + if errors.Is(err, store.ErrNotMember) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "workspace member not found"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to look up member"}) + return + } + if targetRole == "owner" { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "cannot change the owner's role; use ownership transfer instead"}) + return + } + if req.Role == "owner" { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "cannot promote to owner; use ownership transfer instead"}) + return + } + member, err := runtimeStore.UpdateWorkspaceMemberRole(r.Context(), workspaceID, userID, req.Role, time.Now().UTC()) + if err != nil { + if errors.Is(err, store.ErrUnknownWorkspaceMember) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "workspace member not found"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to update workspace member role"}) + return + } + writeJSON(w, http.StatusOK, member) + } +} + +func removeWorkspaceMember(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + userID := strings.TrimSpace(chi.URLParam(r, "userID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if !isUUID(userID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + // Prevent removing the workspace owner. + targetRole, err := runtimeStore.GetWorkspaceMemberRole(r.Context(), workspaceID, userID) + if err != nil { + if errors.Is(err, store.ErrNotMember) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "workspace member not found"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to look up member"}) + return + } + if targetRole == "owner" { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "cannot remove the workspace owner"}) + return + } + result, err := runtimeStore.RemoveWorkspaceMember(r.Context(), workspaceID, userID, time.Now().UTC()) + if err != nil { + if errors.Is(err, store.ErrUnknownWorkspaceMember) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "workspace member not found"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to remove workspace member"}) + return + } + writeJSON(w, http.StatusOK, result) + } +} + +// ============================================================ +// Workspace self-service join request handlers +// +// POST /api/v1/workspaces/{wid}/join-requests submit request +// GET /api/v1/workspaces/{wid}/join-requests owner/admin list +// POST /api/v1/workspaces/{wid}/join-requests/{rid}/approve owner/admin approve +// POST /api/v1/workspaces/{wid}/join-requests/{rid}/reject owner/admin reject +// +// The WHERE status='pending' guard is atomic at the SQL layer; two admins racing +// will get ErrJoinRequestAlreadyHandled, which converts to a 409. +// ============================================================ + +type createJoinRequestRequest struct { + Reason string `json:"reason,omitempty"` +} + +func createJoinRequest(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + userID, ok := devActorID(w, r) + if !ok { + return + } + var body createJoinRequestRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err != io.EOF { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) + return + } + // Optional reason; optional field, length soft limit — prevents abuse without enforcing schema + if len(body.Reason) > 1000 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "reason must be 1000 characters or less"}) + return + } + result, err := runtimeStore.RequestJoinWorkspace(r.Context(), store.RequestJoinWorkspaceInput{ + WorkspaceID: workspaceID, + UserID: userID, + Reason: body.Reason, + Now: time.Now().UTC(), + }) + if err != nil { + if errors.Is(err, store.ErrUnknownWorkspace) { + // Covers two cases: does not exist / private and not open — always 404 to prevent enumeration + writeJSON(w, http.StatusNotFound, map[string]string{"error": "workspace not found or not open to join requests"}) + return + } + writeReadError(w, err, "failed to submit join request") + return + } + if result.Already { + writeJSON(w, http.StatusConflict, map[string]any{ + "error": "already a member or pending request exists", + "request": result.Request, + }) + return + } + writeJSON(w, http.StatusCreated, map[string]any{ + "request": result.Request, + }) + } +} + +// withdrawOwnJoinRequest — requester self-withdraws a pending request. Path +// carries no requestID: the requester has only one pending row, and +// (workspace_id, current_user_id) uniquely locates it, so the client doesn't +// need to hold a request id either. +func withdrawOwnJoinRequest(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + userID, ok := devActorID(w, r) + if !ok { + return + } + if err := runtimeStore.WithdrawOwnJoinRequest(r.Context(), workspaceID, userID, time.Now().UTC()); err != nil { + if errors.Is(err, store.ErrJoinRequestAlreadyHandled) { + // No pending row found: may have been approved/rejected by owner, or the user never applied + writeJSON(w, http.StatusConflict, map[string]string{"error": "no pending request to withdraw"}) + return + } + writeReadError(w, err, "failed to withdraw join request") + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +func listJoinRequests(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + rows, err := runtimeStore.ListPendingJoinRequests(r.Context(), workspaceID) + if err != nil { + writeReadError(w, err, "failed to list join requests") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "workspace_id": workspaceID, + "requests": rows, + }) + } +} + +func approveJoinRequest(runtimeStore RuntimeStore) http.HandlerFunc { + return reviewJoinRequestHandler(runtimeStore, true) +} + +func rejectJoinRequest(runtimeStore RuntimeStore) http.HandlerFunc { + return reviewJoinRequestHandler(runtimeStore, false) +} + +func reviewJoinRequestHandler(runtimeStore RuntimeStore, approve bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + requestID := strings.TrimSpace(chi.URLParam(r, "requestID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if !isUUID(requestID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "request_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + reviewerID, ok := devActorID(w, r) + if !ok { + return + } + input := store.ReviewJoinRequestInput{ + WorkspaceID: workspaceID, + RequestID: requestID, + ReviewerID: reviewerID, + Now: time.Now().UTC(), + } + var ( + member store.WorkspaceMemberRead + err error + ) + if approve { + member, err = runtimeStore.ApproveJoinRequest(r.Context(), input) + } else { + member, err = runtimeStore.RejectJoinRequest(r.Context(), input) + } + if err != nil { + if errors.Is(err, store.ErrJoinRequestAlreadyHandled) { + // Already handled by another admin / row is not in pending state + writeJSON(w, http.StatusConflict, map[string]string{"error": "join request already handled"}) + return + } + writeReadError(w, err, "failed to review join request") + return + } + writeJSON(w, http.StatusOK, member) + } +} + +// listDiscoverableWorkspaces — `GET /api/v1/me/discoverable-workspaces` +// Public workspaces the current user can request to join. +// +// Query params: +// - q : fuzzy search on workspace.name (case-insensitive); empty returns all +// - limit : default 50, clamped to [1, 100] +// - offset: default 0 +// +// Response carries a `total` field (post-filter count); the frontend uses it for +// "View all (N)" and pagination. +func listDiscoverableWorkspaces(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + userID := strings.TrimSpace(auth.UserIDFromContext(r.Context())) + if userID == "" { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "authenticated user missing from request context"}) + return + } + if !isUUID(userID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id must be a valid uuid"}) + return + } + q := strings.TrimSpace(r.URL.Query().Get("q")) + // Limit search-term length to prevent malicious overlong strings from slowing ILIKE index scans + if len(q) > 100 { + q = q[:100] + } + offset := parseOffset(r) + result, err := runtimeStore.ListDiscoverableWorkspaces(r.Context(), store.ListDiscoverableWorkspacesInput{ + UserID: userID, + Search: q, + Limit: parseLimit(r, 50), + Offset: offset, + }) + if err != nil { + writeReadError(w, err, "failed to list discoverable workspaces") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "user_id": userID, + "workspaces": result.Workspaces, + "total": result.Total, + "limit": parseLimit(r, 50), + "offset": offset, + }) + } +} diff --git a/server/internal/dev/routes_workspaces.go b/server/internal/dev/routes_workspaces.go new file mode 100644 index 0000000..3459164 --- /dev/null +++ b/server/internal/dev/routes_workspaces.go @@ -0,0 +1,761 @@ +package dev + +import ( + "encoding/json" + "errors" + "io" + + "net/http" + "sort" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" + "github.com/go-chi/chi/v5" +) + +// getWorkspaceSettings returns workspace-level settings. +// +// @Summary Get workspace settings +// @Description Returns workspace-level settings. Caller must be a workspace member. +// @Tags workspaces +// @ID getDevWorkspaceSettings +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Success 200 {object} map[string]interface{} "Workspace settings" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller is not a workspace member" +// @Router /api/v1/workspaces/{workspaceID}/settings [get] +func getWorkspaceSettings(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if runtimeStore == nil || !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + result, err := runtimeStore.GetWorkspaceSettings(r.Context(), workspaceID) + if err != nil { + writeStoreAgentError(w, err) + return + } + writeJSON(w, http.StatusOK, result) + } +} + +// patchWorkspaceSettings applies a partial update to workspace settings. +// +// @Summary Update workspace settings +// @Description Partially updates workspace-level settings. Owner/admin only. +// @Tags workspaces +// @ID patchDevWorkspaceSettings +// @Accept json +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param body body map[string]interface{} true "Settings patch" +// @Success 200 {object} map[string]interface{} "Updated settings" +// @Failure 400 {object} map[string]string "Invalid body or UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Router /api/v1/workspaces/{workspaceID}/settings [patch] +func patchWorkspaceSettings(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if runtimeStore == nil || !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + if _, err := decodeJSONWithFields(r, &struct{}{}); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + result, err := runtimeStore.PatchWorkspaceSettings(r.Context(), workspaceID) + if err != nil { + writeStoreAgentError(w, err) + return + } + writeJSON(w, http.StatusOK, result) + } +} + +// ============================================================ +// Workspace-dimension IM connectors (feishu / slack / discord) +// ------------------------------------------------------------ +// Bound to the workspace, NOT to an agent: the same dimension as the +// /list a user picks an agent from after @-summoning the shared bot. +// Credentials live in the vault; the table stores only *_ref UUIDs + +// non-secret fields. RBAC is enforced by the route gate middleware +// (member for GET, owner/admin for PATCH), so the handlers trust the +// URL workspaceID. +// ============================================================ + +type updateWorkspaceSlackConnectorBody struct { + Enabled bool `json:"enabled"` + AppID string `json:"app_id"` + BotTokenRef string `json:"bot_token_ref"` + AppTokenRef string `json:"app_token_ref"` + SigningSecretRef string `json:"signing_secret_ref"` + EventMode string `json:"event_mode"` +} + +type updateWorkspaceDiscordConnectorBody struct { + Enabled bool `json:"enabled"` + AppID string `json:"app_id"` + BotTokenRef string `json:"bot_token_ref"` + PublicKeyRef string `json:"public_key_ref"` + Intents string `json:"intents"` +} + +type updateWorkspaceTeamsConnectorBody struct { + Enabled bool `json:"enabled"` + AppID string `json:"app_id"` + AppPasswordRef string `json:"app_password_ref"` + TenantID string `json:"tenant_id"` +} + +type updateWorkspaceFeishuConnectorBody struct { + Enabled bool `json:"enabled"` + AppID string `json:"app_id"` + AppSecretRef string `json:"app_secret_ref"` + VerificationTokenRef string `json:"verification_token_ref"` + EncryptKeyRef string `json:"encrypt_key_ref"` + BotOpenID string `json:"bot_open_id"` + EventMode string `json:"event_mode"` +} + +// listWorkspaceConnectors returns all platforms' connector rows for the +// workspace, decoded (config jsonb → map). Backs the admin panel's +// initial state. Never exposes secret plaintext (only *_ref UUIDs). +// listWorkspaceConnectors lists the workspace's configured connectors. +// +// @Summary List workspace connectors +// @Description Returns the workspace's configured connectors. Owner/admin only. +// @Tags workspaces +// @ID listDevWorkspaceConnectors +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Success 200 {object} map[string]interface{} "Connector list" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Router /api/v1/workspaces/{workspaceID}/connectors [get] +func listWorkspaceConnectors(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + connectors, err := runtimeStore.GetWorkspaceIMConnectors(r.Context(), workspaceID) + if err != nil { + writeStoreAgentError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"connectors": connectors}) + } +} + +// updateWorkspaceSlackConnector updates the workspace-level Slack connector. +// +// @Summary Update the workspace's Slack connector +// @Description Updates the workspace-level Slack connector credentials/config. Owner/admin only. +// @Tags workspaces +// @ID updateDevWorkspaceSlackConnector +// @Accept json +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param body body updateWorkspaceSlackConnectorBody true "Connector config" +// @Success 200 {object} map[string]interface{} "Updated connector" +// @Failure 400 {object} map[string]string "Invalid body or UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Router /api/v1/workspaces/{workspaceID}/connector/slack [patch] +func updateWorkspaceSlackConnector(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + var req updateWorkspaceSlackConnectorBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + change, err := runtimeStore.UpsertWorkspaceSlackConnector(r.Context(), store.UpsertWorkspaceSlackConnectorInput{ + WorkspaceID: workspaceID, + Enabled: req.Enabled, + AppID: req.AppID, + BotTokenRef: req.BotTokenRef, + AppTokenRef: req.AppTokenRef, + SigningSecretRef: req.SigningSecretRef, + EventMode: req.EventMode, + }, actorIDFromRequest(r)) + if err != nil { + switch { + case errors.Is(err, store.ErrSlackConnectorIncomplete): + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "slack_connector_incomplete", "detail": err.Error()}) + return + case errors.Is(err, store.ErrSlackAppIDInUse): + writeJSON(w, http.StatusConflict, map[string]string{"error": "slack_app_id_in_use", "detail": err.Error()}) + return + } + writeStoreAgentError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"connector": change}) + } +} + +// updateWorkspaceDiscordConnector updates the workspace-level Discord connector. +// +// @Summary Update the workspace's Discord connector +// @Description Updates the workspace-level Discord connector credentials/config. Owner/admin only. +// @Tags workspaces +// @ID updateDevWorkspaceDiscordConnector +// @Accept json +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param body body updateWorkspaceDiscordConnectorBody true "Connector config" +// @Success 200 {object} map[string]interface{} "Updated connector" +// @Failure 400 {object} map[string]string "Invalid body or UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Router /api/v1/workspaces/{workspaceID}/connector/discord [patch] +func updateWorkspaceDiscordConnector(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + var req updateWorkspaceDiscordConnectorBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + change, err := runtimeStore.UpsertWorkspaceDiscordConnector(r.Context(), store.UpsertWorkspaceDiscordConnectorInput{ + WorkspaceID: workspaceID, + Enabled: req.Enabled, + AppID: req.AppID, + BotTokenRef: req.BotTokenRef, + PublicKeyRef: req.PublicKeyRef, + Intents: req.Intents, + }, actorIDFromRequest(r)) + if err != nil { + switch { + case errors.Is(err, store.ErrDiscordConnectorIncomplete): + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "discord_connector_incomplete", "detail": err.Error()}) + return + case errors.Is(err, store.ErrDiscordAppIDInUse): + writeJSON(w, http.StatusConflict, map[string]string{"error": "discord_app_id_in_use", "detail": err.Error()}) + return + } + writeStoreAgentError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"connector": change}) + } +} + +// updateWorkspaceTeamsConnector updates the workspace-level Teams connector. +// +// @Summary Update the workspace's Microsoft Teams connector +// @Description Updates the workspace-level Teams connector credentials/config. Owner/admin only. +// @Tags workspaces +// @ID updateDevWorkspaceTeamsConnector +// @Accept json +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param body body updateWorkspaceTeamsConnectorBody true "Connector config" +// @Success 200 {object} map[string]interface{} "Updated connector" +// @Failure 400 {object} map[string]string "Invalid body or UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Router /api/v1/workspaces/{workspaceID}/connector/teams [patch] +func updateWorkspaceTeamsConnector(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + var req updateWorkspaceTeamsConnectorBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + change, err := runtimeStore.UpsertWorkspaceTeamsConnector(r.Context(), store.UpsertWorkspaceTeamsConnectorInput{ + WorkspaceID: workspaceID, + Enabled: req.Enabled, + AppID: req.AppID, + AppPasswordRef: req.AppPasswordRef, + TenantID: req.TenantID, + }, actorIDFromRequest(r)) + if err != nil { + switch { + case errors.Is(err, store.ErrTeamsConnectorIncomplete): + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "teams_connector_incomplete", "detail": err.Error()}) + return + case errors.Is(err, store.ErrTeamsAppIDInUse): + writeJSON(w, http.StatusConflict, map[string]string{"error": "teams_app_id_in_use", "detail": err.Error()}) + return + } + writeStoreAgentError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"connector": change}) + } +} + +// updateWorkspaceFeishuConnector updates the workspace-level Feishu connector. +// +// @Summary Update the workspace's Feishu connector +// @Description Updates the workspace-level Feishu connector credentials/config. Owner/admin only. +// @Tags workspaces +// @ID updateDevWorkspaceFeishuConnector +// @Accept json +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param body body updateWorkspaceFeishuConnectorBody true "Connector config" +// @Success 200 {object} map[string]interface{} "Updated connector" +// @Failure 400 {object} map[string]string "Invalid body or UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Router /api/v1/workspaces/{workspaceID}/connector/feishu [patch] +func updateWorkspaceFeishuConnector(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + var req updateWorkspaceFeishuConnectorBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + botOpenID, err := resolveFeishuBotOpenID(r.Context(), runtimeStore, workspaceID, req.AppID, req.AppSecretRef, req.BotOpenID) + if err != nil { + log.Bg().Warn("feishu bot open_id auto-resolve failed", "workspace_id", workspaceID, "app_id", req.AppID, "err", err) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "feishu_bot_open_id_resolve_failed", "detail": err.Error()}) + return + } + change, err := runtimeStore.UpsertWorkspaceFeishuConnector(r.Context(), store.UpsertWorkspaceFeishuConnectorInput{ + WorkspaceID: workspaceID, + Enabled: req.Enabled, + AppID: req.AppID, + AppSecretRef: req.AppSecretRef, + VerificationTokenRef: req.VerificationTokenRef, + EncryptKeyRef: req.EncryptKeyRef, + BotOpenID: botOpenID, + EventMode: req.EventMode, + }, actorIDFromRequest(r)) + if err != nil { + switch { + case errors.Is(err, store.ErrFeishuConnectorIncomplete): + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "feishu_connector_incomplete", "detail": err.Error()}) + return + case errors.Is(err, store.ErrFeishuAppIDInUse): + writeJSON(w, http.StatusConflict, map[string]string{"error": "feishu_app_id_in_use", "detail": err.Error()}) + return + } + writeStoreAgentError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"connector": change}) + } +} + +// listWorkspaceAuditRecords serves /workspaces/{workspaceID}/audit-records. +// It reads the unified audit_records table (5-category source taxonomy, +// jsonb payload). Optional query filters: source, event_type, +// target_type, target_id, actor_id. +// listWorkspaceAuditRecords lists audit-log entries for a workspace. +// +// @Summary List workspace audit records +// @Description Returns audit-log entries for the workspace. Owner/admin only. +// @Tags workspaces +// @ID listDevWorkspaceAuditRecords +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Success 200 {object} map[string]interface{} "Audit records" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Router /api/v1/workspaces/{workspaceID}/audit-records [get] +func listWorkspaceAuditRecords(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + + q := r.URL.Query() + filter := store.ListAuditRecordsFilter{ + WorkspaceID: workspaceID, + Source: strings.TrimSpace(q.Get("source")), + EventType: strings.TrimSpace(q.Get("event_type")), + ActorID: strings.TrimSpace(q.Get("actor_id")), + TargetType: strings.TrimSpace(q.Get("target_type")), + TargetID: strings.TrimSpace(q.Get("target_id")), + } + records, err := runtimeStore.ListAuditRecords(r.Context(), filter, parseLimit(r, 100)) + if err != nil { + writeReadError(w, err, "failed to list workspace audit records") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "workspace_id": workspaceID, + "source": filter.Source, + "event_type": filter.EventType, + "target_type": filter.TargetType, + "audit_records": records, + }) + } +} + +// listWorkspaceConnectorUsage aggregates connector types in use by +// agents in a workspace. There is no `connectors` table — the +// connector identity lives on each agent's `connector_type` field. +// listWorkspaceConnectorUsage returns connector usage rows. +// +// @Summary List workspace connector usage +// @Description Returns per-connector usage rows for the workspace. Owner/admin only. +// @Tags workspaces +// @ID listDevWorkspaceConnectorUsage +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Success 200 {object} map[string]interface{} "Connector usage rows" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Router /api/v1/workspaces/{workspaceID}/connector-usage [get] +func listWorkspaceConnectorUsage(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + + agents, err := runtimeStore.ListWorkspaceAgentsForAdmin(r.Context(), workspaceID) + if err != nil { + writeReadError(w, err, "failed to list workspace connectors") + return + } + + type connectorRow struct { + ConnectorType string `json:"connector_type"` + Label string `json:"label"` + Status string `json:"status"` + AgentCount int `json:"agent_count"` + AgentSlugs []string `json:"agent_slugs"` + } + bucket := map[string]*connectorRow{} + for _, a := range agents { + ct := strings.TrimSpace(a.ConnectorType) + if ct == "" { + ct = "unknown" + } + row, ok := bucket[ct] + if !ok { + row = &connectorRow{ + ConnectorType: ct, + Label: connectorLabel(ct), + Status: connectorStatus(ct), + AgentSlugs: []string{}, + } + bucket[ct] = row + } + row.AgentCount++ + row.AgentSlugs = append(row.AgentSlugs, a.Slug) + } + + out := make([]connectorRow, 0, len(bucket)) + for _, row := range bucket { + out = append(out, *row) + } + sort.Slice(out, func(i, j int) bool { + if out[i].AgentCount != out[j].AgentCount { + return out[i].AgentCount > out[j].AgentCount + } + return out[i].ConnectorType < out[j].ConnectorType + }) + + writeJSON(w, http.StatusOK, map[string]any{ + "workspace_id": workspaceID, + "connectors": out, + }) + } +} + +// connectorLabel maps the raw connector_type to a UI-friendly label. +func connectorLabel(t string) string { + switch t { + case "agent_daemon": + return "Agent Daemon" + case "http-agent", "http": + return "HTTP Agent" + default: + return t + } +} + +// connectorStatus is a coarse health hint. Per-run failures show up +// in Run Detail. Future connectors needing external setup can return +// "needs_config" / "offline". +func connectorStatus(t string) string { + switch t { + case "agent_daemon", "http-agent", "http": + return "ready" + default: + return "unknown" + } +} + +// listWorkspaceGateways returns a static registry of known gateway types. +// No DB schema — connectors don't have one either. +// listWorkspaceGateways lists inbound/outbound gateway rows. +// +// @Summary List workspace gateways +// @Description Returns gateway inbound/outbound rows for the workspace. Owner/admin only. +// @Tags workspaces +// @ID listDevWorkspaceGateways +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Success 200 {object} map[string]interface{} "Gateway rows" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Router /api/v1/workspaces/{workspaceID}/gateways [get] +func listWorkspaceGateways() http.HandlerFunc { + type gatewayRow struct { + Type string `json:"type"` + Label string `json:"label"` + Status string `json:"status"` + Phase string `json:"phase"` + Description string `json:"description"` + } + registry := []gatewayRow{ + { + Type: "dev", + Label: "Dev Gateway", + Status: "active", + Phase: "phase_1", + Description: "Built-in dev gateway/inbound entry point used by the devgateway tool and E2E tests.", + }, + { + Type: "feishu", + Label: "Feishu", + Status: "not_configured", + Phase: "phase_3", + Description: "Feishu group / thread gateway with real webhook signature + OAuth.", + }, + { + Type: "slack", + Label: "Slack", + Status: "not_configured", + Phase: "phase_3", + Description: "Slack channel + thread gateway.", + }, + { + Type: "web", + Label: "Web", + Status: "active", + Phase: "phase_1", + Description: "Built-in web entrypoint for conversations created from the admin UI.", + }, + } + + return func(w http.ResponseWriter, r *http.Request) { + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "workspace_id": workspaceID, + "gateways": registry, + }) + } +} + +type createWorkspaceRequest struct { + Name string `json:"name"` + Visibility string `json:"visibility,omitempty"` // "public" / "private"; empty → server defaults to "private" +} + +// createWorkspace creates a new workspace with the caller as owner. +// +// @Summary Create a workspace +// @Description Creates a new workspace and adds the caller as the initial owner. +// @Tags workspaces +// @ID createDevWorkspace +// @Accept json +// @Produce json +// @Param body body createWorkspaceRequest true "Workspace payload" +// @Success 201 {object} map[string]interface{} "Created workspace" +// @Failure 400 {object} map[string]string "Invalid body" +// @Failure 401 {object} map[string]string "Caller is not authenticated" +// @Router /api/v1/workspaces [post] +func createWorkspace(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + actorID, ok := devActorID(w, r) + if !ok { + return + } + var body createWorkspaceRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err != io.EOF { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) + return + } + result, err := runtimeStore.CreateWorkspace(r.Context(), store.CreateWorkspaceInput{ + Name: body.Name, + Visibility: strings.TrimSpace(body.Visibility), + CreatedBy: actorID, + Now: time.Now().UTC(), + }) + if err != nil { + writeReadError(w, err, "failed to create workspace") + return + } + writeJSON(w, http.StatusCreated, map[string]any{ + "workspace": result.Workspace, + "member": result.Member, + }) + } +} + +type updateWorkspaceRequest struct { + Name *string `json:"name,omitempty"` + Visibility *string `json:"visibility,omitempty"` // "public" / "private"; nil → unchanged +} + +// updateWorkspace applies a partial update to a workspace. +// +// @Summary Update a workspace +// @Description Partially updates a workspace's mutable fields. Owner/admin only. +// @Tags workspaces +// @ID updateDevWorkspace +// @Accept json +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param body body updateWorkspaceRequest true "Workspace update payload" +// @Success 200 {object} map[string]interface{} "Updated workspace" +// @Failure 400 {object} map[string]string "Invalid body or UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Router /api/v1/workspaces/{workspaceID} [patch] +func updateWorkspace(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + actorID, ok := devActorID(w, r) + if !ok { + return + } + var body updateWorkspaceRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err != io.EOF { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) + return + } + row, err := runtimeStore.UpdateWorkspace(r.Context(), store.UpdateWorkspaceInput{ + WorkspaceID: workspaceID, + Name: body.Name, + Visibility: body.Visibility, + ActorID: actorID, + Now: time.Now().UTC(), + }) + if err != nil { + writeReadError(w, err, "failed to update workspace") + return + } + writeJSON(w, http.StatusOK, row) + } +} + +// archiveWorkspace marks a workspace as archived. +// +// @Summary Archive a workspace +// @Description Marks the workspace as archived. Owner only. +// @Tags workspaces +// @ID archiveDevWorkspace +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Success 200 {object} map[string]interface{} "Archived workspace" +// @Failure 400 {object} map[string]string "Invalid UUID" +// @Failure 403 {object} map[string]string "Caller is not workspace owner" +// @Router /api/v1/workspaces/{workspaceID}/archive [post] +func archiveWorkspace(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceOwnerOrAdmin(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + actorID, ok := devActorID(w, r) + if !ok { + return + } + row, err := runtimeStore.ArchiveWorkspace(r.Context(), store.ArchiveWorkspaceInput{ + WorkspaceID: workspaceID, + ActorID: actorID, + Now: time.Now().UTC(), + }) + if err != nil { + writeReadError(w, err, "failed to archive workspace") + return + } + writeJSON(w, http.StatusOK, row) + } +} + +func listWorkspaceUsageLogs(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed read APIs are disabled"}) + return + } + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceID")) + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } + agentRunID := strings.TrimSpace(r.URL.Query().Get("agent_run_id")) + if agentRunID != "" && !isUUID(agentRunID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_run_id must be a valid uuid"}) + return + } + + usage, err := runtimeStore.ListWorkspaceUsageLogs(r.Context(), workspaceID, agentRunID, parseLimit(r, 100)) + if err != nil { + writeReadError(w, err, "failed to list workspace usage") + return + } + writeJSON(w, http.StatusOK, map[string]any{"workspace_id": workspaceID, "agent_run_id": agentRunID, "usage_logs": usage}) + } +}