mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* feat(plugins): host plugin artifacts, bind installs to immutable versions A plugin used to be a URL. The manifest was frozen at install, but the surface script was fetched from the author's server every time a panel opened, with no integrity check — so an administrator consented to a manifest while the browser ran whatever that host served that day, inside the scopes already granted. The author's uptime was our uptime, every panel open leaked the reader's IP and "who read which issue" to the author, and publishing at all required a public HTTPS domain, which is why the repo's own deploy-sentinel example never ran. The author now uploads an artifact bundle and Multica stores it: - plugin_package / plugin_package_version / plugin_package_file. A version is insert-only, and the (package_id, version) unique index is what makes immutability a database rule rather than a convention. - An installation names one version. Publishing another changes nothing for a workspace until an administrator upgrades, which is a second consent. - The bundle is validated once, at publish: the manifest parses, every file it declares is present and loadable, and a surface entry with a top-level import is refused with the line named. That failure used to surface months later in a reader's browser. - The host inlines the surface script into the document it generates, so the CSP names no remote script origin at all. `connect-src 'none'` now means it: a surface with no net: scope cannot reach anywhere, including its author. - Install-by-URL is gone rather than kept alongside. Two paths would make "is this code frozen?" depend on how the plugin was installed. - MULTICA_PLUGIN_DIR stays the development channel, but publishes an ordinary immutable version instead of being a second render path; re-publishing an unchanged version lands as `+dev.N`. Hook endpoints and MCP servers remain on the author's own infrastructure. Migration 376 drops existing installations: they name a source URL that is no longer a concept, and the code they would run was never published here. plugins_v1 has never left its feature flag and holds no production data — the same call migration 344 made. Closes MUL-6469 Co-authored-by: multica-agent <[email protected]> * fix(plugins): serialize publish/install/delete, correct the isolation claim Review of #7321 found five problems. Four are fixed here; the fifth is real, needs its own design pass, and is now tracked and described honestly in the code instead of claimed away. Migration prefix collision. main took 376 for agent_task_durable_work_dir while this branch was open, so `migration prefix 376 is reused` failed backend-tests. Rebased and renumbered the six migrations to 377-382. TOCTOU between delete and install/publish. Relationships are application-owned by repository policy, so nothing made "the version this installation names still exists" true across statements: `delete counts zero installs` -> `install reads the version` -> `delete commits` -> `install commits` left an installation whose panel 404s forever, and a publish racing a delete left a version whose package row was gone. Publish, install and delete now hold a (workspace, plugin key) advisory lock for their whole transaction, and delete counts installations inside it. Both interleavings have regression tests that reproduce the bug with the lock removed. Failed publishes moved package state. upsertPackage ran before the version transaction, so a republish that lost the unique index had already renamed the package, and a first publish that failed afterwards left a plugin with zero versions. It now runs inside the same transaction. The top-level import check was wrong in BOTH directions. It read line prefixes, so it missed ` import x` and `/* c */ import x` — and it refused a valid file whose template literal contained the word at a line start, which is ordinary in a surface that renders code samples. A refusal blocks a publish with no way around it, so that false positive was the worse half. Replaced with a scanner that skips comments and string/template literals; where it is imprecise it is imprecise safely (`/` always reads as division), so a mistake can only cost a detection, never invent one. Sandbox self-navigation is NOT fixed. `sandbox="allow-scripts"` permits `_self` navigation by design, no shipped CSP directive covers it, and contentWindow is unchanged across it — so a hostile artifact can still reach its author once and hand the replacement document the bridge port. Closing it needs an embedder frame-src policy or a handshake a navigated document cannot complete; both are decisions of their own. What lands here is damage control, labelled as such: the generated document beacons on pagehide from a listener the plugin cannot detach, and the embedder drops the bridge and unmounts the frame. The "a surface cannot reach its author" wording is removed from the SDK README and the tests. Closes MUL-6469 Refs MUL-6485 Co-authored-by: multica-agent <[email protected]> * feat(plugins): isolate hosted surfaces (MUL-6485) Co-authored-by: multica-agent <[email protected]> * feat(plugins): host plugin artifacts, bind installs to immutable versions A plugin used to be a URL. The manifest was frozen at install, but the surface script was fetched from the author's server every time a panel opened, with no integrity check — so an administrator consented to a manifest while the browser ran whatever that host served that day, inside the scopes already granted. The author's uptime was our uptime, every panel open leaked the reader's IP and "who read which issue" to the author, and publishing at all required a public HTTPS domain, which is why the repo's own deploy-sentinel example never ran. The author now uploads an artifact bundle and Multica stores it: - plugin_package / plugin_package_version / plugin_package_file. A version is insert-only, and the (package_id, version) unique index is what makes immutability a database rule rather than a convention. - An installation names one version. Publishing another changes nothing for a workspace until an administrator upgrades, which is a second consent. - The bundle is validated once, at publish: the manifest parses, every file it declares is present and loadable, and a surface entry with a top-level import is refused with the line named. That failure used to surface months later in a reader's browser. - The host inlines the surface script into the document it generates, so the CSP names no remote script origin at all. `connect-src 'none'` now means it: a surface with no net: scope cannot reach anywhere, including its author. - Install-by-URL is gone rather than kept alongside. Two paths would make "is this code frozen?" depend on how the plugin was installed. - MULTICA_PLUGIN_DIR stays the development channel, but publishes an ordinary immutable version instead of being a second render path; re-publishing an unchanged version lands as `+dev.N`. Hook endpoints and MCP servers remain on the author's own infrastructure. Migration 376 drops existing installations: they name a source URL that is no longer a concept, and the code they would run was never published here. plugins_v1 has never left its feature flag and holds no production data — the same call migration 344 made. Closes MUL-6469 Co-authored-by: multica-agent <[email protected]> * fix(plugins): serialize publish/install/delete, correct the isolation claim Review of #7321 found five problems. Four are fixed here; the fifth is real, needs its own design pass, and is now tracked and described honestly in the code instead of claimed away. Migration prefix collision. main took 376 for agent_task_durable_work_dir while this branch was open, so `migration prefix 376 is reused` failed backend-tests. Rebased and renumbered the six migrations to 377-382. TOCTOU between delete and install/publish. Relationships are application-owned by repository policy, so nothing made "the version this installation names still exists" true across statements: `delete counts zero installs` -> `install reads the version` -> `delete commits` -> `install commits` left an installation whose panel 404s forever, and a publish racing a delete left a version whose package row was gone. Publish, install and delete now hold a (workspace, plugin key) advisory lock for their whole transaction, and delete counts installations inside it. Both interleavings have regression tests that reproduce the bug with the lock removed. Failed publishes moved package state. upsertPackage ran before the version transaction, so a republish that lost the unique index had already renamed the package, and a first publish that failed afterwards left a plugin with zero versions. It now runs inside the same transaction. The top-level import check was wrong in BOTH directions. It read line prefixes, so it missed ` import x` and `/* c */ import x` — and it refused a valid file whose template literal contained the word at a line start, which is ordinary in a surface that renders code samples. A refusal blocks a publish with no way around it, so that false positive was the worse half. Replaced with a scanner that skips comments and string/template literals; where it is imprecise it is imprecise safely (`/` always reads as division), so a mistake can only cost a detection, never invent one. Sandbox self-navigation is NOT fixed. `sandbox="allow-scripts"` permits `_self` navigation by design, no shipped CSP directive covers it, and contentWindow is unchanged across it — so a hostile artifact can still reach its author once and hand the replacement document the bridge port. Closing it needs an embedder frame-src policy or a handshake a navigated document cannot complete; both are decisions of their own. What lands here is damage control, labelled as such: the generated document beacons on pagehide from a listener the plugin cannot detach, and the embedder drops the bridge and unmounts the frame. The "a surface cannot reach its author" wording is removed from the SDK README and the tests. Closes MUL-6469 Refs MUL-6485 Co-authored-by: multica-agent <[email protected]> * fix(plugins): harden surface validation and error reporting * fix(migrations): renumber plugin package migrations * fix(plugins): validate classic surfaces and reset failures * fix(plugins): refresh issue-scoped surface launches Co-authored-by: multica-agent <[email protected]> * feat(plugins): isolate hosted surfaces (MUL-6485) Co-authored-by: multica-agent <[email protected]> * fix(plugins): refresh issue-scoped surface launches Co-authored-by: multica-agent <[email protected]> * fix(plugins): make surface termination event-driven Co-authored-by: multica-agent <[email protected]> * feat(plugins): isolate hosted surfaces (MUL-6485) Co-authored-by: multica-agent <[email protected]> * fix(plugins): refresh issue-scoped surface launches Co-authored-by: multica-agent <[email protected]> * fix(plugins): make surface termination event-driven Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]>
1187 lines
49 KiB
Go
1187 lines
49 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"math"
|
|
"net/http"
|
|
"net/netip"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"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/entitlement"
|
|
"github.com/multica-ai/multica/server/internal/events"
|
|
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
|
|
composio "github.com/multica-ai/multica/server/internal/integrations/composio"
|
|
"github.com/multica-ai/multica/server/internal/integrations/dingtalk"
|
|
"github.com/multica-ai/multica/server/internal/integrations/ghsnapshot"
|
|
"github.com/multica-ai/multica/server/internal/integrations/lark"
|
|
"github.com/multica-ai/multica/server/internal/integrations/slack"
|
|
"github.com/multica-ai/multica/server/internal/integrations/telegram"
|
|
"github.com/multica-ai/multica/server/internal/integrations/wecom"
|
|
"github.com/multica-ai/multica/server/internal/issuestatus"
|
|
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"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/featureflag"
|
|
"github.com/multica-ai/multica/server/pkg/llm"
|
|
)
|
|
|
|
// randomID returns a random 16-byte hex string used as a request ID for
|
|
// in-memory stores (model list, local skills, CLI update, etc.).
|
|
func randomID() string {
|
|
b := make([]byte, 16)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
type txStarter interface {
|
|
Begin(ctx context.Context) (pgx.Tx, error)
|
|
}
|
|
|
|
type dbExecutor interface {
|
|
Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error)
|
|
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
|
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
|
}
|
|
|
|
type Config struct {
|
|
AllowSignup bool
|
|
AllowedEmails []string
|
|
AllowedEmailDomains []string
|
|
// DisableWorkspaceCreation, when true, makes POST /api/workspaces return
|
|
// 403 for every caller. There is no role/owner exception because the repo
|
|
// has no platform-admin concept; operators bootstrap the workspace with
|
|
// the flag off, then flip it on and restart so subsequent users join via
|
|
// invitation only. The public /api/config endpoint mirrors this flag so
|
|
// the UI can hide every "Create workspace" affordance — see #3433.
|
|
DisableWorkspaceCreation bool
|
|
// VCSIntegrationEnabled gates the self-hosted Git provider integration
|
|
// (Forgejo / Gitea / GitLab) at the deployment level, independent of whether
|
|
// MULTICA_VCS_SECRET_KEY is set. It is the product boundary: the feature is
|
|
// intended for self-hosted Multica only (where Multica and the Git instance
|
|
// can share a network), and is left off on the managed cloud — connect,
|
|
// rotate, and webhook handlers reject when it is false, and /api/config
|
|
// omits it so the UI hides the whole section rather than showing a
|
|
// "missing key" message a cloud user cannot act on. Populated from
|
|
// MULTICA_VCS_INTEGRATION_ENABLED; the self-host compose defaults it on.
|
|
VCSIntegrationEnabled bool
|
|
// PublicURL is the absolute base URL the API is reachable at from the
|
|
// public internet, with no trailing slash (e.g. "https://multica.ai").
|
|
// Used to build webhook_url responses and the fixed Remote MCP OAuth
|
|
// callback URI — never to decide request identity, routing, or workspace
|
|
// scope. Empty when unset; webhook clients can fall back to their own origin,
|
|
// while OAuth Connect fails closed because providers require an exact URI.
|
|
// Reading the public host from request headers (Host / X-Forwarded-Host)
|
|
// is intentionally avoided so a misconfigured reverse proxy cannot trick
|
|
// the server into minting webhook URLs pointing at an attacker-controlled
|
|
// host.
|
|
PublicURL string
|
|
// AppURL is the browser application's canonical origin, resolved from
|
|
// MULTICA_APP_URL (falling back to FRONTEND_ORIGIN). It is kept separate
|
|
// from PublicURL because split app/API deployments use different origins.
|
|
AppURL string
|
|
// TrustedProxies are CIDRs whose source IP we trust to set
|
|
// X-Forwarded-For / X-Real-IP. Empty means "trust nothing": the rate
|
|
// limiter uses r.RemoteAddr exclusively. Populated via the
|
|
// MULTICA_TRUSTED_PROXIES env var (comma-separated CIDRs, e.g.
|
|
// "10.0.0.0/8,127.0.0.1/32"). This is specifically to keep the per-IP
|
|
// webhook limiter from being bypassed by a spoofed XFF on deployments
|
|
// without a header-stripping reverse proxy in front.
|
|
TrustedProxies []netip.Prefix
|
|
// CloudRuntimeFleetURL enables the SaaS-only remote Fleet adapter when set.
|
|
// Empty keeps self-hosted deployments explicit: cloud runtime endpoints
|
|
// return 503 instead of attempting to dial a hard-coded private service.
|
|
CloudRuntimeFleetURL string
|
|
CloudRuntimeFleetTimeout time.Duration
|
|
AttachmentDownloadMode string
|
|
AttachmentDownloadURLTTL time.Duration
|
|
// AttachmentFrameAncestors are trusted browser origins allowed to embed
|
|
// attachment preview responses. In production this should mirror the
|
|
// frontend/CORS origin allowlist so split app/api self-hosted deployments
|
|
// can frame API-hosted PDFs without allowing arbitrary third-party frames.
|
|
AttachmentFrameAncestors []string
|
|
// PluginSurfaceOrigin is the dedicated, cookie-free browser origin that
|
|
// routes /plugin-surfaces/* back to this server. It must not be the app/API
|
|
// origin: the route serves stored third-party JavaScript as HTML.
|
|
PluginSurfaceOrigin string
|
|
// LLM* configure the basic LLM API layer (MUL-4238). They back the
|
|
// server-internal LLM helpers in pkg/llm (e.g. chat title generation).
|
|
// The generic OpenAI-compatible passthrough endpoints were removed in
|
|
// MUL-4309; LLM access is internal-only now. When both LLMAPIKey and
|
|
// LLMBaseURL are empty the layer is disabled and callers fall back
|
|
// silently (see maybeGenerateChatTitleAsync).
|
|
// - LLMAPIKey -> MULTICA_LLM_API_KEY
|
|
// - LLMBaseURL -> MULTICA_LLM_BASE_URL (OpenAI or any compatible gateway)
|
|
// - LLMDefaultModel -> MULTICA_LLM_DEFAULT_MODEL (used when a request omits `model`)
|
|
// - LLMMaxRetries -> MULTICA_LLM_MAX_RETRIES (transport retry budget)
|
|
LLMAPIKey string
|
|
LLMBaseURL string
|
|
LLMDefaultModel string
|
|
// LLMMaxRetries is the parsed MULTICA_LLM_MAX_RETRIES budget. nil means
|
|
// unset (llm.DefaultMaxRetries applies); llm.Retries(0) disables retries.
|
|
// The type carries the validation: it can only be built through llm.Retries,
|
|
// and cmd/server additionally fails the boot on an out-of-range value before
|
|
// one reaches this struct. See llm.Config.MaxRetries for the full semantics.
|
|
LLMMaxRetries *llm.RetryOverride
|
|
// ServerVersion is the build version of the running API binary (the same
|
|
// value main.go stamps via -X main.version and reports on /metrics).
|
|
// Surfaced through /api/config so self-hosted operators can confirm which
|
|
// server build is deployed. Empty in dev builds.
|
|
ServerVersion string
|
|
}
|
|
|
|
type cloudRuntimeProxy interface {
|
|
Enabled() bool
|
|
Do(ctx context.Context, req cloudruntime.Request) (*cloudruntime.Response, error)
|
|
}
|
|
|
|
type RuntimeProfileRefreshNotifier interface {
|
|
NotifyRuntimeProfilesChanged(workspaceID, profileID string)
|
|
}
|
|
|
|
type WorkspaceSetRefreshNotifier interface {
|
|
NotifyWorkspacesChanged(userID string)
|
|
}
|
|
|
|
// DaemonPendingWorkNotifier pushes a runtime-scoped "heartbeat now" hint to the
|
|
// daemon so a queued heartbeat-carried request (model discovery) is picked up
|
|
// immediately instead of on the daemon's next scheduled tick (MUL-5444).
|
|
// Satisfied by both *daemonws.Hub (single-node) and *daemonws.RelayNotifier
|
|
// (multi-node, fans out through Redis).
|
|
type DaemonPendingWorkNotifier interface {
|
|
NotifyPendingWork(runtimeID, kind string)
|
|
}
|
|
|
|
type Handler struct {
|
|
Queries *db.Queries
|
|
DB dbExecutor
|
|
TxStarter txStarter
|
|
// issueTableWindowCache is initialized only on the request-local Handler
|
|
// copy used by a repeatable-read table request. It lets facets reuse one
|
|
// visible-id snapshot without adding mutable state to the shared Handler.
|
|
issueTableWindowCache *issueTableWindowCache
|
|
Hub *realtime.Hub
|
|
DaemonHub *daemonws.Hub
|
|
DaemonProfileRefresh RuntimeProfileRefreshNotifier
|
|
DaemonWorkspaceRefresh WorkspaceSetRefreshNotifier
|
|
Bus *events.Bus
|
|
TaskService *service.TaskService
|
|
PluginService *service.PluginService
|
|
IssueService *service.IssueService
|
|
AutopilotService *service.AutopilotService
|
|
// Entitlements supplies workspace-scoped commercial gates. A nil provider
|
|
// preserves the self-hosted and pre-rollout behavior without extra reads.
|
|
Entitlements entitlement.Provider
|
|
EmailService *service.EmailService
|
|
UpdateStore UpdateStore
|
|
ModelListStore ModelListStore
|
|
LocalSkillListStore LocalSkillListStore
|
|
LocalSkillImportStore LocalSkillImportStore
|
|
FeatureFlags *featureflag.Service
|
|
// IssueStatusCatalog reads the workspace status catalog. Defaults to
|
|
// Queries; a test can substitute a counting wrapper to assert HOW MANY
|
|
// catalog reads a request performs, which is the only property that
|
|
// distinguishes the current one-read derivation from the N+1 it replaced.
|
|
// (MUL-6243)
|
|
IssueStatusCatalog issuestatus.Querier
|
|
LivenessStore LivenessStore
|
|
HeartbeatScheduler HeartbeatScheduler
|
|
Storage storage.Storage
|
|
CFSigner *auth.CloudFrontSigner
|
|
Analytics analytics.Client
|
|
// DaemonPendingWork pushes "heartbeat now" hints for queued
|
|
// heartbeat-carried requests (MUL-5444). Optional: when nil,
|
|
// requestDaemonPendingWork falls back to the local DaemonHub, which is the
|
|
// correct delivery scope for a single-node deployment.
|
|
DaemonPendingWork DaemonPendingWorkNotifier
|
|
// ModelCatalogCache serves the last known good model list for a runtime so
|
|
// the picker can render without waiting for a daemon round trip
|
|
// (stale-while-revalidate, MUL-5444). Nil-safe: every call site treats a nil
|
|
// cache as a permanent miss and falls back to the full discovery flow.
|
|
ModelCatalogCache ModelCatalogCache
|
|
// Metrics is the shared business-metrics collector built by main.go.
|
|
// May be nil in tests / self-hosted with the metrics listener disabled;
|
|
// every Record* method is nil-safe and obsmetrics.RecordEvent treats a
|
|
// nil Metrics as "PostHog only".
|
|
Metrics *obsmetrics.BusinessMetrics
|
|
PATCache *auth.PATCache
|
|
DaemonTokenCache *auth.DaemonTokenCache
|
|
MembershipCache *auth.MembershipCache
|
|
WebhookRateLimiter WebhookRateLimiter
|
|
WebhookIPRateLimiter WebhookRateLimiter
|
|
WebhookAbsoluteIPRateLimiter WebhookRateLimiter
|
|
InvitationRateLimiters InvitationRateLimiters
|
|
WebhookDeliveryWorker *WebhookDeliveryWorker
|
|
CloudRuntime cloudRuntimeProxy
|
|
// Lark integration. All three are nil when the Lark master key
|
|
// (MULTICA_LARK_SECRET_KEY) is unset; the corresponding HTTP
|
|
// handlers return 503 in that case so a misconfigured self-host
|
|
// deployment surfaces a clear error instead of silently using a
|
|
// zero key. Wired in cmd/server/router.go after handler.New.
|
|
LarkInstallations *lark.InstallationService
|
|
LarkBindingTokens *lark.BindingTokenService
|
|
// LarkRegistration owns the device-flow install lifecycle: begin
|
|
// a registration session against accounts.feishu.cn, poll, and
|
|
// on success write lark_installation + the installer's
|
|
// lark_user_binding in one DB transaction. Nil when the at-rest
|
|
// key is unset or the RegistrationService failed to construct at
|
|
// boot.
|
|
LarkRegistration *lark.RegistrationService
|
|
// LarkAPIClient is the live transport that backs SendInteractiveCard,
|
|
// PatchInteractiveCard, SendBindingPromptCard, GetBotInfo. The
|
|
// router wires the real Lark HTTP client whenever
|
|
// MULTICA_LARK_SECRET_KEY is set; tests that need a no-op
|
|
// behaviour can swap in `lark.NewStubAPIClient(...)` directly. The
|
|
// UI consults IsConfigured() to decide whether to surface install
|
|
// entry points.
|
|
LarkAPIClient lark.APIClient
|
|
// Composio integration (MUL-3720). Nil when COMPOSIO_API_KEY is unset;
|
|
// the composio HTTP handlers return 503 in that case. Wired in
|
|
// cmd/server/router.go after handler.New.
|
|
Composio *composio.Service
|
|
// ChannelSupervisor owns the per-installation supervisor goroutines
|
|
// that hold the §4.4 WS lease and drive each channel.Channel
|
|
// (MUL-3620 generalized the Feishu-only Hub into this channel-agnostic
|
|
// engine). The router constructs it independently of platform secrets — it
|
|
// drives any channel type, not just Feishu. It remains nil when lease
|
|
// configuration is unsafe or a selected Redis backend fails its startup
|
|
// readiness check; each platform registers its Factory only when configured
|
|
// (Feishu when MULTICA_LARK_SECRET_KEY is set). The router does NOT
|
|
// call Run; the process owner (main.go) starts it under a long-running
|
|
// context and joins via WaitWithTimeout (bounded, fenced by
|
|
// ShutdownTimeout) during graceful shutdown so the lease renewer yields
|
|
// cleanly when the DB is healthy without blocking process exit if the
|
|
// pool is frozen — at worst the next replica waits the full TTL.
|
|
ChannelSupervisor *engine.Supervisor
|
|
// ChannelRouter is the channel-agnostic inbound pipeline (the shared
|
|
// handler the Supervisor injects into every Channel). main.go calls
|
|
// Drain on it during shutdown, after the Supervisor has stopped
|
|
// delivering events, to flush debounced run triggers and join in-flight
|
|
// reply goroutines. Built unconditionally (even without Lark).
|
|
ChannelRouter *engine.Router
|
|
// ChannelMediaReconciler settles the channel-media intent ledger
|
|
// (uploaded-but-unbound object reclaim). Built in cmd/server/router.go
|
|
// where the storage backend exists; main.go starts it as an independent
|
|
// worker goroutine. Nil when no storage backend is configured.
|
|
ChannelMediaReconciler *service.ChannelMediaReconciler
|
|
// SlackInstall owns the bring-your-own-app Slack install lifecycle (register
|
|
// pasted tokens / list / revoke) and the at-rest encryption of each app's bot
|
|
// + app tokens (MUL-3666). Nil unless MULTICA_SLACK_SECRET_KEY is set.
|
|
SlackInstall *slack.InstallService
|
|
// SlackBindingTokens mints/redeems the user-binding tokens behind the
|
|
// "link your Slack account" prompt (MUL-3666). Nil unless Slack is
|
|
// configured (MULTICA_SLACK_SECRET_KEY set).
|
|
SlackBindingTokens *slack.BindingTokenService
|
|
// DingTalkInstall owns the bring-your-own-app DingTalk lifecycle. It is nil
|
|
// unless MULTICA_DINGTALK_SECRET_KEY is configured.
|
|
DingTalkInstall *dingtalk.InstallService
|
|
// DingTalkBindingTokens mints and redeems the single-use account-link tokens.
|
|
DingTalkBindingTokens *dingtalk.BindingTokenService
|
|
// SlackHistory backs the agent-facing `multica chat history` command: it
|
|
// reads a chat session's bound Slack conversation on demand (MUL-3871). Nil
|
|
// unless Slack is configured; GetChatChannelHistory then reports "no channel
|
|
// integration". A future platform satisfies the same reader interface.
|
|
SlackHistory ChatChannelHistoryReader
|
|
// WecomStore is the read/write handle over channel_installation rows scoped
|
|
// to channel_type='wecom'. Nil disables the wecom Web-UI endpoints (they
|
|
// return 503) and prevents boot from wiring the smart-bot supervisor.
|
|
WecomStore *wecom.Store
|
|
// WecomCredentials unseals a wecom installation's smart-bot secret for the
|
|
// WebSocket subscribe frame. Nil disables the wecom integration.
|
|
WecomCredentials wecom.CredentialsResolver
|
|
// WecomBindingTokens mints/redeems the user-binding tokens behind the
|
|
// "link your Multica account" prompt sent to first-time WeCom users
|
|
// (their aibot userid is a "T"-prefixed anonymized id with no relation
|
|
// to their real userid or email, so an explicit binding is required —
|
|
// see wecom/binding.go). Nil disables the redeem endpoint (returns 503)
|
|
// and the OutboundReplier's binding-prompt path.
|
|
WecomBindingTokens WecomBindingRedeemer
|
|
|
|
// WecomCredentialProbe overrides the install-time control check. Nil in
|
|
// production, which gets the real handshake probe; tests inject a fake so
|
|
// the install path runs without a socket.
|
|
WecomCredentialProbe wecom.CredentialProbe
|
|
|
|
// TelegramInstall owns the Telegram bot install lifecycle (register a
|
|
// pasted BotFather token / list / revoke) and the at-rest encryption of
|
|
// each bot's token. Nil unless MULTICA_TELEGRAM_SECRET_KEY is set.
|
|
TelegramInstall *telegram.InstallService
|
|
// TelegramBindingTokens mints/redeems the user-binding tokens behind the
|
|
// "link your Telegram account" prompt. Nil unless Telegram is configured.
|
|
TelegramBindingTokens *telegram.BindingTokenService
|
|
// TelegramOutbound owns the asynchronous terminal-delivery worker pool.
|
|
// The process owner starts and joins it; the synchronous event bus only
|
|
// enqueues EventChatDone work.
|
|
TelegramOutbound *telegram.Outbound
|
|
|
|
// channelFileDelivery names the channel types that can, IN THIS
|
|
// DEPLOYMENT, carry a file the agent produced the last hop into the
|
|
// conversation. It answers the claim response's
|
|
// chat_channel_delivers_files, which the agent's PER-TURN prompt turns into
|
|
// either "run `multica attachment upload`" or "describe the file in words"
|
|
// (daemon/prompt.go). Not the brief: the brief is the prompt cache prefix and
|
|
// this is a per-turn verdict, so stating it there made one session render two
|
|
// briefs (MUL-5377).
|
|
//
|
|
// It is a deployment fact, not a property of the channel type, and that
|
|
// distinction is the whole reason it lives here. Whether the file arrives
|
|
// takes TWO things: an adapter that goes back for the bound attachment,
|
|
// and object storage for it to go back to. The first is a property of the
|
|
// code, the second of the configuration, and only the process that wired
|
|
// both knows the conjunction. A daemon that answers this from the channel
|
|
// type alone promises delivery a storage-less deployment cannot perform.
|
|
//
|
|
// Written once at boot by DeclareChannelFileDelivery (cmd/server/router.go,
|
|
// in the same branch that passes the storage to the adapter, so the two
|
|
// cannot drift), read-only from then on. Nil means no channel delivers
|
|
// files, which is what a deployment with no storage configured gets.
|
|
channelFileDelivery map[string]bool
|
|
// LLM is the basic LLM API layer (MUL-4238): a thin wrapper over the
|
|
// OpenAI Go SDK backing server-internal one-shot LLM helpers such as chat
|
|
// title generation. The generic passthrough endpoints were removed in
|
|
// MUL-4309, so it is internal-only now. Always non-nil (New builds it from
|
|
// Config); when unconfigured its Enabled() reports false and callers fall
|
|
// back silently.
|
|
LLM *llm.Client
|
|
// VCSSecretBox encrypts/decrypts per-workspace Git provider access tokens and
|
|
// webhook secrets at rest (Forgejo / Gitea / GitLab). Nil when
|
|
// MULTICA_VCS_SECRET_KEY is unset; the connect/webhook handlers return 503
|
|
// in that case so a misconfigured self-host deployment surfaces a clear
|
|
// error rather than silently storing plaintext. Wired in
|
|
// cmd/server/router.go after New.
|
|
VCSSecretBox *secretbox.Box
|
|
// PluginSurfaceTokens seal short-lived launch claims. Nil disables surface
|
|
// launches; wired from a domain-separated MULTICA_PLUGIN_SECRET_KEY at boot.
|
|
PluginSurfaceTokens *secretbox.Box
|
|
// PRRefresh drives the GitHub API snapshot pipeline for PR cards (MUL-5265):
|
|
// webhook / page-visit / TTL triggers → authenticated GraphQL fetch →
|
|
// head-SHA-guarded atomic snapshot write. Always non-nil, but inert (every
|
|
// trigger is a no-op) when GITHUB_APP_ID / GITHUB_APP_PRIVATE_KEY are unset,
|
|
// so the feature degrades cleanly on deployments without a private key.
|
|
// Wired in cmd/server/router.go after New.
|
|
PRRefresh *ghsnapshot.Manager
|
|
cfg Config
|
|
}
|
|
|
|
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner, analyticsClient analytics.Client, cfg Config, daemonHubs ...*daemonws.Hub) *Handler {
|
|
var executor dbExecutor
|
|
if candidate, ok := txStarter.(dbExecutor); ok {
|
|
executor = candidate
|
|
}
|
|
|
|
if analyticsClient == nil {
|
|
analyticsClient = analytics.NoopClient{}
|
|
}
|
|
if mode, ok := normalizeAttachmentDownloadMode(cfg.AttachmentDownloadMode); ok {
|
|
cfg.AttachmentDownloadMode = string(mode)
|
|
} else {
|
|
slog.Warn("invalid ATTACHMENT_DOWNLOAD_MODE, using auto", "value", cfg.AttachmentDownloadMode)
|
|
cfg.AttachmentDownloadMode = string(attachmentDownloadModeAuto)
|
|
}
|
|
if cfg.AttachmentDownloadURLTTL <= 0 {
|
|
cfg.AttachmentDownloadURLTTL = defaultAttachmentDownloadURLTTL
|
|
}
|
|
|
|
var daemonHub *daemonws.Hub
|
|
if len(daemonHubs) > 0 {
|
|
daemonHub = daemonHubs[0]
|
|
}
|
|
var daemonProfileRefresh RuntimeProfileRefreshNotifier
|
|
var daemonWorkspaceRefresh WorkspaceSetRefreshNotifier
|
|
if daemonHub != nil {
|
|
daemonProfileRefresh = daemonHub
|
|
daemonWorkspaceRefresh = daemonHub
|
|
}
|
|
|
|
llmClient := llm.New(llm.Config{
|
|
APIKey: cfg.LLMAPIKey,
|
|
BaseURL: cfg.LLMBaseURL,
|
|
DefaultModel: cfg.LLMDefaultModel,
|
|
MaxRetries: cfg.LLMMaxRetries,
|
|
})
|
|
// Report the effective retry policy so an operator can confirm from the
|
|
// boot log alone what a misbehaving upstream will cost, instead of inferring
|
|
// it from an env var whose semantics used to be unguessable (MUL-6364).
|
|
// Read off the client, not off cfg, so the line cannot drift from what the
|
|
// SDK actually enforces. Counts and an enum only — never the key or the base
|
|
// URL, since a self-hosted gateway URL routinely embeds a token.
|
|
llmRetry := llmClient.RetryBudget()
|
|
slog.Info("llm retry policy",
|
|
"max_retries", llmRetry.MaxRetries,
|
|
"source", llmRetry.Source,
|
|
"request_timeout", llmRetry.RequestTimeout,
|
|
"enabled", llmClient.Enabled(),
|
|
)
|
|
|
|
taskSvc := service.NewTaskService(queries, txStarter, hub, bus, daemonHub)
|
|
taskSvc.Analytics = analyticsClient
|
|
// Chat follow-up suggestions run through the same internal LLM layer that
|
|
// backs auto-titling. A deployment with no MULTICA_LLM_* configuration gets
|
|
// a disabled client, which turns the feature off rather than failing.
|
|
taskSvc.QuickActions = llmClient
|
|
h := &Handler{
|
|
Queries: queries,
|
|
DB: executor,
|
|
TxStarter: txStarter,
|
|
Hub: hub,
|
|
DaemonHub: daemonHub,
|
|
DaemonProfileRefresh: daemonProfileRefresh,
|
|
DaemonWorkspaceRefresh: daemonWorkspaceRefresh,
|
|
Bus: bus,
|
|
TaskService: taskSvc,
|
|
PluginService: service.NewPluginService(queries, txStarter),
|
|
IssueService: service.NewIssueService(queries, txStarter, bus, analyticsClient, taskSvc),
|
|
AutopilotService: service.NewAutopilotService(queries, txStarter, bus, taskSvc),
|
|
EmailService: emailService,
|
|
UpdateStore: NewInMemoryUpdateStore(),
|
|
ModelListStore: NewInMemoryModelListStore(),
|
|
ModelCatalogCache: NewInMemoryModelCatalogCache(),
|
|
LocalSkillListStore: NewInMemoryLocalSkillListStore(),
|
|
LocalSkillImportStore: NewInMemoryLocalSkillImportStore(),
|
|
LivenessStore: NewNoopLivenessStore(),
|
|
HeartbeatScheduler: NewPassthroughHeartbeatScheduler(queries),
|
|
Storage: store,
|
|
CFSigner: cfSigner,
|
|
Analytics: analyticsClient,
|
|
WebhookRateLimiter: NewMemoryWebhookRateLimiter(DefaultWebhookRateLimit()),
|
|
WebhookIPRateLimiter: NewMemoryWebhookIPRateLimiter(DefaultWebhookIPRateLimit()),
|
|
WebhookAbsoluteIPRateLimiter: NewMemoryWebhookAbsoluteIPRateLimiter(DefaultWebhookAbsoluteIPRateLimit()),
|
|
InvitationRateLimiters: NewMemoryInvitationRateLimiters(DefaultInvitationRateLimits()),
|
|
CloudRuntime: cloudruntime.NewClient(cloudruntime.Config{
|
|
BaseURL: cfg.CloudRuntimeFleetURL,
|
|
Timeout: cfg.CloudRuntimeFleetTimeout,
|
|
}),
|
|
LLM: llmClient,
|
|
cfg: cfg,
|
|
}
|
|
h.WebhookDeliveryWorker = NewWebhookDeliveryWorker(h)
|
|
|
|
// GitHub API snapshot pipeline for PR cards (MUL-5265). Built
|
|
// unconditionally but inert (every trigger no-ops) when the App private key
|
|
// is unconfigured, so the feature degrades cleanly. main.go calls
|
|
// h.PRRefresh.Start(ctx) to launch its worker pool + TTL sweeper.
|
|
ghClient, err := ghsnapshot.NewClientFromEnv()
|
|
if err != nil {
|
|
// Malformed key is operator-actionable; the pipeline stays disabled.
|
|
slog.Warn("github: PR snapshot pipeline disabled (invalid App private key)", "err", err)
|
|
}
|
|
h.PRRefresh = ghsnapshot.NewManager(ghClient, queries, txStarter, h.broadcastPRSnapshotApplied)
|
|
|
|
return h
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
// Marshal the body up front so we can advertise an accurate Content-Length
|
|
// header. Streaming straight into the ResponseWriter after WriteHeader forces
|
|
// net/http into chunked transfer encoding, which omits Content-Length; buffering
|
|
// first lets clients (and proxies) see the exact body size.
|
|
body, err := json.Marshal(v)
|
|
if err != nil {
|
|
// Fall back to a minimal, self-describing error payload rather than leaving
|
|
// the client with a half-written response.
|
|
body = []byte(`{"error":"failed to encode response"}`)
|
|
status = http.StatusInternalServerError
|
|
}
|
|
// Match the trailing newline that json.Encoder.Encode historically appended.
|
|
body = append(body, '\n')
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write(body)
|
|
}
|
|
|
|
// writeMeasuredJSON behaves like writeJSON but returns the encoded body size so
|
|
// callers can record payload bytes in slow-endpoint diagnostics. It measures the
|
|
// uncompressed JSON length and is unrelated to transport compression.
|
|
func writeMeasuredJSON(w http.ResponseWriter, status int, v any) (int, error) {
|
|
body, err := json.Marshal(v)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to encode response")
|
|
return 0, err
|
|
}
|
|
body = append(body, '\n')
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
|
w.WriteHeader(status)
|
|
if _, err := w.Write(body); err != nil {
|
|
return len(body), err
|
|
}
|
|
return len(body), nil
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
}
|
|
|
|
// writeErrorCode is writeError plus a stable machine-readable code, so a UI
|
|
// can translate the failure instead of toasting the English sentence at a
|
|
// user whose console is in another language. The sentence stays as the
|
|
// fallback for anything that has not been given a translation yet.
|
|
func writeErrorCode(w http.ResponseWriter, status int, code, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg, "code": code})
|
|
}
|
|
|
|
func writeRevisionConflict(w http.ResponseWriter, resourceType string, resourceID pgtype.UUID, expected, actual int64) {
|
|
writeJSON(w, http.StatusConflict, map[string]any{
|
|
"error": "resource changed since it was loaded",
|
|
"code": "revision_conflict",
|
|
"resource_type": resourceType,
|
|
"resource_id": uuidToString(resourceID),
|
|
"expected_revision": expected,
|
|
"actual_revision": actual,
|
|
})
|
|
}
|
|
|
|
func writeEditConflict(w http.ResponseWriter, resourceType string, resourceID pgtype.UUID) {
|
|
writeJSON(w, http.StatusConflict, map[string]any{
|
|
"error": "resource field changed since it was loaded",
|
|
"code": "revision_conflict",
|
|
"resource_type": resourceType,
|
|
"resource_id": uuidToString(resourceID),
|
|
})
|
|
}
|
|
|
|
// Thin wrappers around util functions.
|
|
//
|
|
// parseUUID is intentionally the panicking variant: any handler call site
|
|
// reachable here is expected to feed a UUID that is either (a) a sqlc round-trip
|
|
// of a DB-sourced value, or (b) a raw request input that has already been
|
|
// validated upstream. A panic here means an unguarded user-input string slipped
|
|
// in — that is a real bug we want surfaced loudly (chi's middleware.Recoverer
|
|
// converts it to a 500) instead of silently corrupting data via a zero UUID.
|
|
//
|
|
// For unvalidated user input at request boundaries, use parseUUIDOrBadRequest
|
|
// (writes 400) — never feed raw chi.URLParam / request-body strings into
|
|
// parseUUID directly when the call writes to the database.
|
|
func parseUUID(s string) pgtype.UUID { return util.MustParseUUID(s) }
|
|
func uuidToString(u pgtype.UUID) string { return util.UUIDToString(u) }
|
|
func textToPtr(t pgtype.Text) *string { return util.TextToPtr(t) }
|
|
func ptrToText(s *string) pgtype.Text { return util.PtrToText(s) }
|
|
func strToText(s string) pgtype.Text { return util.StrToText(s) }
|
|
func timestampToString(t pgtype.Timestamptz) string { return util.TimestampToString(t) }
|
|
func timestampToPtr(t pgtype.Timestamptz) *string { return util.TimestampToPtr(t) }
|
|
func timestampToNanoPtr(t pgtype.Timestamptz) *string {
|
|
return util.TimestampToNanoPtr(t)
|
|
}
|
|
func dateToPtr(d pgtype.Date) *string { return util.DateToPtr(d) }
|
|
func uuidToPtr(u pgtype.UUID) *string { return util.UUIDToPtr(u) }
|
|
|
|
// uuidsToStrings maps a UUID array column to string ids, skipping NULL/invalid
|
|
// entries. Returns nil (not an empty slice) when there is nothing to emit so
|
|
// `omitempty` JSON fields drop out cleanly (MUL-4195).
|
|
func uuidsToStrings(us []pgtype.UUID) []string {
|
|
if len(us) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(us))
|
|
for _, u := range us {
|
|
if u.Valid {
|
|
out = append(out, uuidToString(u))
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// uuidStringsOrEmpty preserves the distinction between a modern, authoritative
|
|
// empty UUID-array value (`[]`) and a field omitted by a legacy server. Delivery
|
|
// receipts use this so clients never mistake zero delivered comments for an
|
|
// unknown receipt and fall back to the enqueue-time plan.
|
|
func uuidStringsOrEmpty(us []pgtype.UUID) []string {
|
|
out := uuidsToStrings(us)
|
|
if out == nil {
|
|
return []string{}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func int8ToPtr(v pgtype.Int8) *int64 { return util.Int8ToPtr(v) }
|
|
func int4ToPtr(v pgtype.Int4) *int32 { return util.Int4ToPtr(v) }
|
|
func ptrToInt4(v *int32) pgtype.Int4 { return util.PtrToInt4(v) }
|
|
|
|
// parseUUIDOrBadRequest validates a UUID string sourced from user input
|
|
// (URL params, request body, headers). On invalid input it writes a 400
|
|
// response and returns ok=false; callers must return immediately.
|
|
//
|
|
// Use this anywhere a malformed UUID would otherwise reach a write query
|
|
// (DELETE / UPDATE) — the silent zero-UUID behavior of the old ParseUUID
|
|
// caused real silent-data-loss bugs (#1661).
|
|
func parseUUIDOrBadRequest(w http.ResponseWriter, s, fieldName string) (pgtype.UUID, bool) {
|
|
u, err := util.ParseUUID(s)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid "+fieldName)
|
|
return pgtype.UUID{}, false
|
|
}
|
|
return u, true
|
|
}
|
|
|
|
func parseUUIDSliceOrBadRequest(w http.ResponseWriter, ids []string, fieldName string) ([]pgtype.UUID, bool) {
|
|
uuids := make([]pgtype.UUID, len(ids))
|
|
for i, id := range ids {
|
|
u, err := util.ParseUUID(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid "+fieldName)
|
|
return nil, false
|
|
}
|
|
uuids[i] = u
|
|
}
|
|
return uuids, true
|
|
}
|
|
|
|
// DeclareChannelFileDelivery records that this deployment can put a file the
|
|
// agent produced into a channelType conversation. Call it at boot, from the
|
|
// same branch that gives the adapter what it needs to perform the delivery —
|
|
// the point is that one condition produces both the capability and the claim
|
|
// it is announced under, so a deployment cannot end up promising a hop it does
|
|
// not have.
|
|
//
|
|
// Not safe to call once the server is serving: the map it writes is read
|
|
// without a lock by every task claim.
|
|
func (h *Handler) DeclareChannelFileDelivery(channelType string) {
|
|
if channelType == "" {
|
|
return
|
|
}
|
|
if h.channelFileDelivery == nil {
|
|
h.channelFileDelivery = map[string]bool{}
|
|
}
|
|
h.channelFileDelivery[channelType] = true
|
|
}
|
|
|
|
// channelDeliversFiles answers chat_channel_delivers_files for one claim.
|
|
//
|
|
// The default is false in every direction that is not an explicit declaration:
|
|
// a channel nobody declared, a deployment with no object storage, an adapter
|
|
// that was never wired. False is the safe answer because of what the two
|
|
// answers cost. False when the file could have travelled loses a delivery the
|
|
// agent then describes in words. True when it cannot has the agent write "the
|
|
// chart is attached" into a room where nothing is attached, and the reader is
|
|
// left hunting for it.
|
|
//
|
|
// Web chat (empty channel type) is not answered here. It has no adapter and no
|
|
// last hop to have — the browser renders the attachment card off the same bind
|
|
// — and the prompt handles it in its own branch.
|
|
func (h *Handler) channelDeliversFiles(channelType string) bool {
|
|
if channelType == "" {
|
|
return false
|
|
}
|
|
return h.channelFileDelivery[channelType]
|
|
}
|
|
|
|
// publish sends a domain event through the event bus.
|
|
func (h *Handler) publish(eventType, workspaceID, actorType, actorID string, payload any) {
|
|
h.Bus.Publish(events.Event{
|
|
Type: eventType,
|
|
WorkspaceID: workspaceID,
|
|
ActorType: actorType,
|
|
ActorID: actorID,
|
|
Payload: payload,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) notifyDaemonWorkspacesChanged(userIDs ...string) {
|
|
if h.DaemonWorkspaceRefresh == nil {
|
|
return
|
|
}
|
|
seen := make(map[string]struct{}, len(userIDs))
|
|
for _, userID := range userIDs {
|
|
if userID == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[userID]; ok {
|
|
continue
|
|
}
|
|
seen[userID] = struct{}{}
|
|
h.DaemonWorkspaceRefresh.NotifyWorkspacesChanged(userID)
|
|
}
|
|
}
|
|
|
|
// publishTask is publish() plus a TaskID hint so the realtime layer can route
|
|
// the event to the per-task scope rather than the whole workspace.
|
|
func (h *Handler) publishTask(eventType, workspaceID, actorType, actorID, taskID string, payload any) {
|
|
h.Bus.Publish(events.Event{
|
|
Type: eventType,
|
|
WorkspaceID: workspaceID,
|
|
ActorType: actorType,
|
|
ActorID: actorID,
|
|
TaskID: taskID,
|
|
Payload: payload,
|
|
})
|
|
}
|
|
|
|
// publishChat is publish() plus a ChatSessionID hint so the realtime layer
|
|
// can route the event to the per-chat-session scope.
|
|
func (h *Handler) publishChat(eventType, workspaceID, actorType, actorID, chatSessionID string, payload any) {
|
|
h.Bus.Publish(events.Event{
|
|
Type: eventType,
|
|
WorkspaceID: workspaceID,
|
|
ActorType: actorType,
|
|
ActorID: actorID,
|
|
ChatSessionID: chatSessionID,
|
|
Payload: payload,
|
|
})
|
|
}
|
|
|
|
func isNotFound(err error) bool {
|
|
return errors.Is(err, pgx.ErrNoRows)
|
|
}
|
|
|
|
func isUniqueViolation(err error) bool {
|
|
var pgErr *pgconn.PgError
|
|
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
|
}
|
|
|
|
// isCheckViolation reports whether err is a PostgreSQL CHECK constraint
|
|
// violation (SQLSTATE 23514). Used to translate column-level CHECK failures
|
|
// into a 4xx instead of a generic 500.
|
|
func isCheckViolation(err error) bool {
|
|
var pgErr *pgconn.PgError
|
|
return errors.As(err, &pgErr) && pgErr.Code == "23514"
|
|
}
|
|
|
|
func requestUserID(r *http.Request) string {
|
|
return r.Header.Get("X-User-ID")
|
|
}
|
|
|
|
// resolveActor determines whether the request is from an agent or a human member.
|
|
//
|
|
// First-class signal: X-Actor-Source set to "task_token" means the request
|
|
// authenticated via an `mat_` task-scoped token. The auth middleware sets
|
|
// that header (and stripped any client-supplied value first), so it is
|
|
// authoritative — the bound (agent_id, task_id) cannot be forged or
|
|
// stripped by the agent process. This is the path MUL-2600 relies on to
|
|
// reject agent-process traffic on owner-only endpoints.
|
|
//
|
|
// Fallback signal (legacy CLI / member-token paths): the request MUST
|
|
// carry both X-Agent-ID and a valid X-Task-ID, and the task must belong
|
|
// to the claimed agent. Otherwise we fall back to "member".
|
|
//
|
|
// X-Agent-ID alone is not trusted: any workspace member can guess or observe
|
|
// an agent's UUID, and a member-supplied X-Agent-ID would otherwise let that
|
|
// member impersonate the agent and bypass the private-agent gate (#2359
|
|
// review). The daemon always pairs the two headers, so requiring both has
|
|
// no effect on legitimate agent callers but closes the impersonation path.
|
|
//
|
|
// Returns ("agent", agentID) on success, ("member", userID) otherwise.
|
|
func (h *Handler) resolveActor(r *http.Request, userID, workspaceID string) (actorType, actorID string) {
|
|
if r.Header.Get("X-Actor-Source") == "task_token" {
|
|
// Server-set header — auth middleware also forced X-Agent-ID
|
|
// from the token row. Trust it directly without re-querying.
|
|
return "agent", r.Header.Get("X-Agent-ID")
|
|
}
|
|
agentID := r.Header.Get("X-Agent-ID")
|
|
if agentID == "" {
|
|
return "member", userID
|
|
}
|
|
taskID := r.Header.Get("X-Task-ID")
|
|
if taskID == "" {
|
|
slog.Debug("resolveActor: X-Agent-ID present but X-Task-ID missing, refusing to trust agent identity", "agent_id", agentID)
|
|
return "member", userID
|
|
}
|
|
|
|
agentUUID, err := util.ParseUUID(agentID)
|
|
if err != nil {
|
|
slog.Debug("resolveActor: X-Agent-ID is not a valid UUID, falling back to member", "agent_id", agentID)
|
|
return "member", userID
|
|
}
|
|
// Validate the agent exists in the target workspace.
|
|
agent, err := h.Queries.GetAgent(r.Context(), agentUUID)
|
|
if err != nil || uuidToString(agent.WorkspaceID) != workspaceID {
|
|
slog.Debug("resolveActor: X-Agent-ID rejected, agent not found or workspace mismatch", "agent_id", agentID, "workspace_id", workspaceID)
|
|
return "member", userID
|
|
}
|
|
|
|
taskUUID, err := util.ParseUUID(taskID)
|
|
if err != nil {
|
|
slog.Debug("resolveActor: X-Task-ID is not a valid UUID, falling back to member", "task_id", taskID)
|
|
return "member", userID
|
|
}
|
|
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
|
|
if err != nil || uuidToString(task.AgentID) != agentID {
|
|
slog.Debug("resolveActor: X-Task-ID rejected, task not found or agent mismatch", "agent_id", agentID, "task_id", taskID)
|
|
return "member", userID
|
|
}
|
|
|
|
return "agent", agentID
|
|
}
|
|
|
|
func requireUserID(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
userID := requestUserID(r)
|
|
if userID == "" {
|
|
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
|
return "", false
|
|
}
|
|
return userID, true
|
|
}
|
|
|
|
// resolveWorkspaceID returns the workspace UUID for this request. Delegates
|
|
// to middleware.ResolveWorkspaceIDFromRequest so middleware-protected routes
|
|
// and middleware-less routes (e.g. /api/upload-file) share identical
|
|
// resolution behavior — including slug → UUID translation via the DB.
|
|
//
|
|
// Returns "" when no workspace identifier was provided or a slug was provided
|
|
// but doesn't match any workspace.
|
|
func (h *Handler) resolveWorkspaceID(r *http.Request) string {
|
|
return middleware.ResolveWorkspaceIDFromRequest(r, h.Queries)
|
|
}
|
|
|
|
// ctxMember returns the workspace member from context (set by workspace middleware).
|
|
func ctxMember(ctx context.Context) (db.Member, bool) {
|
|
return middleware.MemberFromContext(ctx)
|
|
}
|
|
|
|
// ctxWorkspaceID returns the workspace ID from context (set by workspace middleware).
|
|
func ctxWorkspaceID(ctx context.Context) string {
|
|
return middleware.WorkspaceIDFromContext(ctx)
|
|
}
|
|
|
|
// workspaceIDFromURL returns the workspace ID from context (preferred) or chi URL param (fallback).
|
|
func workspaceIDFromURL(r *http.Request, param string) string {
|
|
if id := middleware.WorkspaceIDFromContext(r.Context()); id != "" {
|
|
return id
|
|
}
|
|
return chi.URLParam(r, param)
|
|
}
|
|
|
|
// workspaceMember returns the member from middleware context, or falls back to a DB
|
|
// lookup when the handler is called directly (e.g. in tests).
|
|
func (h *Handler) workspaceMember(w http.ResponseWriter, r *http.Request, workspaceID string) (db.Member, bool) {
|
|
if m, ok := ctxMember(r.Context()); ok {
|
|
return m, true
|
|
}
|
|
return h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
|
|
}
|
|
|
|
func roleAllowed(role string, roles ...string) bool {
|
|
for _, candidate := range roles {
|
|
if role == candidate {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func countOwners(members []db.Member) int {
|
|
owners := 0
|
|
for _, member := range members {
|
|
if member.Role == "owner" {
|
|
owners++
|
|
}
|
|
}
|
|
return owners
|
|
}
|
|
|
|
func (h *Handler) getWorkspaceMember(ctx context.Context, userID, workspaceID string) (db.Member, error) {
|
|
userUUID, err := util.ParseUUID(userID)
|
|
if err != nil {
|
|
return db.Member{}, err
|
|
}
|
|
wsUUID, err := util.ParseUUID(workspaceID)
|
|
if err != nil {
|
|
return db.Member{}, err
|
|
}
|
|
return h.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
|
|
UserID: userUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) requireWorkspaceMember(w http.ResponseWriter, r *http.Request, workspaceID, notFoundMsg string) (db.Member, bool) {
|
|
if workspaceID == "" {
|
|
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
|
return db.Member{}, false
|
|
}
|
|
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return db.Member{}, false
|
|
}
|
|
|
|
member, err := h.getWorkspaceMember(r.Context(), userID, workspaceID)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, notFoundMsg)
|
|
return db.Member{}, false
|
|
}
|
|
|
|
return member, true
|
|
}
|
|
|
|
func (h *Handler) requireWorkspaceRole(w http.ResponseWriter, r *http.Request, workspaceID, notFoundMsg string, roles ...string) (db.Member, bool) {
|
|
member, ok := h.requireWorkspaceMember(w, r, workspaceID, notFoundMsg)
|
|
if !ok {
|
|
return db.Member{}, false
|
|
}
|
|
if !roleAllowed(member.Role, roles...) {
|
|
writeError(w, http.StatusForbidden, "insufficient permissions")
|
|
return db.Member{}, false
|
|
}
|
|
return member, true
|
|
}
|
|
|
|
// isWorkspaceEntity checks whether a user_id belongs to the given workspace,
|
|
// as either a member or an agent depending on userType.
|
|
func (h *Handler) isWorkspaceEntity(ctx context.Context, userType, userID, workspaceID string) bool {
|
|
switch userType {
|
|
case "member":
|
|
_, err := h.getWorkspaceMember(ctx, userID, workspaceID)
|
|
return err == nil
|
|
case "agent":
|
|
userUUID, err := util.ParseUUID(userID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
wsUUID, err := util.ParseUUID(workspaceID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
_, err = h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
|
|
ID: userUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
return err == nil
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (h *Handler) loadIssueForUser(w http.ResponseWriter, r *http.Request, issueID string) (db.Issue, bool) {
|
|
if _, ok := requireUserID(w, r); !ok {
|
|
return db.Issue{}, false
|
|
}
|
|
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
if workspaceID == "" {
|
|
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
|
return db.Issue{}, false
|
|
}
|
|
|
|
// Try identifier format first (e.g., "JIA-42"). resolveIssueByIdentifier
|
|
// silently returns false for non-identifier strings, falling through to
|
|
// the UUID path below.
|
|
if issue, ok := h.resolveIssueByIdentifier(r.Context(), issueID, workspaceID); ok {
|
|
if !h.authorizeIssueWindow(w, r, issue.ID, issue.WorkspaceID, "direct") {
|
|
return db.Issue{}, false
|
|
}
|
|
return issue, true
|
|
}
|
|
|
|
issueUUID, err := util.ParseUUID(issueID)
|
|
if err != nil {
|
|
// Not a valid UUID and didn't match identifier format → 404 (consistent
|
|
// with previous silent-zero behavior, which would also have produced 404).
|
|
writeError(w, http.StatusNotFound, "issue not found")
|
|
return db.Issue{}, false
|
|
}
|
|
wsUUID, err := util.ParseUUID(workspaceID)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid workspace_id")
|
|
return db.Issue{}, false
|
|
}
|
|
issue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
|
ID: issueUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "issue not found")
|
|
return db.Issue{}, false
|
|
}
|
|
if !h.authorizeIssueWindow(w, r, issue.ID, issue.WorkspaceID, "direct") {
|
|
return db.Issue{}, false
|
|
}
|
|
return issue, true
|
|
}
|
|
|
|
// resolveIssueByIdentifier tries to look up an issue by "PREFIX-NUMBER" format.
|
|
//
|
|
// The prefix must match the workspace's own issue prefix, the same rule
|
|
// `lookupIssueByIdentifier` applies to VCS webhooks. Without it every prefix
|
|
// resolved to the same issue number, so `FOO-134` and `TRS-134` were
|
|
// interchangeable — which makes the identifier URL `/{ws}/issues/{key}`
|
|
// unusable as a canonical link.
|
|
func (h *Handler) resolveIssueByIdentifier(ctx context.Context, id, workspaceID string) (db.Issue, bool) {
|
|
parts := splitIdentifier(id)
|
|
if parts == nil {
|
|
return db.Issue{}, false
|
|
}
|
|
if workspaceID == "" {
|
|
return db.Issue{}, false
|
|
}
|
|
wsUUID, err := util.ParseUUID(workspaceID)
|
|
if err != nil {
|
|
return db.Issue{}, false
|
|
}
|
|
// Case-insensitive: a hand-typed `trs-134` should open `TRS-134`.
|
|
prefix := h.getIssuePrefix(ctx, wsUUID)
|
|
if prefix == "" || !strings.EqualFold(parts.prefix, prefix) {
|
|
return db.Issue{}, false
|
|
}
|
|
issue, err := h.Queries.GetIssueByNumber(ctx, db.GetIssueByNumberParams{
|
|
WorkspaceID: wsUUID,
|
|
Number: parts.number,
|
|
})
|
|
if err != nil {
|
|
return db.Issue{}, false
|
|
}
|
|
return issue, true
|
|
}
|
|
|
|
type identifierParts struct {
|
|
prefix string
|
|
number int32
|
|
}
|
|
|
|
func splitIdentifier(id string) *identifierParts {
|
|
idx := -1
|
|
for i := len(id) - 1; i >= 0; i-- {
|
|
if id[i] == '-' {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx <= 0 || idx >= len(id)-1 {
|
|
return nil
|
|
}
|
|
numStr := id[idx+1:]
|
|
num := 0
|
|
for _, c := range numStr {
|
|
if c < '0' || c > '9' {
|
|
return nil
|
|
}
|
|
num = num*10 + int(c-'0')
|
|
// Guard the int32 conversion below: a UUID whose last group happens to
|
|
// be all digits ("…-421234567890") reaches here and would otherwise be
|
|
// truncated into a plausible-looking issue number.
|
|
if num > math.MaxInt32 {
|
|
return nil
|
|
}
|
|
}
|
|
if num <= 0 {
|
|
return nil
|
|
}
|
|
return &identifierParts{prefix: id[:idx], number: int32(num)}
|
|
}
|
|
|
|
// issuePrefixForWorkspace resolves a workspace row's effective issue prefix:
|
|
// the configured value, or one generated from the workspace name when the
|
|
// stored prefix is empty (e.g. workspaces created before the prefix was
|
|
// introduced). Split out from getIssuePrefix so callers that already hold the
|
|
// row — such as the GitHub close-intent scan, which must not re-read it — can
|
|
// reuse the rule.
|
|
//
|
|
// The empty-prefix fallback stays on the FROZEN name-based derivation, not the
|
|
// slug-based one new workspaces get (MUL-6050): identifiers are computed at
|
|
// read time, so switching this path would rewrite the identifier of every
|
|
// issue in those legacy workspaces. New workspaces always persist a prefix at
|
|
// creation, so they never reach this branch.
|
|
func issuePrefixForWorkspace(ws db.Workspace) string {
|
|
if ws.IssuePrefix != "" {
|
|
return ws.IssuePrefix
|
|
}
|
|
return legacyIssuePrefixFromName(ws.Name)
|
|
}
|
|
|
|
// getIssuePrefix fetches the effective issue_prefix for a workspace, and
|
|
// returns "" when the workspace row cannot be loaded.
|
|
func (h *Handler) getIssuePrefix(ctx context.Context, workspaceID pgtype.UUID) string {
|
|
ws, err := h.Queries.GetWorkspace(ctx, workspaceID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return issuePrefixForWorkspace(ws)
|
|
}
|
|
|
|
func (h *Handler) loadAgentForUser(w http.ResponseWriter, r *http.Request, agentID string) (db.Agent, bool) {
|
|
if _, ok := requireUserID(w, r); !ok {
|
|
return db.Agent{}, false
|
|
}
|
|
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
if workspaceID == "" {
|
|
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
|
return db.Agent{}, false
|
|
}
|
|
|
|
agentUUID, ok := parseUUIDOrBadRequest(w, agentID, "agent id")
|
|
if !ok {
|
|
return db.Agent{}, false
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return db.Agent{}, false
|
|
}
|
|
|
|
agent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
|
|
ID: agentUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "agent not found")
|
|
return db.Agent{}, false
|
|
}
|
|
if agent.Kind != "user" {
|
|
writeError(w, http.StatusNotFound, "agent not found")
|
|
return db.Agent{}, false
|
|
}
|
|
return agent, true
|
|
}
|
|
|
|
func (h *Handler) loadInboxItemForUser(w http.ResponseWriter, r *http.Request, itemID string) (db.InboxItem, bool) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return db.InboxItem{}, false
|
|
}
|
|
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
if workspaceID == "" {
|
|
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
|
return db.InboxItem{}, false
|
|
}
|
|
|
|
itemUUID, ok := parseUUIDOrBadRequest(w, itemID, "inbox item id")
|
|
if !ok {
|
|
return db.InboxItem{}, false
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return db.InboxItem{}, false
|
|
}
|
|
|
|
item, err := h.Queries.GetInboxItemInWorkspace(r.Context(), db.GetInboxItemInWorkspaceParams{
|
|
ID: itemUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "inbox item not found")
|
|
return db.InboxItem{}, false
|
|
}
|
|
|
|
if item.RecipientType != "member" || uuidToString(item.RecipientID) != userID {
|
|
writeError(w, http.StatusNotFound, "inbox item not found")
|
|
return db.InboxItem{}, false
|
|
}
|
|
if item.IssueID.Valid && !h.authorizeIssueWindow(w, r, item.IssueID, item.WorkspaceID, "inbox") {
|
|
return db.InboxItem{}, false
|
|
}
|
|
return item, true
|
|
}
|