mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-13 13:18:56 +02:00
* feat(server): remove generic LLM passthrough endpoints (MUL-4309) Remove the OpenAI-compatible passthrough HTTP handlers LLMChatCompletions / LLMChatCompletionsStream and their two routes (/api/llm/v1/chat/completions[/stream]) plus their tests. Exposing a generic LLM proxy backed by the deployment key let any logged-in user run arbitrary completions on our dime. pkg/llm and the MULTICA_LLM_* config are kept unchanged as the server-internal LLM entry point, so chat title generation (maybeGenerateChatTitleAsync -> h.LLM.GenerateText) continues to work untouched. Updated the handler.go and .env.example comments to reflect internal-only usage. Co-authored-by: multica-agent <github@multica.ai> * docs(server): fix stale comments referencing removed LLM passthrough handlers (MUL-4309) Address GPT-Boy review nits: three doc comments still described the deleted OpenAI-compatible HTTP proxy handlers / 503 behavior. Update pkg/llm/client.go (package doc + ErrNotConfigured) and the Handler.LLM field comment to describe the internal-only usage. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
1493 lines
66 KiB
Go
1493 lines
66 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/netip"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
chimw "github.com/go-chi/chi/v5/middleware"
|
|
"github.com/go-chi/cors"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/redis/go-redis/v9"
|
|
|
|
"github.com/multica-ai/multica/server/internal/analytics"
|
|
"github.com/multica-ai/multica/server/internal/auth"
|
|
"github.com/multica-ai/multica/server/internal/cloudruntime"
|
|
"github.com/multica-ai/multica/server/internal/daemonws"
|
|
"github.com/multica-ai/multica/server/internal/events"
|
|
"github.com/multica-ai/multica/server/internal/featureflags"
|
|
"github.com/multica-ai/multica/server/internal/handler"
|
|
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
|
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
|
|
composiointeg "github.com/multica-ai/multica/server/internal/integrations/composio"
|
|
"github.com/multica-ai/multica/server/internal/integrations/lark"
|
|
"github.com/multica-ai/multica/server/internal/integrations/slack"
|
|
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
|
|
"github.com/multica-ai/multica/server/internal/middleware"
|
|
"github.com/multica-ai/multica/server/internal/realtime"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
"github.com/multica-ai/multica/server/internal/storage"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
"github.com/multica-ai/multica/server/internal/util/secretbox"
|
|
composiosdk "github.com/multica-ai/multica/server/pkg/composio"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/featureflag"
|
|
)
|
|
|
|
var defaultOrigins = []string{
|
|
"http://localhost:3000", // Next.js dev
|
|
"http://localhost:5173", // electron-vite dev
|
|
"http://localhost:5174", // electron-vite dev (fallback port)
|
|
}
|
|
|
|
func allowedOrigins() []string {
|
|
raw := strings.TrimSpace(os.Getenv("CORS_ALLOWED_ORIGINS"))
|
|
if raw == "" {
|
|
raw = strings.TrimSpace(os.Getenv("FRONTEND_ORIGIN"))
|
|
}
|
|
if raw == "" {
|
|
return defaultOrigins
|
|
}
|
|
|
|
parts := strings.Split(raw, ",")
|
|
origins := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
origin := strings.TrimSpace(part)
|
|
if origin != "" {
|
|
origins = append(origins, origin)
|
|
}
|
|
}
|
|
if len(origins) == 0 {
|
|
return defaultOrigins
|
|
}
|
|
return origins
|
|
}
|
|
|
|
// appURLFromEnv resolves the user-facing web app URL. It prefers
|
|
// MULTICA_APP_URL and falls back to FRONTEND_ORIGIN, matching how the backend
|
|
// resolves the app URL elsewhere (handler.daemonSetupURLsFromEnv) and the CLI
|
|
// login flow (cmd/multica tryResolveAppURL). Empty when neither is set.
|
|
func appURLFromEnv() string {
|
|
if v := strings.TrimRight(strings.TrimSpace(os.Getenv("MULTICA_APP_URL")), "/"); v != "" {
|
|
return v
|
|
}
|
|
return strings.TrimRight(strings.TrimSpace(os.Getenv("FRONTEND_ORIGIN")), "/")
|
|
}
|
|
|
|
// parseTrustedProxies parses a comma-separated list of CIDR prefixes from the
|
|
// MULTICA_TRUSTED_PROXIES env var. Invalid entries are dropped with a single
|
|
// warn-line per entry rather than crashing the server — a typo in one CIDR
|
|
// shouldn't take the whole API down. Returns nil for empty input, which the
|
|
// rate limiter treats as "trust no proxy headers, use RemoteAddr only".
|
|
func parseTrustedProxies(raw string) []netip.Prefix {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
var out []netip.Prefix
|
|
for _, part := range strings.Split(raw, ",") {
|
|
s := strings.TrimSpace(part)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
p, err := netip.ParsePrefix(s)
|
|
if err != nil {
|
|
slog.Warn("MULTICA_TRUSTED_PROXIES: ignoring invalid CIDR",
|
|
"value", s, "error", err)
|
|
continue
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// NewRouter creates the fully-configured Chi router with all middleware and routes.
|
|
// rdb is optional: when non-nil the runtime local-skill request stores are
|
|
// swapped for Redis-backed implementations so multiple API nodes share the
|
|
// same pending queue (required for multi-node prod). This should be a request
|
|
// path Redis client, not the realtime relay's blocking read client. A nil rdb
|
|
// keeps the default in-memory stores which are fine for single-node dev and
|
|
// tests.
|
|
func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus, analyticsClient analytics.Client, rdb *redis.Client) chi.Router {
|
|
r, _ := NewRouterWithOptions(pool, hub, bus, analyticsClient, rdb, RouterOptions{})
|
|
return r
|
|
}
|
|
|
|
type RouterOptions struct {
|
|
HTTPMetrics *obsmetrics.HTTPMetrics
|
|
BusinessMetrics *obsmetrics.BusinessMetrics
|
|
DaemonHub *daemonws.Hub
|
|
DaemonWakeup service.TaskWakeupNotifier
|
|
FeatureFlags *featureflag.Service
|
|
// HeartbeatScheduler, when non-nil, replaces the default synchronous
|
|
// passthrough scheduler on the constructed Handler. main.go injects a
|
|
// BatchedHeartbeatScheduler here so the caller can also drive Run/Stop;
|
|
// tests leave this nil and get the legacy synchronous behavior.
|
|
HeartbeatScheduler handler.HeartbeatScheduler
|
|
}
|
|
|
|
// NewRouterWithOptions builds the fully-configured Chi router and
|
|
// returns the *handler.Handler it was constructed from. Callers that
|
|
// need to drive background lifecycle on services attached to the
|
|
// handler (e.g. starting the Lark inbound Hub under a long-running
|
|
// context, calling Wait on shutdown) use the returned handler;
|
|
// callers that only need the HTTP handler (tests, the simple
|
|
// NewRouter shim) discard the second value.
|
|
func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus, analyticsClient analytics.Client, rdb *redis.Client, opts RouterOptions) (chi.Router, *handler.Handler) {
|
|
queries := db.New(pool)
|
|
emailSvc := service.NewEmailService()
|
|
daemonHub := opts.DaemonHub
|
|
if daemonHub == nil {
|
|
daemonHub = daemonws.NewHub()
|
|
}
|
|
|
|
// Initialize storage with S3 as primary, fallback to local
|
|
var store storage.Storage
|
|
s3 := storage.NewS3StorageFromEnv()
|
|
if s3 != nil {
|
|
store = s3
|
|
} else {
|
|
local := storage.NewLocalStorageFromEnv()
|
|
if local != nil {
|
|
store = local
|
|
}
|
|
}
|
|
|
|
cfSigner := auth.NewCloudFrontSignerFromEnv()
|
|
origins := allowedOrigins()
|
|
|
|
signupConfig := handler.Config{
|
|
AllowSignup: os.Getenv("ALLOW_SIGNUP") != "false",
|
|
AllowedEmails: splitAndTrim(os.Getenv("ALLOWED_EMAILS")),
|
|
AllowedEmailDomains: splitAndTrim(os.Getenv("ALLOWED_EMAIL_DOMAINS")),
|
|
DisableWorkspaceCreation: os.Getenv("DISABLE_WORKSPACE_CREATION") == "true",
|
|
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MULTICA_PUBLIC_URL")), "/"),
|
|
TrustedProxies: parseTrustedProxies(os.Getenv("MULTICA_TRUSTED_PROXIES")),
|
|
CloudRuntimeFleetURL: cloudRuntimeFleetURLFromEnv(),
|
|
CloudRuntimeFleetTimeout: envDuration("MULTICA_CLOUD_FLEET_TIMEOUT", 35*time.Second),
|
|
AttachmentDownloadMode: os.Getenv("ATTACHMENT_DOWNLOAD_MODE"),
|
|
AttachmentDownloadURLTTL: envDuration("ATTACHMENT_DOWNLOAD_URL_TTL", 30*time.Minute),
|
|
AttachmentFrameAncestors: origins,
|
|
LLMAPIKey: strings.TrimSpace(os.Getenv("MULTICA_LLM_API_KEY")),
|
|
LLMBaseURL: strings.TrimSpace(os.Getenv("MULTICA_LLM_BASE_URL")),
|
|
LLMDefaultModel: strings.TrimSpace(os.Getenv("MULTICA_LLM_DEFAULT_MODEL")),
|
|
}
|
|
h := handler.New(queries, pool, hub, bus, emailSvc, store, cfSigner, analyticsClient, signupConfig, daemonHub)
|
|
h.Metrics = opts.BusinessMetrics
|
|
h.FeatureFlags = opts.FeatureFlags
|
|
h.TaskService.FeatureFlags = opts.FeatureFlags
|
|
h.TaskService.Metrics = opts.BusinessMetrics
|
|
h.IssueService.Metrics = opts.BusinessMetrics
|
|
if opts.BusinessMetrics != nil {
|
|
// Wire the BusinessMetrics receiver into the cloud runtime client
|
|
// so every outbound Fleet/Gateway request feeds the
|
|
// multica_cloudruntime_request_* histograms.
|
|
if client, ok := h.CloudRuntime.(*cloudruntime.Client); ok {
|
|
client.SetRecorder(opts.BusinessMetrics)
|
|
}
|
|
}
|
|
if opts.DaemonWakeup != nil {
|
|
h.TaskService.Wakeup = opts.DaemonWakeup
|
|
if notifier, ok := opts.DaemonWakeup.(handler.RuntimeProfileRefreshNotifier); ok {
|
|
h.DaemonProfileRefresh = notifier
|
|
}
|
|
}
|
|
if rdb != nil {
|
|
h.UpdateStore = handler.NewRedisUpdateStore(rdb)
|
|
h.ModelListStore = handler.NewRedisModelListStore(rdb)
|
|
h.LocalSkillListStore = handler.NewRedisLocalSkillListStore(rdb)
|
|
h.LocalSkillImportStore = handler.NewRedisLocalSkillImportStore(rdb)
|
|
h.LivenessStore = handler.NewRedisLivenessStore(rdb)
|
|
h.WebhookRateLimiter = handler.NewRedisWebhookRateLimiter(rdb, handler.DefaultWebhookRateLimit())
|
|
h.WebhookIPRateLimiter = handler.NewRedisWebhookIPRateLimiter(rdb, handler.DefaultWebhookIPRateLimit())
|
|
}
|
|
|
|
// Channel engine (MUL-3620): the platform-agnostic inbound runtime.
|
|
// Built UNCONDITIONALLY — it drives any channel.Channel, not just
|
|
// Feishu, so it must not depend on the Lark master key (a future
|
|
// Slack-only deployment has no Lark key). Platform adapters register a
|
|
// Factory + ResolverSet into it below; the Supervisor enumerates active
|
|
// installations across ALL channel types and routes each to its
|
|
// registered platform's Factory. Installations whose channel_type has no
|
|
// registered Factory are skipped by the Supervisor — either no platform is
|
|
// configured, or (Slack/B2) the platform drives ONE deployment-level
|
|
// connection of its own outside the per-installation supervisor. The Router
|
|
// is the single shared inbound handler injected into every Channel.
|
|
channelRegistry := channel.NewRegistry()
|
|
channelRouter := engine.NewRouter(h.IssueService, h.TaskService, queries, engine.RouterConfig{Logger: slog.Default()})
|
|
// Debounce the per-session run trigger so a burst of messages collapses
|
|
// into one agent run instead of one per message (MUL-2968).
|
|
channelRouter.EnableRunBatching(engine.DefaultChatRunBatchWindow)
|
|
h.ChannelRouter = channelRouter
|
|
h.ChannelSupervisor = engine.NewSupervisor(
|
|
lark.NewChannelInstallationStore(queries),
|
|
channelRegistry,
|
|
channelRouter.Handle,
|
|
engine.Config{},
|
|
)
|
|
|
|
// Lark integration. Only wired when MULTICA_LARK_SECRET_KEY is set:
|
|
// the InstallationService refuses to fall back to plaintext storage
|
|
// for app_secret, and the BindingTokenService cannot mint usable
|
|
// tokens without it either. When the key is absent the Lark
|
|
// handlers return 503 with a clear message; the rest of the server
|
|
// continues to start so self-host deployments that have not opted
|
|
// in to Lark are unaffected. Feishu registers its Factory + ResolverSet
|
|
// into the channel engine above.
|
|
if larkKey, err := secretbox.LoadKey("MULTICA_LARK_SECRET_KEY"); err == nil {
|
|
box, err := secretbox.New(larkKey)
|
|
if err != nil {
|
|
slog.Error("lark: secretbox.New failed; lark integration disabled", "error", err)
|
|
} else {
|
|
installSvc, err := lark.NewInstallationService(queries, box)
|
|
if err != nil {
|
|
slog.Error("lark: InstallationService init failed; lark integration disabled", "error", err)
|
|
} else {
|
|
h.LarkInstallations = installSvc
|
|
h.LarkBindingTokens = lark.NewBindingTokenService(queries, pool)
|
|
slog.Info("lark integration enabled")
|
|
|
|
// APIClient: wire the real Lark Open Platform HTTP client
|
|
// (IM v1 send/patch + binding-prompt + bot info). Setting
|
|
// MULTICA_LARK_SECRET_KEY is the operator's opt-in for
|
|
// the integration as a whole; we don't expose a separate
|
|
// "HTTP enabled" knob because the inbound dispatcher
|
|
// without outbound replies is not a useful production
|
|
// state, and CI / integration tests that want to avoid
|
|
// real Lark traffic can point MULTICA_LARK_HTTP_BASE_URL
|
|
// at a mock server.
|
|
//
|
|
// MULTICA_LARK_HTTP_BASE_URL is an OPTIONAL deployment-wide
|
|
// override. Normal operation leaves it empty: each call then
|
|
// resolves its open-platform host from the installation's
|
|
// region (open.feishu.cn vs open.larksuite.com), so one
|
|
// deployment serves both clouds. Set it only to force every
|
|
// installation onto one host — a proxy, a mock for tests, or
|
|
// a single-cloud staging setup.
|
|
larkClient := lark.NewHTTPAPIClient(lark.HTTPClientConfig{
|
|
BaseURL: strings.TrimSpace(os.Getenv("MULTICA_LARK_HTTP_BASE_URL")),
|
|
Logger: slog.Default(),
|
|
})
|
|
h.LarkAPIClient = larkClient
|
|
|
|
// Channel-backed store: routes the lark package's DB seams
|
|
// onto the channel_* tables (MUL-3515). Interface-wired
|
|
// consumers (patcher, typing indicator, dispatcher, hub,
|
|
// backfills) take it directly; the constructor-based services
|
|
// wrap *db.Queries internally, so they keep taking queries.
|
|
cs := lark.NewChannelStore(queries)
|
|
patcher := lark.NewPatcher(cs, installSvc, larkClient, lark.PatcherConfig{})
|
|
patcher.Register(bus)
|
|
|
|
// Typing indicator: shows a "processing" reaction on the user's
|
|
// message while the agent is working, then removes it before the
|
|
// reply is sent. Best-effort; failures are logged only.
|
|
typingIndicator := lark.NewTypingIndicatorManager(larkClient, installSvc, cs, slog.Default())
|
|
patcher.SetTypingIndicatorManager(typingIndicator)
|
|
|
|
// Inbound pipeline seams: lark_inbound_audit logger and the
|
|
// shared channel-agnostic chat-session service. They back the
|
|
// Feishu ResolverSet that the engine.Router runs through,
|
|
// sharing the same IssueService + TaskService that back HTTP, so
|
|
// /issue-created issues share counter, dup guard, project
|
|
// boundary, broadcast, analytics and agent-enqueue with the rest
|
|
// of the product. Feishu is just another consumer of the shared
|
|
// engine.ChatSession (channel_type-keyed); the Lark session
|
|
// titles preserve the pre-cutover wording.
|
|
auditLogger := lark.NewAuditLogger(queries)
|
|
feishuSession := engine.NewChatSession(queries, pool, channel.TypeFeishu, engine.SessionTitles{
|
|
Group: "Lark group chat",
|
|
Direct: "Lark direct message",
|
|
Fallback: "Lark chat",
|
|
})
|
|
|
|
// OutcomeReplier wires the outbound side: NeedsBinding /
|
|
// AgentOffline / AgentArchived / issue-created translate to a
|
|
// Lark-side reply card. Requires the real APIClient and the
|
|
// binding token service; otherwise it falls back to the noop
|
|
// replier (outcomes logged, not delivered). We only register
|
|
// it on the ResolverSet when it can actually deliver, so a
|
|
// pre-outbound deployment pays no reply-goroutine cost.
|
|
replier := lark.NewLarkOutcomeReplier(lark.OutcomeReplierConfig{
|
|
APIClient: larkClient,
|
|
BindingSvc: h.LarkBindingTokens,
|
|
Credentials: installSvc,
|
|
Queries: queries,
|
|
AppURL: appURLFromEnv(),
|
|
Logger: slog.Default(),
|
|
})
|
|
var resolverReplier lark.OutcomeReplier
|
|
if larkClient.IsConfigured() {
|
|
resolverReplier = replier
|
|
}
|
|
|
|
// Feishu adapter (MUL-3620): the WSLongConnConnector talks
|
|
// Lark's long-conn protocol over gorilla/websocket and wraps
|
|
// every read with a ctx-cancel watchdog so lease loss /
|
|
// shutdown breaks the blocking ReadMessage in bounded time —
|
|
// the invariant §4.4 leans on. If the endpoint fetcher fails
|
|
// to initialize (bad MULTICA_LARK_CALLBACK_BASE_URL or
|
|
// similar), buildLarkConnector logs and falls back to the
|
|
// NoopConnector so the lease / supervisor lifecycle still runs
|
|
// against real DB rows — inbound messages are silently dropped
|
|
// until the config is fixed, with the boot log labelling the
|
|
// mode "noop".
|
|
//
|
|
// Registering the Factory (connect/send) + ResolverSet
|
|
// (inbound pipeline seams) is all it takes to add the platform
|
|
// to the engine — no engine edit.
|
|
connector, connectorLabel := buildLarkConnector(installSvc, larkClient)
|
|
lark.RegisterFeishu(channelRegistry, lark.FeishuChannelDeps{
|
|
Connector: connector,
|
|
APIClient: larkClient,
|
|
Credentials: installSvc,
|
|
Logger: slog.Default(),
|
|
})
|
|
channelRouter.Register(channel.TypeFeishu, lark.NewFeishuResolverSet(
|
|
cs, feishuSession, auditLogger, resolverReplier, typingIndicator,
|
|
))
|
|
slog.Info("lark inbound pipeline wired", "connector", connectorLabel)
|
|
|
|
// One-shot union_id backfill for installations created
|
|
// before migration 112 added bot_union_id. Runs off the
|
|
// hot startup path so a slow Lark round-trip cannot block
|
|
// HTTP listener boot. New installs already write
|
|
// bot_union_id during the device-flow finalize, so this
|
|
// is bridge code — it will simply find no rows to update
|
|
// on a fresh deployment and exit. MUL-2671.
|
|
go lark.BackfillBotUnionIDs(context.Background(), cs, larkClient, installSvc, slog.Default())
|
|
|
|
// Upgrade repair for deployments that ran the whole
|
|
// integration against Lark international via the deployment-
|
|
// wide base-URL override before per-installation region
|
|
// existed: migration 116 backfilled their rows to 'feishu',
|
|
// so relabel them to 'lark' (their true cloud) before the
|
|
// operator clears the override. No-op on mainland / fresh
|
|
// deployments. Off the hot startup path like the union_id
|
|
// backfill. MUL-3083.
|
|
go lark.BackfillRegionFromLegacyOverride(context.Background(), cs,
|
|
strings.TrimSpace(os.Getenv("MULTICA_LARK_HTTP_BASE_URL")),
|
|
strings.TrimSpace(os.Getenv("MULTICA_LARK_CALLBACK_BASE_URL")),
|
|
slog.Default())
|
|
|
|
// Device-flow registration service: end-to-end install
|
|
// pipeline that talks to accounts.feishu.cn (RFC 8628)
|
|
// for the QR-scan handshake and then commits the
|
|
// resulting Bot credentials + the installer's
|
|
// lark_user_binding in one DB transaction. The optional
|
|
// MULTICA_LARK_REGISTRATION_DOMAIN / _LARK_DOMAIN env
|
|
// vars override the protocol hosts for staging / dev.
|
|
regCfg := lark.RegistrationConfig{
|
|
Domain: strings.TrimSpace(os.Getenv("MULTICA_LARK_REGISTRATION_DOMAIN")),
|
|
LarkDomain: strings.TrimSpace(os.Getenv("MULTICA_LARK_REGISTRATION_LARK_DOMAIN")),
|
|
}
|
|
regClient := lark.NewRegistrationClient(regCfg)
|
|
regSvc, rerr := lark.NewRegistrationService(
|
|
lark.RegistrationServiceConfig{Logger: slog.Default()},
|
|
regClient,
|
|
larkClient,
|
|
queries,
|
|
pool,
|
|
installSvc,
|
|
h.LarkBindingTokens,
|
|
)
|
|
if rerr != nil {
|
|
slog.Error("lark: RegistrationService init failed; install disabled", "error", rerr)
|
|
} else {
|
|
// Publish lark_installation:created at row-commit time so the
|
|
// connection badge refreshes on every workspace client, not just
|
|
// the tab that polls the install status to success.
|
|
regSvc.SetEventBus(bus)
|
|
h.LarkRegistration = regSvc
|
|
slog.Info("lark device-flow install enabled")
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
slog.Info("lark integration disabled (MULTICA_LARK_SECRET_KEY not set)")
|
|
}
|
|
|
|
// Slack integration. Multi-tenant B2 model (MUL-3666): Multica hosts ONE
|
|
// Slack app, workspaces self-install via OAuth, and inbound runs on a single
|
|
// deployment-level Socket Mode connection routed by team_id — replacing the
|
|
// stage-3 per-installation connection model (MUL-3516).
|
|
//
|
|
// Two deployment-level env vars gate the two halves:
|
|
// - MULTICA_SLACK_SECRET_KEY decrypts the per-installation bot token
|
|
// (xoxb-) stored on the channel_installation row. It gates the inbound
|
|
// ResolverSet + the outbound reply subscriber, so without it there is no
|
|
// Slack at all.
|
|
// - MULTICA_SLACK_APP_TOKEN is the app-level token (xapp-) authorizing the
|
|
// single Socket Mode connection. It cannot be obtained via OAuth, so it
|
|
// is a one-time operator config. Without it, inbound is disabled (the
|
|
// ResolverSet + outbound are still wired so an existing install's replies
|
|
// keep flowing, but no new events are received).
|
|
//
|
|
// The ResolverSet/Outbound share the same engine.ChatSession, channel_*
|
|
// tables, IssueService and TaskService as Feishu, so /issue, dedup, and
|
|
// run-triggering behave identically. Feishu is untouched. Each Slack
|
|
// installation is a bring-your-own-app (BYO) install carrying its OWN
|
|
// app-level token, so a per-installation Slack Factory is registered and the
|
|
// Supervisor drives one Socket Mode connection per installation (like Feishu).
|
|
if slackKey, err := secretbox.LoadKey("MULTICA_SLACK_SECRET_KEY"); err == nil {
|
|
box, err := secretbox.New(slackKey)
|
|
if err != nil {
|
|
slog.Error("slack: secretbox.New failed; slack integration disabled", "error", err)
|
|
} else {
|
|
// Outbound replier (MUL-3666): delivers NeedsBinding prompt /
|
|
// AgentOffline / AgentArchived / issue-created notices. The binding
|
|
// token service mints the single-use token embedded in the prompt's
|
|
// redeem link; the redeem endpoint (registered below, public) binds
|
|
// the Slack user to their Multica account.
|
|
slackBindingSvc := slack.NewBindingTokenService(queries, pool)
|
|
h.SlackBindingTokens = slackBindingSvc
|
|
slackReplier := slack.NewOutboundReplier(slack.OutboundReplierConfig{
|
|
Binding: slackBindingSvc,
|
|
Decrypt: box.Open,
|
|
// The bind link (/slack/bind) is a web-app page, so it must use the
|
|
// app URL (MULTICA_APP_URL ?? FRONTEND_ORIGIN), NOT MULTICA_PUBLIC_URL
|
|
// (the backend/API URL). Mirrors the Lark replier (appURLFromEnv).
|
|
AppURL: appURLFromEnv(),
|
|
Logger: slog.Default(),
|
|
})
|
|
// Typing indicator (MUL-3874): a 👀 reaction on the user's message
|
|
// while the agent works, cleared when the run finishes or fails.
|
|
// Best-effort; failures are logged only. Registered before the
|
|
// outbound reply subscriber so, on EventChatDone, the reaction clears
|
|
// ahead of the reply (bus delivery is synchronous, in subscription
|
|
// order). Subscribing here is also the only path that clears the
|
|
// reaction on a failed run, which the outbound replier does not handle.
|
|
slackTyping := slack.NewTypingIndicatorManager(queries, box.Open, slog.Default())
|
|
slackTyping.Register(bus)
|
|
channelRouter.Register(slack.TypeSlack, slack.NewSlackResolverSet(queries, pool, slackReplier, slackTyping))
|
|
slack.NewOutbound(queries, box.Open, slog.Default()).Register(bus)
|
|
|
|
// On-demand history reader behind the unified `multica chat history`
|
|
// command (MUL-3871): pull the session's Slack conversation when the
|
|
// agent asks, instead of force-assembling it on every inbound.
|
|
h.SlackHistory = slack.NewHistory(queries, box.Open, slog.Default())
|
|
|
|
// `/issue` slash command (MUL-3908): a real Slack slash command,
|
|
// delivered over the same Socket Mode connection. It is a quick-create
|
|
// entry point — the invoker's natural-language description is enqueued as
|
|
// a quick-create task (no chat session or chat run) and the agent authors
|
|
// the well-formed issue in the background — reusing the shared TaskService
|
|
// + binding service. The invoker gets a private ephemeral acknowledgement
|
|
// and a Multica notification when the issue lands.
|
|
slackSlash := slack.NewSlashCommandProcessor(slack.SlashCommandConfig{
|
|
Queries: queries,
|
|
Tasks: h.TaskService,
|
|
Binding: slackBindingSvc,
|
|
AppURL: appURLFromEnv(),
|
|
Logger: slog.Default(),
|
|
})
|
|
|
|
// Per-installation inbound: the Supervisor builds + supervises one
|
|
// Socket Mode connection per active Slack installation, authenticated
|
|
// with that installation's OWN app-level token (xapp-, pasted at BYO
|
|
// install) — no deployment-level app token, no single connection.
|
|
slack.RegisterSlack(channelRegistry, slack.ChannelDeps{Decrypt: box.Open, Logger: slog.Default(), Slash: slackSlash})
|
|
|
|
// BYO self-serve install (paste bot token + app-level token). The
|
|
// InstallService needs only the at-rest encryption key — there is no
|
|
// hosted OAuth client credential.
|
|
installSvc, ierr := slack.NewInstallService(queries, pool, box, slog.Default())
|
|
if ierr != nil {
|
|
slog.Error("slack: InstallService init failed; install disabled", "error", ierr)
|
|
} else {
|
|
h.SlackInstall = installSvc
|
|
}
|
|
slog.Info("slack integration enabled (BYO per-installation socket mode)")
|
|
}
|
|
} else {
|
|
slog.Info("slack integration disabled (MULTICA_SLACK_SECRET_KEY not set)")
|
|
}
|
|
|
|
// Composio integration (MUL-3720). Gated by COMPOSIO_API_KEY plus the
|
|
// composio_mcp_apps feature flag. The env var is the project-scoped key the
|
|
// standalone SDK authenticates Composio with (sent as x-api-key; the project
|
|
// is resolved from the key, so NO project id is configured). When unset or
|
|
// flag-disabled the whole block is skipped and the composio HTTP handlers
|
|
// return 503; existing deployments are unaffected. An operator opts in by
|
|
// setting COMPOSIO_API_KEY plus a callback base
|
|
// (COMPOSIO_CALLBACK_BASE_URL, falling back to MULTICA_PUBLIC_URL). The
|
|
// toolkit→auth-config mapping is NOT configured here — it is resolved
|
|
// dynamically from the project's /auth_configs at request time, so enabling
|
|
// a toolkit is a dashboard action, not a redeploy. State signing uses
|
|
// COMPOSIO_STATE_SECRET, or a key derived from JWT_SECRET when that is unset.
|
|
if composioAPIKey := strings.TrimSpace(os.Getenv("COMPOSIO_API_KEY")); composioAPIKey != "" {
|
|
if !featureflags.ComposioMCPAppsEnabled(context.Background(), opts.FeatureFlags) {
|
|
slog.Info("composio integration disabled (feature flag off)")
|
|
} else {
|
|
sdkClient, err := composiosdk.NewClient(composiosdk.Options{APIKey: composioAPIKey})
|
|
if err != nil {
|
|
slog.Error("composio: SDK client init failed; composio integration disabled", "error", err)
|
|
} else {
|
|
stateSecret := composioStateSecret()
|
|
callbackBase := composioCallbackBaseURL(signupConfig.PublicURL)
|
|
switch {
|
|
case len(stateSecret) == 0:
|
|
slog.Error("composio: no state secret (set COMPOSIO_STATE_SECRET or JWT_SECRET); composio integration disabled")
|
|
case callbackBase == "":
|
|
slog.Error("composio: no callback base url (set COMPOSIO_CALLBACK_BASE_URL or MULTICA_PUBLIC_URL); composio integration disabled")
|
|
default:
|
|
svc, serr := composiointeg.NewService(sdkClient, queries, composiointeg.Config{
|
|
StateSecret: stateSecret,
|
|
CallbackBaseURL: callbackBase,
|
|
FrontendBaseURL: appURLFromEnv(),
|
|
})
|
|
if serr != nil {
|
|
slog.Error("composio: service init failed; composio integration disabled", "error", serr)
|
|
} else {
|
|
h.Composio = svc
|
|
// Stage 3 (MUL-3721) hook: feed the per-task MCP
|
|
// overlay builder into TaskService so every Enqueue*
|
|
// path attaches the initiator user's Composio session
|
|
// URL to the task row before the daemon claims it.
|
|
// taskSvc already exists by this point — it was
|
|
// constructed inside NewHandler — and exposes its
|
|
// Composio field for exactly this kind of late wiring,
|
|
// so no Handler-level mutation is needed.
|
|
if h.TaskService != nil {
|
|
h.TaskService.Composio = svc
|
|
}
|
|
slog.Info("composio integration enabled")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
slog.Info("composio integration disabled (COMPOSIO_API_KEY not set)")
|
|
}
|
|
|
|
if opts.HeartbeatScheduler != nil {
|
|
h.HeartbeatScheduler = opts.HeartbeatScheduler
|
|
}
|
|
// Auth caches: PAT cache is shared between the regular Auth middleware,
|
|
// the DaemonAuth fallback (mul_) path, and the revoke handler
|
|
// (invalidate). DaemonTokenCache backs the DaemonAuth mdt_ path. Both
|
|
// constructors return nil when rdb is nil — every consumer handles that
|
|
// as "no cache, always hit DB".
|
|
patCache := auth.NewPATCache(rdb)
|
|
daemonTokenCache := auth.NewDaemonTokenCache(rdb)
|
|
h.PATCache = patCache
|
|
h.DaemonTokenCache = daemonTokenCache
|
|
h.MembershipCache = auth.NewMembershipCache(rdb)
|
|
|
|
// Cloud PAT verifier: validates mcn_ tokens against Multica Cloud
|
|
// Fleet. Returns nil when no Fleet URL is configured — the Auth /
|
|
// DaemonAuth middlewares treat nil as "mcn_ not supported" and
|
|
// reject with 401, instead of falling through to mul_/JWT paths.
|
|
// Reuses MULTICA_CLOUD_FLEET_URL (the same URL the cloud-runtime
|
|
// proxy uses) so a deployment doesn't need a second config knob.
|
|
cloudPATVerifier := auth.NewCloudPATVerifier(auth.CloudPATVerifierConfig{
|
|
FleetBaseURL: signupConfig.CloudRuntimeFleetURL,
|
|
Redis: rdb,
|
|
})
|
|
|
|
// Empty-claim cache: lets the daemon poll path skip a Postgres
|
|
// scan when a recent check confirmed the runtime had no queued
|
|
// task. Returns nil when rdb is nil — TaskService treats that
|
|
// as "no cache, always hit DB" (existing behavior).
|
|
h.TaskService.EmptyClaim = service.NewEmptyClaimCache(rdb)
|
|
|
|
// Wire WS heartbeat after stores are finalized so the WS path uses the
|
|
// same (possibly Redis-backed) stores as the HTTP path.
|
|
daemonHub.SetHeartbeatHandler(h.HandleDaemonWSHeartbeat)
|
|
health := newServerHealth(pool)
|
|
|
|
r := chi.NewRouter()
|
|
|
|
// Global middleware
|
|
r.Use(chimw.RequestID)
|
|
r.Use(middleware.ClientMetadata)
|
|
r.Use(middleware.RequestLogger)
|
|
if opts.HTTPMetrics != nil {
|
|
r.Use(opts.HTTPMetrics.Middleware)
|
|
}
|
|
r.Use(chimw.Recoverer)
|
|
r.Use(middleware.ContentSecurityPolicy)
|
|
|
|
// Share allowed origins with WebSocket origin checker.
|
|
realtime.SetAllowedOrigins(origins)
|
|
|
|
// Share the same trusted-proxy CIDRs (MULTICA_TRUSTED_PROXIES) so the
|
|
// WebSocket origin check honors X-Forwarded-Host only from trusted proxies,
|
|
// using one config source instead of a parallel one.
|
|
realtime.SetTrustedProxies(signupConfig.TrustedProxies)
|
|
|
|
r.Use(cors.Handler(cors.Options{
|
|
AllowedOrigins: origins,
|
|
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID", "X-Workspace-Slug", "X-Request-ID", "X-Agent-ID", "X-Task-ID", "X-CSRF-Token", "X-Client-Platform", "X-Client-Version", "X-Client-OS"},
|
|
AllowCredentials: true,
|
|
MaxAge: 300,
|
|
}))
|
|
|
|
// Health / readiness checks
|
|
r.Get("/health", health.liveHandler)
|
|
r.Get("/readyz", health.readyHandler)
|
|
r.Get("/healthz", health.readyHandler)
|
|
|
|
// Realtime subsystem metrics — connection counts, slow-client evictions,
|
|
// and per-event-type send QPS counters. Exposed as JSON so it can be
|
|
// scraped by ops or surfaced in the admin UI without adding a Prometheus
|
|
// dependency. See MUL-1138 (Phase 0).
|
|
//
|
|
// Access is restricted (MUL-1342): when REALTIME_METRICS_TOKEN is set,
|
|
// callers must present it via Authorization: Bearer <token>. When the
|
|
// env var is unset the handler only serves loopback callers so local
|
|
// dev keeps working without exposing the metrics on a public listener.
|
|
r.Get("/health/realtime", realtimeMetricsHandler(os.Getenv("REALTIME_METRICS_TOKEN")))
|
|
|
|
// WebSocket
|
|
mc := &membershipChecker{queries: queries}
|
|
pr := &patResolver{queries: queries, cache: patCache}
|
|
slugResolver := realtime.SlugResolver(func(ctx context.Context, slug string) (string, error) {
|
|
ws, err := queries.GetWorkspaceBySlug(ctx, slug)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return util.UUIDToString(ws.ID), nil
|
|
})
|
|
r.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
|
|
realtime.HandleWebSocket(hub, mc, pr, slugResolver, w, r)
|
|
})
|
|
|
|
// Local file serving (when using local storage). Served through the
|
|
// handler so /uploads/* carries the same preview security headers as the
|
|
// /api/attachments download endpoint; self-hosted split-origin/same-origin
|
|
// clients can then iframe-preview PDFs/HTML fetched straight from the
|
|
// static route instead of hitting the global frame-ancestors 'none' CSP.
|
|
// See MUL-3821 / #4477.
|
|
if _, ok := store.(*storage.LocalStorage); ok {
|
|
r.Get("/uploads/*", h.ServeLocalUpload)
|
|
}
|
|
|
|
// Auth (public) — per-IP rate limiting.
|
|
if rdb == nil {
|
|
slog.Warn("rate limiting disabled: REDIS_URL not configured")
|
|
}
|
|
trustedProxies := middleware.ParseTrustedProxies(os.Getenv("RATE_LIMIT_TRUSTED_PROXIES"))
|
|
authRL := middleware.RateLimit(rdb, envPositiveInt("RATE_LIMIT_AUTH", 5), time.Minute, trustedProxies)
|
|
authVerifyRL := middleware.RateLimit(rdb, envPositiveInt("RATE_LIMIT_AUTH_VERIFY", 20), time.Minute, trustedProxies)
|
|
contactSalesRL := middleware.RateLimit(rdb, envPositiveInt("RATE_LIMIT_CONTACT_SALES", 5), time.Hour, trustedProxies)
|
|
r.With(authRL).Post("/auth/send-code", h.SendCode)
|
|
r.With(authVerifyRL).Post("/auth/verify-code", h.VerifyCode)
|
|
r.With(authRL).Post("/auth/google", h.GoogleLogin)
|
|
r.Post("/auth/logout", h.Logout)
|
|
|
|
// Public API
|
|
r.Get("/api/config", h.GetConfig)
|
|
r.With(contactSalesRL).Post("/api/contact-sales", h.CreateContactSales)
|
|
|
|
// Webhook ingress for autopilots. Outside the authenticated group on
|
|
// purpose: the bearer token in the URL path IS the credential. Workspace
|
|
// context is derived from the trigger row, never from request headers.
|
|
r.Post("/api/webhooks/autopilots/{token}", h.HandleAutopilotWebhook)
|
|
// GitHub App webhook (no Multica auth — requests are authenticated via
|
|
// HMAC-SHA256 signature in the handler) and post-install setup callback.
|
|
r.Post("/api/webhooks/github", h.HandleGitHubWebhook)
|
|
r.Get("/api/github/setup", h.GitHubSetupCallback)
|
|
// Slack OAuth callback (no Multica auth in the path — it is hit by Slack's
|
|
// browser redirect; the workspace/agent/initiator are recovered from the
|
|
// sealed state). It exchanges the code, upserts the install, then bounces
|
|
// the browser back to Settings → Integrations.
|
|
// Stripe webhook (no Multica auth — Stripe signs the raw body
|
|
// with a shared secret, the multica-cloud upstream verifies. We
|
|
// only forward the bytes + the Stripe-Signature header; see
|
|
// HandleCloudBillingStripeWebhook for the rationale).
|
|
r.Post("/api/webhooks/stripe", h.HandleCloudBillingStripeWebhook)
|
|
|
|
// Composio OAuth callback (MUL-3843). NOT under the Auth group on purpose:
|
|
// Composio 302-redirects the user's browser here at the end of the OAuth
|
|
// flow, and the cookie session is frequently absent (expired session,
|
|
// SameSite=Strict / Safari ITP stripping cross-site cookies, private
|
|
// windows, self-hosted callbacks on a different subdomain). Identity is NOT
|
|
// taken from the session — it comes from the HMAC-signed `state` query
|
|
// param, which CompleteCallback verifies (signature, expiry, replay) before
|
|
// doing anything. h.Composio == nil still returns 503. Keeping it inside the
|
|
// Auth group made a missing cookie a hard 401, breaking the flow for exactly
|
|
// the browsers above; the other four composio endpoints stay session-gated.
|
|
r.Get("/api/integrations/composio/callback", h.ComposioCallback)
|
|
|
|
// Daemon API routes (require daemon token or valid user token)
|
|
r.Route("/api/daemon", func(r chi.Router) {
|
|
r.Use(middleware.DaemonAuth(queries, patCache, daemonTokenCache, cloudPATVerifier))
|
|
|
|
r.Post("/register", h.DaemonRegister)
|
|
r.Post("/deregister", h.DaemonDeregister)
|
|
r.Post("/heartbeat", h.DaemonHeartbeat)
|
|
r.Get("/ws", h.DaemonWebSocket)
|
|
r.Get("/workspaces/{workspaceId}/repos", h.GetDaemonWorkspaceRepos)
|
|
r.Get("/workspaces/{workspaceId}/runtime-profiles", h.DaemonListRuntimeProfiles)
|
|
|
|
r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime)
|
|
r.Post("/runtimes/{runtimeId}/tasks/{taskId}/prepare-lease", h.ExtendTaskPrepareLease)
|
|
r.Post("/runtimes/{runtimeId}/tasks/{taskId}/skill-bundles/resolve", h.ResolveTaskSkillBundles)
|
|
r.Get("/runtimes/{runtimeId}/tasks/pending", h.ListPendingTasksByRuntime)
|
|
r.Post("/runtimes/{runtimeId}/update/{updateId}/result", h.ReportUpdateResult)
|
|
r.Post("/runtimes/{runtimeId}/models/{requestId}/result", h.ReportModelListResult)
|
|
r.Post("/runtimes/{runtimeId}/local-skills/{requestId}/result", h.ReportLocalSkillListResult)
|
|
r.Post("/runtimes/{runtimeId}/local-skills/import/{requestId}/result", h.ReportLocalSkillImportResult)
|
|
|
|
r.Get("/tasks/{taskId}/status", h.GetTaskStatus)
|
|
r.Post("/tasks/{taskId}/start", h.StartTask)
|
|
r.Post("/tasks/{taskId}/wait-local-directory", h.MarkTaskWaitingLocalDirectory)
|
|
r.Post("/tasks/{taskId}/progress", h.ReportTaskProgress)
|
|
r.Post("/tasks/{taskId}/complete", h.CompleteTask)
|
|
r.Post("/tasks/{taskId}/fail", h.FailTask)
|
|
r.Post("/tasks/{taskId}/usage", h.ReportTaskUsage)
|
|
r.Post("/tasks/{taskId}/messages", h.ReportTaskMessages)
|
|
r.Get("/tasks/{taskId}/messages", h.ListTaskMessages)
|
|
|
|
r.Get("/issues/{issueId}/gc-check", h.GetIssueGCCheck)
|
|
r.Get("/chat-sessions/{sessionId}/gc-check", h.GetChatSessionGCCheck)
|
|
r.Get("/autopilot-runs/{runId}/gc-check", h.GetAutopilotRunGCCheck)
|
|
r.Get("/tasks/{taskId}/gc-check", h.GetTaskGCCheck)
|
|
|
|
r.Post("/runtimes/{runtimeId}/recover-orphans", h.RecoverOrphanedTasks)
|
|
r.Post("/tasks/{taskId}/session", h.PinTaskSession)
|
|
})
|
|
|
|
// Protected API routes
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.Auth(queries, patCache, cloudPATVerifier))
|
|
r.Use(middleware.RefreshCloudFrontCookies(cfSigner))
|
|
|
|
// --- User-scoped routes (no workspace context required) ---
|
|
r.Get("/api/me", h.GetMe)
|
|
r.Patch("/api/me", h.UpdateMe)
|
|
r.Patch("/api/me/onboarding", h.PatchOnboarding)
|
|
r.Post("/api/me/onboarding/complete", h.CompleteOnboarding)
|
|
r.Post("/api/me/onboarding/cloud-waitlist", h.JoinCloudWaitlist)
|
|
// DEPRECATED — shim routes for desktop < v3 during the rollout
|
|
// window. v3 frontend creates the Helper agent + starter issue
|
|
// via generic CreateAgent / CreateIssue and only calls /complete
|
|
// here. Remove once X-Client-Version telemetry confirms zero
|
|
// pre-v3 desktops are still calling these. Handlers live in
|
|
// server/internal/handler/onboarding_shim.go.
|
|
r.Post("/api/me/onboarding/runtime-bootstrap", h.BootstrapOnboardingRuntime)
|
|
r.Post("/api/me/onboarding/no-runtime-bootstrap", h.BootstrapOnboardingNoRuntime)
|
|
r.Post("/api/cli-token", h.IssueCliToken)
|
|
r.Post("/api/upload-file", h.UploadFile)
|
|
r.Post("/api/feedback", h.CreateFeedback)
|
|
|
|
// Note (MUL-4309): the generic OpenAI-compatible passthrough endpoints
|
|
// (POST /api/llm/v1/chat/completions[/stream]) were intentionally
|
|
// removed. Exposing a general LLM proxy backed by the deployment's own
|
|
// key let any logged-in user run arbitrary completions on our dime.
|
|
// LLM access is now server-internal only (see pkg/llm); anything the
|
|
// web/client needs must go through a purpose-built business endpoint
|
|
// that fixes the prompt/model server-side (e.g. chat title generation).
|
|
|
|
// Attachment download — user-scoped (auth-only), NOT
|
|
// workspace-scoped. The handler self-resolves the workspace
|
|
// from the attachment row and enforces membership inside, so
|
|
// this route is callable as a native browser <img>/<video>
|
|
// src that cannot attach X-Workspace-Slug / X-Workspace-ID
|
|
// headers. Persisting `/api/attachments/<id>/download` into
|
|
// comment markdown depends on this — see MUL-3130. The
|
|
// metadata / delete endpoints below stay workspace-scoped
|
|
// because they are JSON-API consumers that always have
|
|
// workspace context.
|
|
r.Get("/api/attachments/{id}/download", h.DownloadAttachment)
|
|
|
|
r.Route("/api/workspaces", func(r chi.Router) {
|
|
r.Get("/", h.ListWorkspaces)
|
|
r.Post("/", h.CreateWorkspace)
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
// Member-level access
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireWorkspaceMemberFromURL(queries, "id"))
|
|
r.Get("/", h.GetWorkspace)
|
|
r.Get("/members", h.ListMembersWithUser)
|
|
r.Post("/leave", h.LeaveWorkspace)
|
|
r.Get("/invitations", h.ListWorkspaceInvitations)
|
|
// Listing GitHub installations is member-visible so the
|
|
// integrations tab no longer renders blank for non-admins;
|
|
// the handler strips the management handle and adds a
|
|
// can_manage hint so the UI can gate connect/disconnect.
|
|
r.Get("/github/installations", h.ListGitHubInstallations)
|
|
// Custom runtime profiles — listing/reading is member-visible
|
|
// (the Runtime page renders for everyone; create/edit/delete
|
|
// are admin-gated below).
|
|
r.Get("/runtime-profiles", h.ListRuntimeProfiles)
|
|
r.Get("/runtime-profiles/{profileId}", h.GetRuntimeProfile)
|
|
})
|
|
// Admin-level access
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireWorkspaceRoleFromURL(queries, "id", "owner", "admin"))
|
|
r.Put("/", h.UpdateWorkspace)
|
|
r.Patch("/", h.UpdateWorkspace)
|
|
r.Post("/members", h.CreateInvitation)
|
|
r.Route("/members/{memberId}", func(r chi.Router) {
|
|
r.Patch("/", h.UpdateMember)
|
|
r.Delete("/", h.DeleteMember)
|
|
})
|
|
r.Delete("/invitations/{invitationId}", h.RevokeInvitation)
|
|
// Custom runtime profile mutations (admin-only).
|
|
r.Post("/runtime-profiles", h.CreateRuntimeProfile)
|
|
r.Patch("/runtime-profiles/{profileId}", h.UpdateRuntimeProfile)
|
|
r.Put("/runtime-profiles/{profileId}", h.UpdateRuntimeProfile)
|
|
r.Delete("/runtime-profiles/{profileId}", h.DeleteRuntimeProfile)
|
|
})
|
|
// Owner-only access
|
|
r.With(middleware.RequireWorkspaceRoleFromURL(queries, "id", "owner")).Delete("/", h.DeleteWorkspace)
|
|
|
|
// GitHub integration — connect / disconnect remain admin-only;
|
|
// the read-only list endpoint lives in the member-level group
|
|
// above so non-admins can see the workspace's connection state.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireWorkspaceRoleFromURL(queries, "id", "owner", "admin"))
|
|
r.Get("/github/connect", h.GitHubConnect)
|
|
r.Delete("/github/installations/{installationId}", h.DeleteGitHubInstallation)
|
|
})
|
|
|
|
// Lark integration. Every endpoint here only requires
|
|
// workspace membership at the router; the real authorization
|
|
// is per-agent and enforced inside each handler via
|
|
// canManageAgent (agent owner OR workspace owner/admin), so an
|
|
// agent's owner can bind/manage their own agent's Bot without
|
|
// being a workspace admin (MUL-4213). The router can't make
|
|
// that call itself: begin identifies the agent by an
|
|
// `agent_id` query param and revoke by an installation id,
|
|
// neither of which is a URL param the role middleware sees.
|
|
// - Listing stays member-visible (same rationale as GitHub:
|
|
// the Integrations tab must render for non-admins so they
|
|
// see "wired up by whom").
|
|
// - Begin / status / revoke each load the target agent and
|
|
// run canManageAgent (status gates on the session
|
|
// initiator or an admin) before doing anything.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireWorkspaceMemberFromURL(queries, "id"))
|
|
r.Get("/lark/installations", h.ListLarkInstallations)
|
|
r.Delete("/lark/installations/{installationId}", h.RevokeLarkInstallation)
|
|
// Device-flow scan-to-install. Begin opens a new
|
|
// registration session against Lark and returns
|
|
// the QR-code URL; the frontend dialog then polls
|
|
// /install/{sessionId}/status until success or
|
|
// terminal failure.
|
|
r.Post("/lark/install/begin", h.BeginLarkInstall)
|
|
r.Get("/lark/install/{sessionId}/status", h.GetLarkInstallStatus)
|
|
})
|
|
|
|
// Slack integration (MUL-3666). Same admin/member split as
|
|
// Lark: listing is member-visible; OAuth begin + revoke are
|
|
// admin-only. The OAuth callback itself is a public route (it is
|
|
// hit by Slack's browser redirect with no workspace in the path)
|
|
// and is registered outside this workspace group.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireWorkspaceMemberFromURL(queries, "id"))
|
|
r.Get("/slack/installations", h.ListSlackInstallations)
|
|
})
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireWorkspaceRoleFromURL(queries, "id", "owner", "admin"))
|
|
r.Delete("/slack/installations/{installationId}", h.RevokeSlackInstallation)
|
|
r.Post("/slack/install/byo", h.RegisterSlackBYO)
|
|
})
|
|
})
|
|
})
|
|
|
|
// Lark binding-token redemption. NOT workspace-scoped because
|
|
// the redeemer hits this BEFORE they have any workspace
|
|
// context — the redemption itself is what mints their
|
|
// lark_user_binding row. Identity comes from the session;
|
|
// the token only proves "this open_id requested binding," and
|
|
// is combined with the logged-in user to create the mapping.
|
|
r.Post("/api/lark/binding/redeem", h.RedeemLarkBindingToken)
|
|
// Slack binding-token redemption. Same rationale as Lark: NOT
|
|
// workspace-scoped because the redeemer hits this before they have any
|
|
// workspace context — the redemption itself mints their binding row. The
|
|
// logged-in user (from the session) is bound to the Slack id the token
|
|
// carries.
|
|
r.Post("/api/slack/binding/redeem", h.RedeemSlackBindingToken)
|
|
|
|
// Composio integration (MUL-3720). User-scoped (no workspace context):
|
|
// a connection belongs to a user. These four require a logged-in
|
|
// session; the OAuth callback is the outlier and lives outside the Auth
|
|
// group (registered above with the other public OAuth/webhook routes —
|
|
// see MUL-3843). All return 503 when COMPOSIO_API_KEY is unset.
|
|
r.Route("/api/integrations/composio", func(r chi.Router) {
|
|
r.Post("/connect/init", h.ComposioConnectInit)
|
|
r.Get("/toolkits", h.ListComposioToolkits)
|
|
r.Get("/connections", h.ListComposioConnections)
|
|
r.Delete("/connections/{id}", h.DeleteComposioConnection)
|
|
})
|
|
|
|
// User-scoped invitation routes (no workspace context required)
|
|
r.Get("/api/invitations", h.ListMyInvitations)
|
|
r.Get("/api/invitations/{id}", h.GetMyInvitation)
|
|
r.Post("/api/invitations/{id}/accept", h.AcceptInvitation)
|
|
r.Post("/api/invitations/{id}/decline", h.DeclineInvitation)
|
|
|
|
r.Route("/api/tokens", func(r chi.Router) {
|
|
r.Get("/", h.ListPersonalAccessTokens)
|
|
r.Post("/", h.CreatePersonalAccessToken)
|
|
r.Post("/current/renew", h.RenewCurrentPersonalAccessToken)
|
|
r.Delete("/{id}", h.RevokePersonalAccessToken)
|
|
})
|
|
|
|
// Cloud Billing proxy. Same upstream service / port as
|
|
// cloud-runtime — multica-cloud's Fleet and Billing share
|
|
// :8080 and the same chi router. All routes here forward
|
|
// to /api/v1/billing/* with X-User-ID stamped from the
|
|
// authenticated context.
|
|
//
|
|
// User-scoped (account-level), NOT workspace-scoped — sits
|
|
// outside the RequireWorkspaceMember group so a user can
|
|
// inspect their balance, top up, and open the Billing Portal
|
|
// without an active workspace selected. The upstream owner
|
|
// model is single-user; X-Workspace-ID would be ignored even
|
|
// if we sent it. The Stripe webhook is the public outlier
|
|
// and lives outside the entire Auth group (see above).
|
|
//
|
|
// IMPORTANT — task-token actors are blocked here. The Auth
|
|
// middleware happily turns an mat_ task token into a normal
|
|
// X-User-ID stamp (so agents can comment, claim issues, etc.
|
|
// as their owner), but billing is account-level and a running
|
|
// agent reading its owner's balance / opening a checkout
|
|
// session is the kind of lateral-movement we're explicitly
|
|
// trying to prevent. handler.RequireHumanActor checks the
|
|
// authoritative server-set X-Actor-Source header and 403s
|
|
// any task-token request. See actor_guards.go for the full
|
|
// rationale.
|
|
r.Route("/api/cloud-billing", func(r chi.Router) {
|
|
r.Use(handler.RequireHumanActor)
|
|
|
|
r.Get("/balance", h.GetCloudBillingBalance)
|
|
r.Get("/transactions", h.ListCloudBillingTransactions)
|
|
r.Get("/batches", h.ListCloudBillingBatches)
|
|
r.Get("/topups", h.ListCloudBillingTopups)
|
|
r.Get("/price-tiers", h.ListCloudBillingPriceTiers)
|
|
r.Post("/checkout-sessions", h.CreateCloudBillingCheckoutSession)
|
|
r.Get("/checkout-sessions/{sessionId}", h.GetCloudBillingCheckoutSession)
|
|
r.Post("/portal-sessions", h.CreateCloudBillingPortalSession)
|
|
})
|
|
|
|
// --- Workspace-scoped routes (all require workspace membership) ---
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireWorkspaceMember(queries))
|
|
|
|
// Assignee frequency
|
|
r.Get("/api/assignee-frequency", h.GetAssigneeFrequency)
|
|
|
|
// Issues
|
|
r.Route("/api/issues", func(r chi.Router) {
|
|
r.Get("/search", h.SearchIssues)
|
|
r.Get("/child-progress", h.ChildIssueProgress)
|
|
r.Get("/children", h.ListChildrenByParents)
|
|
r.Get("/grouped", h.ListGroupedIssues)
|
|
r.Get("/", h.ListIssues)
|
|
r.Post("/", h.CreateIssue)
|
|
r.Post("/quick-create", h.QuickCreateIssue)
|
|
r.Post("/preview-trigger", h.PreviewIssueTrigger)
|
|
r.Post("/batch-update", h.BatchUpdateIssues)
|
|
r.Post("/batch-delete", h.BatchDeleteIssues)
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
r.Get("/", h.GetIssue)
|
|
r.Put("/", h.UpdateIssue)
|
|
r.Delete("/", h.DeleteIssue)
|
|
r.Post("/comments/trigger-preview", h.PreviewCommentTriggers)
|
|
r.Post("/comments", h.CreateComment)
|
|
r.Get("/comments", h.ListComments)
|
|
r.Get("/timeline", h.ListTimeline)
|
|
r.Get("/subscribers", h.ListIssueSubscribers)
|
|
r.Post("/subscribe", h.SubscribeToIssue)
|
|
r.Post("/unsubscribe", h.UnsubscribeFromIssue)
|
|
r.Get("/active-task", h.GetActiveTaskForIssue)
|
|
r.Post("/tasks/{taskId}/cancel", h.CancelTask)
|
|
r.Post("/rerun", h.RerunIssue)
|
|
r.Get("/task-runs", h.ListTasksByIssue)
|
|
r.Get("/usage", h.GetIssueUsage)
|
|
r.Post("/reactions", h.AddIssueReaction)
|
|
r.Delete("/reactions", h.RemoveIssueReaction)
|
|
r.Get("/attachments", h.ListAttachments)
|
|
r.Get("/children", h.ListChildIssues)
|
|
r.Get("/labels", h.ListLabelsForIssue)
|
|
r.Post("/labels", h.AttachLabel)
|
|
r.Delete("/labels/{labelId}", h.DetachLabel)
|
|
r.Get("/metadata", h.ListIssueMetadata)
|
|
r.Put("/metadata/{key}", h.SetIssueMetadataKey)
|
|
r.Delete("/metadata/{key}", h.DeleteIssueMetadataKey)
|
|
r.Get("/pull-requests", h.ListPullRequestsForIssue)
|
|
})
|
|
})
|
|
|
|
// Task messages (user-facing, not daemon auth)
|
|
r.Get("/api/tasks/{taskId}/messages", h.ListTaskMessagesByUser)
|
|
|
|
// Labels
|
|
r.Route("/api/labels", func(r chi.Router) {
|
|
r.Get("/", h.ListLabels)
|
|
r.Post("/", h.CreateLabel)
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
r.Get("/", h.GetLabel)
|
|
r.Put("/", h.UpdateLabel)
|
|
r.Delete("/", h.DeleteLabel)
|
|
})
|
|
})
|
|
|
|
// Projects
|
|
r.Route("/api/projects", func(r chi.Router) {
|
|
r.Get("/search", h.SearchProjects)
|
|
r.Get("/", h.ListProjects)
|
|
r.Post("/", h.CreateProject)
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
r.Get("/", h.GetProject)
|
|
r.Put("/", h.UpdateProject)
|
|
r.Delete("/", h.DeleteProject)
|
|
r.Get("/resources", h.ListProjectResources)
|
|
r.Post("/resources", h.CreateProjectResource)
|
|
r.Put("/resources/{resourceId}", h.UpdateProjectResource)
|
|
r.Delete("/resources/{resourceId}", h.DeleteProjectResource)
|
|
})
|
|
})
|
|
|
|
// Squads
|
|
r.Route("/api/squads", func(r chi.Router) {
|
|
r.Get("/", h.ListSquads)
|
|
r.Post("/", h.CreateSquad)
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
r.Get("/", h.GetSquad)
|
|
r.Put("/", h.UpdateSquad)
|
|
r.Delete("/", h.DeleteSquad)
|
|
r.Get("/members", h.ListSquadMembers)
|
|
r.Get("/members/status", h.ListSquadMemberStatus)
|
|
r.Post("/members", h.AddSquadMember)
|
|
r.Delete("/members", h.RemoveSquadMember)
|
|
r.Patch("/members/role", h.UpdateSquadMemberRole)
|
|
})
|
|
})
|
|
|
|
// Squad leader evaluation (writes to activity_log)
|
|
r.Post("/api/issues/{id}/squad-evaluated", h.RecordSquadLeaderEvaluation)
|
|
|
|
// Autopilots
|
|
r.Route("/api/autopilots", func(r chi.Router) {
|
|
r.Get("/", h.ListAutopilots)
|
|
r.Post("/", h.CreateAutopilot)
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
r.Get("/", h.GetAutopilot)
|
|
r.Patch("/", h.UpdateAutopilot)
|
|
r.Delete("/", h.DeleteAutopilot)
|
|
r.Post("/trigger", h.TriggerAutopilot)
|
|
r.Get("/runs", h.ListAutopilotRuns)
|
|
r.Get("/runs/{runId}", h.GetAutopilotRun)
|
|
r.Get("/deliveries", h.ListAutopilotDeliveries)
|
|
r.Get("/deliveries/{deliveryId}", h.GetAutopilotDelivery)
|
|
r.Post("/deliveries/{deliveryId}/replay", h.ReplayAutopilotDelivery)
|
|
r.Post("/triggers", h.CreateAutopilotTrigger)
|
|
r.Route("/triggers/{triggerId}", func(r chi.Router) {
|
|
r.Patch("/", h.UpdateAutopilotTrigger)
|
|
r.Delete("/", h.DeleteAutopilotTrigger)
|
|
r.Post("/rotate-webhook-token", h.RotateAutopilotTriggerWebhookToken)
|
|
r.Put("/signing-secret", h.SetAutopilotTriggerSigningSecret)
|
|
})
|
|
r.Post("/collaborators", h.AddAutopilotCollaborator)
|
|
r.Delete("/collaborators/{userId}", h.RemoveAutopilotCollaborator)
|
|
})
|
|
})
|
|
|
|
// Pins
|
|
r.Route("/api/pins", func(r chi.Router) {
|
|
r.Get("/", h.ListPins)
|
|
r.Post("/", h.CreatePin)
|
|
r.Put("/reorder", h.ReorderPins)
|
|
r.Delete("/{itemType}/{itemId}", h.DeletePin)
|
|
})
|
|
|
|
// Attachments
|
|
r.Get("/api/attachments/{id}", h.GetAttachmentByID)
|
|
// /api/attachments/{id}/download is registered in the
|
|
// outer Auth-only group above so it can be loaded as a
|
|
// native <img>/<video> src without workspace headers
|
|
// (MUL-3130). The handler self-resolves the workspace
|
|
// from the attachment row.
|
|
r.Get("/api/attachments/{id}/content", h.GetAttachmentContent)
|
|
r.Delete("/api/attachments/{id}", h.DeleteAttachment)
|
|
|
|
// Comments
|
|
r.Route("/api/comments/{commentId}", func(r chi.Router) {
|
|
r.Put("/", h.UpdateComment)
|
|
r.Delete("/", h.DeleteComment)
|
|
r.Post("/resolve", h.ResolveComment)
|
|
r.Delete("/resolve", h.UnresolveComment)
|
|
r.Post("/reactions", h.AddReaction)
|
|
r.Delete("/reactions", h.RemoveReaction)
|
|
})
|
|
|
|
// Agents
|
|
r.Route("/api/agents", func(r chi.Router) {
|
|
r.Get("/", h.ListAgents)
|
|
r.Post("/", h.CreateAgent)
|
|
// Agent templates: pre-configured instructions + skill refs.
|
|
// Picking a template imports the referenced skills into the
|
|
// workspace (find-or-create by name) and creates the agent
|
|
// with the template's instructions in one transaction.
|
|
r.Post("/from-template", h.CreateAgentFromTemplate)
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
r.Get("/", h.GetAgent)
|
|
r.Put("/", h.UpdateAgent)
|
|
r.Post("/archive", h.ArchiveAgent)
|
|
r.Post("/restore", h.RestoreAgent)
|
|
r.Post("/cancel-tasks", h.CancelAgentTasks)
|
|
r.Get("/tasks", h.ListAgentTasks)
|
|
r.Get("/skills", h.ListAgentSkills)
|
|
r.Put("/skills", h.SetAgentSkills)
|
|
r.Post("/skills/add", h.AddAgentSkills)
|
|
// Dedicated env-management endpoint. Owner/admin only;
|
|
// agent actors are denied. Every reveal / write is
|
|
// audited to activity_log. See MUL-2600 and
|
|
// internal/handler/agent_env.go.
|
|
r.Get("/env", h.GetAgentEnv)
|
|
r.Put("/env", h.UpdateAgentEnv)
|
|
})
|
|
})
|
|
|
|
// Agent templates catalog (browse + detail). The Create flow
|
|
// lives under /api/agents/from-template above; this route is for
|
|
// the picker UI to list available templates.
|
|
r.Route("/api/agent-templates", func(r chi.Router) {
|
|
r.Get("/", h.ListAgentTemplates)
|
|
r.Get("/{slug}", h.GetAgentTemplate)
|
|
})
|
|
|
|
// Skills
|
|
r.Route("/api/skills", func(r chi.Router) {
|
|
r.Get("/", h.ListSkills)
|
|
r.Post("/", h.CreateSkill)
|
|
r.Get("/search", h.SearchSkills)
|
|
r.Post("/import", h.ImportSkill)
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
r.Get("/", h.GetSkill)
|
|
r.Put("/", h.UpdateSkill)
|
|
r.Delete("/", h.DeleteSkill)
|
|
r.Get("/files", h.ListSkillFiles)
|
|
r.Put("/files", h.UpsertSkillFile)
|
|
r.Delete("/files/{fileId}", h.DeleteSkillFile)
|
|
})
|
|
})
|
|
|
|
// Dashboard — workspace-wide token + run-time rollups for the
|
|
// "/{slug}/dashboard" page. Optional ?project_id filter scopes
|
|
// the rollup to a single project.
|
|
r.Route("/api/dashboard", func(r chi.Router) {
|
|
r.Get("/usage/daily", h.GetDashboardUsageDaily)
|
|
r.Get("/usage/by-agent", h.GetDashboardUsageByAgent)
|
|
r.Get("/agent-runtime", h.GetDashboardAgentRunTime)
|
|
r.Get("/runtime/daily", h.GetDashboardRunTimeDaily)
|
|
})
|
|
|
|
// Runtimes
|
|
r.Route("/api/runtimes", func(r chi.Router) {
|
|
r.Get("/", h.ListAgentRuntimes)
|
|
r.Route("/{runtimeId}", func(r chi.Router) {
|
|
r.Patch("/", h.UpdateAgentRuntime)
|
|
r.Get("/usage", h.GetRuntimeUsage)
|
|
r.Get("/usage/by-agent", h.GetRuntimeUsageByAgent)
|
|
r.Get("/usage/by-hour", h.GetRuntimeUsageByHour)
|
|
r.Get("/activity", h.GetRuntimeTaskActivity)
|
|
r.Post("/update", h.InitiateUpdate)
|
|
r.Get("/update/{updateId}", h.GetUpdate)
|
|
r.Post("/models", h.InitiateListModels)
|
|
r.Get("/models/{requestId}", h.GetModelListRequest)
|
|
r.Post("/local-skills", h.InitiateListLocalSkills)
|
|
r.Get("/local-skills/{requestId}", h.GetLocalSkillListRequest)
|
|
r.Post("/local-skills/import", h.InitiateImportLocalSkill)
|
|
r.Get("/local-skills/import/{requestId}", h.GetLocalSkillImportRequest)
|
|
r.Delete("/", h.DeleteAgentRuntime)
|
|
// Cascade variant of DELETE: archive every active agent
|
|
// bound to this runtime, cancel their tasks, then delete
|
|
// the runtime — all in one transaction. Used by the
|
|
// DeleteRuntimeDialog when the strict DELETE refused with
|
|
// `runtime_has_active_agents` and the user confirmed the
|
|
// cascade plan.
|
|
r.Post("/archive-agents-and-delete", h.ArchiveAgentsAndDeleteRuntime)
|
|
})
|
|
})
|
|
|
|
// Cloud Runtime fleet proxy. The remote service URL is configured
|
|
// on SaaS API nodes only; self-hosted deployments return 503.
|
|
r.Route("/api/cloud-runtime", func(r chi.Router) {
|
|
r.Get("/", h.GetCloudRuntimeService)
|
|
r.Get("/healthz", h.GetCloudRuntimeHealth)
|
|
r.Get("/readyz", h.GetCloudRuntimeReady)
|
|
r.Get("/nodes", h.ListCloudRuntimeNodes)
|
|
r.Post("/nodes", h.CreateCloudRuntimeNode)
|
|
r.Delete("/nodes", h.DeleteCloudRuntimeNode)
|
|
r.Post("/nodes/start", h.StartCloudRuntimeNode)
|
|
r.Post("/nodes/stop", h.StopCloudRuntimeNode)
|
|
r.Post("/nodes/reboot", h.RebootCloudRuntimeNode)
|
|
r.Post("/nodes/status", h.GetCloudRuntimeNodeStatus)
|
|
r.Post("/nodes/exec", h.ExecCloudRuntimeNode)
|
|
})
|
|
|
|
// Tasks (user-facing, with ownership check)
|
|
r.Post("/api/tasks/{taskId}/cancel", h.CancelTaskByUser)
|
|
|
|
// Workspace-wide agent task snapshot for presence derivation:
|
|
// every active task + each agent's most recent terminal task.
|
|
r.Get("/api/agent-task-snapshot", h.ListWorkspaceAgentTaskSnapshot)
|
|
|
|
// Workspace-wide daily agent activity (last 30d, anchored on
|
|
// completed_at). Backs the Agents-list sparkline (trailing 7d
|
|
// slice) AND the agent detail "Last 30 days" panel.
|
|
r.Get("/api/agent-activity-30d", h.GetWorkspaceAgentActivity30d)
|
|
|
|
// Workspace-wide 30-day run counts per agent for the Agents-list RUNS column.
|
|
r.Get("/api/agent-run-counts", h.GetWorkspaceAgentRunCounts)
|
|
|
|
r.Route("/api/chat/sessions", func(r chi.Router) {
|
|
r.Post("/", h.CreateChatSession)
|
|
r.Get("/", h.ListChatSessions)
|
|
r.Route("/{sessionId}", func(r chi.Router) {
|
|
r.Get("/", h.GetChatSession)
|
|
r.Patch("/", h.UpdateChatSession)
|
|
r.Patch("/pin", h.SetChatSessionPinned)
|
|
r.Patch("/archive", h.SetChatSessionArchived)
|
|
r.Delete("/", h.DeleteChatSession)
|
|
r.Post("/messages", h.SendChatMessage)
|
|
r.Get("/messages", h.ListChatMessages)
|
|
r.Get("/messages/page", h.ListChatMessagesPage)
|
|
r.Get("/pending-task", h.GetPendingChatTask)
|
|
r.Post("/read", h.MarkChatSessionRead)
|
|
})
|
|
})
|
|
r.Get("/api/chat/pending-tasks", h.ListPendingChatTasks)
|
|
r.Get("/api/chat/pending-tasks/has-any", h.HasPendingChatTasks)
|
|
|
|
// Quick-agent bar: per-user pinned agents for one-tap new chats.
|
|
r.Get("/api/chat/pinned-agents", h.ListChatPinnedAgents)
|
|
r.Post("/api/chat/pinned-agents", h.PinChatAgent)
|
|
r.Delete("/api/chat/pinned-agents/{agentId}", h.UnpinChatAgent)
|
|
|
|
// Agent-facing channel reads (MUL-3871). The caller's task-scoped token
|
|
// resolves to its own chat session; no session/channel id is passed, so
|
|
// an agent can only read its own conversation. `history` is the channel
|
|
// overview (top-level messages + thread metadata); `thread` reads one
|
|
// thread (?id for a specific one, else the thread the session is in).
|
|
r.Get("/api/chat/history", h.GetChatChannelHistory)
|
|
r.Get("/api/chat/thread", h.GetChatThread)
|
|
|
|
// Inbox
|
|
r.Route("/api/inbox", func(r chi.Router) {
|
|
r.Get("/", h.ListInbox)
|
|
r.Get("/unread-count", h.CountUnreadInbox)
|
|
// Cross-workspace unread summary: account-level, keyed on the
|
|
// user. Backs the workspace-switcher dot for OTHER workspaces.
|
|
r.Get("/unread-summary", h.UnreadInboxSummary)
|
|
r.Post("/mark-all-read", h.MarkAllInboxRead)
|
|
r.Post("/archive-all", h.ArchiveAllInbox)
|
|
r.Post("/archive-all-read", h.ArchiveAllReadInbox)
|
|
r.Post("/archive-completed", h.ArchiveCompletedInbox)
|
|
r.Post("/{id}/read", h.MarkInboxRead)
|
|
r.Post("/{id}/archive", h.ArchiveInboxItem)
|
|
})
|
|
|
|
// Notification preferences
|
|
r.Route("/api/notification-preferences", func(r chi.Router) {
|
|
r.Get("/", h.GetNotificationPreferences)
|
|
r.Put("/", h.UpdateNotificationPreferences)
|
|
})
|
|
})
|
|
})
|
|
|
|
return r, h
|
|
}
|
|
|
|
// buildLarkConnector wires the real WS long-conn connector that talks
|
|
// to /callback/ws/endpoint directly with app_id/app_secret. The
|
|
// connector wraps every read with a ctx-cancel watchdog so lease loss /
|
|
// shutdown breaks the blocking ReadMessage in bounded time — the
|
|
// invariant §4.4 leans on. A single connector instance serves every
|
|
// installation; its Run is parameterized by the installation, so the
|
|
// feishuChannel hands it the per-installation row.
|
|
//
|
|
// If the endpoint fetcher fails to initialize (typically a malformed
|
|
// MULTICA_LARK_CALLBACK_BASE_URL), we log and fall back to the
|
|
// NoopConnector so the lease / supervisor lifecycle still exercises
|
|
// against real DB rows. Inbound messages are silently dropped until
|
|
// the config is fixed; the boot log labels the mode "noop" so the
|
|
// degraded state is visible.
|
|
//
|
|
// Returns the connector plus a short label for the boot log:
|
|
// "ws-long-conn" in the healthy case, "noop" in the fallback case.
|
|
func buildLarkConnector(installSvc *lark.InstallationService, apiClient lark.APIClient) (lark.EventConnector, string) {
|
|
endpointFetcher, err := lark.NewHTTPConnectionTokenFetcher(lark.HTTPConnectionTokenConfig{
|
|
BaseURL: strings.TrimSpace(os.Getenv("MULTICA_LARK_CALLBACK_BASE_URL")),
|
|
Logger: slog.Default(),
|
|
})
|
|
if err != nil {
|
|
slog.Error("lark ws: endpoint fetcher init failed; falling back to noop", "error", err)
|
|
return lark.NewNoopConnector(slog.Default()), "noop"
|
|
}
|
|
decoder := lark.NewLarkJSONFrameDecoder()
|
|
dialer := lark.NewGorillaDialer()
|
|
if proxyURL := strings.TrimSpace(os.Getenv("MULTICA_LARK_WS_PROXY_URL")); proxyURL != "" {
|
|
dialer.ProxyURL = proxyURL
|
|
}
|
|
credsProvider := lark.CredentialsProviderFunc(func(ctx context.Context, inst lark.Installation) (lark.InstallationCredentials, error) {
|
|
secret, err := installSvc.DecryptAppSecret(inst)
|
|
if err != nil {
|
|
return lark.InstallationCredentials{}, err
|
|
}
|
|
creds := lark.InstallationCredentials{
|
|
AppID: inst.AppID,
|
|
AppSecret: secret,
|
|
Region: lark.RegionOrDefault(inst.Region),
|
|
}
|
|
if inst.TenantKey.Valid {
|
|
creds.TenantKey = inst.TenantKey.String
|
|
}
|
|
return creds, nil
|
|
})
|
|
// Inbound enricher: expands quoted replies / forwarded bundles AND
|
|
// prefetches a window of surrounding group history (MUL-3084) into the
|
|
// agent's body via the IM API before dispatch. It shares the
|
|
// connector's resolved credentials and runs under the connector's
|
|
// EnrichTimeout so it cannot overrun the Lark long-conn ACK budget.
|
|
enricher := lark.NewInboundEnricher(apiClient, lark.InboundEnricherConfig{
|
|
RecentContextSize: lark.DefaultRecentContextSize,
|
|
Logger: slog.Default(),
|
|
})
|
|
conn, err := lark.NewWSLongConnConnector(lark.WSConnectorConfig{
|
|
Dialer: dialer,
|
|
EndpointFetcher: endpointFetcher,
|
|
FrameDecoder: decoder,
|
|
Enricher: enricher,
|
|
CredentialsProvider: credsProvider,
|
|
Logger: slog.Default(),
|
|
})
|
|
if err != nil {
|
|
slog.Error("lark ws: connector init failed; falling back to noop", "error", err)
|
|
return lark.NewNoopConnector(slog.Default()), "noop"
|
|
}
|
|
return conn, "ws-long-conn"
|
|
}
|
|
|
|
// membershipChecker implements realtime.MembershipChecker using database queries.
|
|
type membershipChecker struct {
|
|
queries *db.Queries
|
|
}
|
|
|
|
func (mc *membershipChecker) IsMember(ctx context.Context, userID, workspaceID string) bool {
|
|
_, err := mc.queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
|
|
UserID: parseUUID(userID),
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
})
|
|
return err == nil
|
|
}
|
|
|
|
// patResolver implements realtime.PATResolver using database queries.
|
|
// patCache is shared with the Auth and DaemonAuth middlewares so a token
|
|
// revoke through any path invalidates the cache for all of them. Nil
|
|
// cache is supported and degrades to direct DB lookups.
|
|
type patResolver struct {
|
|
queries *db.Queries
|
|
cache *auth.PATCache
|
|
}
|
|
|
|
func (pr *patResolver) ResolveToken(ctx context.Context, token string) (string, bool) {
|
|
hash := auth.HashToken(token)
|
|
|
|
if userID, ok := pr.cache.Get(ctx, hash); ok {
|
|
return userID, true
|
|
}
|
|
|
|
pat, err := pr.queries.GetPersonalAccessTokenByHash(ctx, hash)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
|
|
userID := util.UUIDToString(pat.UserID)
|
|
|
|
var expiresAt time.Time
|
|
if pat.ExpiresAt.Valid {
|
|
expiresAt = pat.ExpiresAt.Time
|
|
}
|
|
pr.cache.Set(ctx, hash, userID, auth.TTLForExpiry(time.Now(), expiresAt))
|
|
|
|
// Cache miss = first WS auth in this TTL window. Refresh last_used_at;
|
|
// subsequent connects within the window skip the write.
|
|
go pr.queries.UpdatePersonalAccessTokenLastUsed(context.Background(), pat.ID)
|
|
|
|
return userID, true
|
|
}
|
|
|
|
// parseUUID is a thin alias for util.MustParseUUID. Call sites here are all
|
|
// internal round-trips of DB-sourced UUIDs (e.g. issue.ID, e.ActorID), so an
|
|
// invalid value indicates a programming error and should panic loudly.
|
|
func parseUUID(s string) pgtype.UUID {
|
|
return util.MustParseUUID(s)
|
|
}
|
|
|
|
// optionalUUID returns a NULL pgtype.UUID for an empty string and otherwise
|
|
// behaves like parseUUID. Use this for actor IDs on events where the producer
|
|
// may legitimately be a "system" actor with no member/agent attribution
|
|
// (e.g. GitHub webhook auto-status sync) — the activity_log and inbox_item
|
|
// tables both allow actor_id to be NULL.
|
|
func optionalUUID(s string) pgtype.UUID {
|
|
if s == "" {
|
|
return pgtype.UUID{}
|
|
}
|
|
return util.MustParseUUID(s)
|
|
}
|
|
|
|
func splitAndTrim(s string) []string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(s, ",")
|
|
res := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
trimmed := strings.TrimSpace(p)
|
|
if trimmed != "" {
|
|
res = append(res, trimmed)
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
func cloudRuntimeFleetURLFromEnv() string {
|
|
if url := strings.TrimSpace(os.Getenv("MULTICA_CLOUD_FLEET_URL")); url != "" {
|
|
return url
|
|
}
|
|
return strings.TrimSpace(os.Getenv("MULTICA_FLEET_URL"))
|
|
}
|
|
|
|
// composioStateSecret resolves the HMAC key for the connect-state. Prefers an
|
|
// explicit COMPOSIO_STATE_SECRET; otherwise derives a composio-specific key
|
|
// from JWT_SECRET via SHA-256 so the two signing domains never share an
|
|
// identical key. Returns nil when neither is set (composio stays disabled).
|
|
func composioStateSecret() []byte {
|
|
if v := strings.TrimSpace(os.Getenv("COMPOSIO_STATE_SECRET")); v != "" {
|
|
return []byte(v)
|
|
}
|
|
if v := strings.TrimSpace(os.Getenv("JWT_SECRET")); v != "" {
|
|
sum := sha256.Sum256([]byte("composio-state:" + v))
|
|
return sum[:]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// composioCallbackBaseURL resolves the public API base used to build the
|
|
// Composio callback URL. Prefers COMPOSIO_CALLBACK_BASE_URL, then the
|
|
// already-resolved MULTICA_PUBLIC_URL, then the app URL.
|
|
func composioCallbackBaseURL(publicURL string) string {
|
|
if v := strings.TrimRight(strings.TrimSpace(os.Getenv("COMPOSIO_CALLBACK_BASE_URL")), "/"); v != "" {
|
|
return v
|
|
}
|
|
if publicURL != "" {
|
|
return publicURL
|
|
}
|
|
return appURLFromEnv()
|
|
}
|