mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 18:17:44 +02:00
Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
710 lines
28 KiB
Go
710 lines
28 KiB
Go
// Package composio is the Stage 2 business-integration glue between Multica and
|
|
// the standalone Composio SDK (server/pkg/composio). It owns Multica semantics:
|
|
// the signed-state connect handshake, the local user_composio_connection
|
|
// mirror, idempotent disconnect, and the per-user MCP session helper.
|
|
//
|
|
// It deliberately does NOT wrap the SDK in another HTTP client — it composes
|
|
// *sdk.Client directly through the SDK interface so tests can drop in a fake.
|
|
//
|
|
// MVP scope (MUL-3720): toolkits are discovered dynamically. The
|
|
// toolkit→auth-config mapping is resolved at request time from Composio's
|
|
// /auth_configs endpoint (cached briefly), so a toolkit becomes connectable the
|
|
// moment an auth config is enabled for it in the Composio dashboard — no env
|
|
// var and no redeploy. A toolkit with no enabled auth config is rejected.
|
|
package composio
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
sdk "github.com/multica-ai/multica/server/pkg/composio"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// Service-level errors surfaced to the handler layer.
|
|
var (
|
|
// ErrToolkitNotSupported is returned by BeginConnect when the requested
|
|
// toolkit has no enabled auth config in the Composio project, so there is
|
|
// no auth_config_id to start a connect link with.
|
|
ErrToolkitNotSupported = errors.New("composio: toolkit not supported")
|
|
// ErrConnectNotSuccessful is returned by CompleteCallback when Composio
|
|
// reported a non-success status — no active row is written.
|
|
ErrConnectNotSuccessful = errors.New("composio: connection was not successful")
|
|
// ErrConnectionNotFound is returned by Disconnect when the connection id
|
|
// does not belong to the user (or does not exist).
|
|
ErrConnectionNotFound = errors.New("composio: connection not found")
|
|
// ErrAccountVerification is returned by CompleteCallback when the
|
|
// connected_account_id carried on the callback cannot be confirmed (with
|
|
// Composio) to belong to the user/auth-config named in the signed state —
|
|
// i.e. a tampered or unknown account id. No local row is written.
|
|
ErrAccountVerification = errors.New("composio: connected account verification failed")
|
|
)
|
|
|
|
// defaultStateTTL bounds how long a connect handshake may sit between
|
|
// BeginConnect and the Composio callback. Five minutes is generous for a hosted
|
|
// OAuth flow while keeping the replay window small.
|
|
const defaultStateTTL = 5 * time.Minute
|
|
|
|
// defaultAuthCacheTTL bounds how long the resolved toolkit→auth-config map is
|
|
// cached before a re-fetch from Composio. Short enough that enabling an auth
|
|
// config in the dashboard reflects within minutes; long enough that a burst of
|
|
// connect/list requests does not hammer /auth_configs.
|
|
const defaultAuthCacheTTL = 5 * time.Minute
|
|
|
|
// maxAuthConfigPages / maxToolkitPages cap the paginated fetch-all loops so a
|
|
// pathological or buggy upstream cursor cannot spin forever. At limit=1000 per
|
|
// page these cover far more than any real project / catalog.
|
|
const (
|
|
maxAuthConfigPages = 20
|
|
maxToolkitPages = 20
|
|
listPageLimit = 1000
|
|
composioLogoBaseURL = "https://logos.composio.dev/api"
|
|
)
|
|
|
|
// SDK is the subset of *sdk.Client the service depends on. Declared as an
|
|
// interface so handler/service tests can inject a fake without hitting Composio.
|
|
// *sdk.Client satisfies it.
|
|
type SDK interface {
|
|
CreateLink(ctx context.Context, req sdk.CreateLinkRequest) (*sdk.CreateLinkResponse, error)
|
|
ListConnectedAccounts(ctx context.Context, req sdk.ListConnectedAccountsRequest) (*sdk.ListConnectedAccountsResponse, error)
|
|
ListAuthConfigs(ctx context.Context, req sdk.ListAuthConfigsRequest) (*sdk.ListAuthConfigsResponse, error)
|
|
ListToolkits(ctx context.Context, req sdk.ListToolkitsRequest) (*sdk.ListToolkitsResponse, error)
|
|
RevokeConnection(ctx context.Context, connectedAccountID string) error
|
|
DeleteConnectedAccount(ctx context.Context, connectedAccountID string) error
|
|
CreateSession(ctx context.Context, req sdk.CreateSessionRequest) (*sdk.CreateSessionResponse, error)
|
|
MCPAuthHeaders() map[string]string
|
|
}
|
|
|
|
// Store is the persistence seam for the local connection mirror. *db.Queries
|
|
// satisfies it; tests use an in-memory fake.
|
|
type Store interface {
|
|
UpsertUserComposioConnection(ctx context.Context, arg db.UpsertUserComposioConnectionParams) (db.UserComposioConnection, error)
|
|
ListActiveUserComposioConnections(ctx context.Context, userID pgtype.UUID) ([]db.UserComposioConnection, error)
|
|
GetUserComposioConnection(ctx context.Context, arg db.GetUserComposioConnectionParams) (db.UserComposioConnection, error)
|
|
MarkUserComposioConnectionRevoked(ctx context.Context, arg db.MarkUserComposioConnectionRevokedParams) error
|
|
}
|
|
|
|
// Config configures a Service.
|
|
type Config struct {
|
|
// StateSecret signs the connect-state HMAC. Required (non-empty).
|
|
StateSecret []byte
|
|
// CallbackBaseURL is the absolute, public base URL of THIS API, with no
|
|
// trailing slash (e.g. "https://multica.ai"). The Composio callback
|
|
// URL is built as CallbackBaseURL + CallbackPath. Required.
|
|
CallbackBaseURL string
|
|
// FrontendBaseURL is the web app base used to build the post-callback
|
|
// browser redirect (e.g. "https://multica.ai"). May be empty, in which
|
|
// case CallbackRedirect returns a site-relative path.
|
|
FrontendBaseURL string
|
|
// StateTTL overrides the default connect-state lifetime. Zero uses
|
|
// defaultStateTTL.
|
|
StateTTL time.Duration
|
|
// AuthConfigTTL overrides how long the resolved toolkit→auth-config map is
|
|
// cached. Zero uses defaultAuthCacheTTL.
|
|
AuthConfigTTL time.Duration
|
|
// Now is overridable for deterministic tests. Nil uses time.Now.
|
|
Now func() time.Time
|
|
}
|
|
|
|
// callbackPath is the API path Composio redirects the browser back to. It is a
|
|
// constant (not configurable) so the SDK callback URL and the router route
|
|
// cannot drift apart.
|
|
const callbackPath = "/api/integrations/composio/callback"
|
|
|
|
// Service is the Composio business-integration service.
|
|
type Service struct {
|
|
sdk SDK
|
|
store Store
|
|
secret []byte
|
|
callbackURL string
|
|
frontendURL string
|
|
stateTTL time.Duration
|
|
now func() time.Time
|
|
|
|
// authCache holds the resolved toolkit_slug → auth_config_id map for the
|
|
// project. It is rebuilt from Composio's /auth_configs endpoint on first
|
|
// use and whenever authCacheExp has passed; authCacheMu guards both fields.
|
|
authCacheMu sync.Mutex
|
|
authCache map[string]string
|
|
authCacheExp time.Time
|
|
authCacheTTL time.Duration
|
|
}
|
|
|
|
// NewService validates its inputs and returns a ready Service. It errors when a
|
|
// required dependency is missing so a misconfigured boot fails loudly instead
|
|
// of returning 500s at request time.
|
|
func NewService(client SDK, store Store, cfg Config) (*Service, error) {
|
|
if client == nil {
|
|
return nil, errors.New("composio: SDK client is required")
|
|
}
|
|
if store == nil {
|
|
return nil, errors.New("composio: store is required")
|
|
}
|
|
if len(cfg.StateSecret) == 0 {
|
|
return nil, errors.New("composio: StateSecret is required")
|
|
}
|
|
base := strings.TrimRight(strings.TrimSpace(cfg.CallbackBaseURL), "/")
|
|
if base == "" {
|
|
return nil, errors.New("composio: CallbackBaseURL is required")
|
|
}
|
|
|
|
ttl := cfg.StateTTL
|
|
if ttl <= 0 {
|
|
ttl = defaultStateTTL
|
|
}
|
|
authTTL := cfg.AuthConfigTTL
|
|
if authTTL <= 0 {
|
|
authTTL = defaultAuthCacheTTL
|
|
}
|
|
now := cfg.Now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
|
|
return &Service{
|
|
sdk: client,
|
|
store: store,
|
|
secret: cfg.StateSecret,
|
|
callbackURL: base + callbackPath,
|
|
frontendURL: strings.TrimRight(strings.TrimSpace(cfg.FrontendBaseURL), "/"),
|
|
stateTTL: ttl,
|
|
now: now,
|
|
authCacheTTL: authTTL,
|
|
}, nil
|
|
}
|
|
|
|
// Connection is the API-facing view of a local connection row. The Composio
|
|
// connected_account_id and auth_config_id are intentionally omitted — they are
|
|
// server-internal handles, not API surface.
|
|
type Connection struct {
|
|
ID string `json:"id"`
|
|
ToolkitSlug string `json:"toolkit_slug"`
|
|
Status string `json:"status"`
|
|
ConnectedAt string `json:"connected_at"`
|
|
LastUsedAt *string `json:"last_used_at"`
|
|
}
|
|
|
|
// MCPSession is the result of CreateMCPSession: the streamable MCP URL plus the
|
|
// headers an MCP client must attach. Headers carry the Composio x-api-key, so
|
|
// callers must route them through the redact pipeline before logging.
|
|
type MCPSession struct {
|
|
URL string
|
|
Headers map[string]string
|
|
}
|
|
|
|
// ToolkitView is the API-facing descriptor for one Composio toolkit, carrying
|
|
// exactly the fields the Settings UI renders plus a Connectable flag.
|
|
//
|
|
// Connectable means the project has an enabled auth config for the toolkit, so
|
|
// BeginConnect would succeed. Since MUL-4009 ListToolkits only returns
|
|
// connectable toolkits, so this is always true on the wire; the field is
|
|
// retained for backward compatibility with older desktop clients that branch on
|
|
// it (removing it would make them treat every entry as non-connectable).
|
|
type ToolkitView struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
LogoURL string `json:"logo,omitempty"`
|
|
Category string `json:"category,omitempty"`
|
|
Connectable bool `json:"connectable"`
|
|
}
|
|
|
|
func toolkitLogoURL(slug, upstreamLogoURL string) string {
|
|
if upstreamLogoURL != "" {
|
|
return upstreamLogoURL
|
|
}
|
|
slug = strings.ToLower(strings.TrimSpace(slug))
|
|
if slug == "" {
|
|
return ""
|
|
}
|
|
return composioLogoBaseURL + "/" + url.PathEscape(slug)
|
|
}
|
|
|
|
// BeginConnect validates the toolkit, mints a signed state, and asks Composio
|
|
// for a hosted Connect Link. The returned redirect URL is where the caller
|
|
// sends the user's browser.
|
|
//
|
|
// The auth_config_id is resolved dynamically from the project's enabled auth
|
|
// configs (cached), so a toolkit is connectable iff the dashboard has an auth
|
|
// config for it — no static env map. A toolkit with none yields
|
|
// ErrToolkitNotSupported.
|
|
//
|
|
// The composio_user_id sent to Composio is the Multica user id verbatim — the
|
|
// invariant the rest of the integration relies on.
|
|
func (s *Service) BeginConnect(ctx context.Context, userID pgtype.UUID, toolkitSlug string) (string, error) {
|
|
slug := strings.ToLower(strings.TrimSpace(toolkitSlug))
|
|
authConfigID, err := s.authConfigForToolkit(ctx, slug)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if authConfigID == "" {
|
|
return "", ErrToolkitNotSupported
|
|
}
|
|
if !userID.Valid {
|
|
return "", errors.New("composio: invalid user id")
|
|
}
|
|
composioUserID := util.UUIDToString(userID)
|
|
|
|
state, err := signState(s.secret, stateClaims{
|
|
UserID: composioUserID,
|
|
ToolkitSlug: slug,
|
|
AuthConfigID: authConfigID,
|
|
Exp: s.now().Add(s.stateTTL).Unix(),
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("composio: sign state: %w", err)
|
|
}
|
|
|
|
// Composio appends its own status / connected_account_id query params to
|
|
// the callback URL and preserves ours, so the signed state rides back to us
|
|
// on the redirect.
|
|
callbackURL := s.callbackURL + "?state=" + url.QueryEscape(state)
|
|
|
|
resp, err := s.sdk.CreateLink(ctx, sdk.CreateLinkRequest{
|
|
AuthConfigID: authConfigID,
|
|
UserID: composioUserID,
|
|
CallbackURL: callbackURL,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("composio: create link: %w", err)
|
|
}
|
|
return resp.RedirectURL, nil
|
|
}
|
|
|
|
// CompleteCallback verifies the signed state and, on a successful Composio
|
|
// status, upserts the local connection row. It returns the toolkit slug from
|
|
// the state so the handler can build the right redirect even on the
|
|
// not-successful path.
|
|
//
|
|
// Idempotency: the upsert is keyed on (user_id, connected_account_id), so a
|
|
// duplicate callback re-activates the same row instead of creating a second.
|
|
func (s *Service) CompleteCallback(ctx context.Context, state, status, connectedAccountID string) (string, error) {
|
|
claims, err := verifyState(s.secret, state, s.now())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if !strings.EqualFold(strings.TrimSpace(status), "success") {
|
|
// Honor the state for the redirect slug, but do not write an active row.
|
|
return claims.ToolkitSlug, ErrConnectNotSuccessful
|
|
}
|
|
if strings.TrimSpace(connectedAccountID) == "" {
|
|
return claims.ToolkitSlug, errors.New("composio: callback missing connected_account_id")
|
|
}
|
|
|
|
userID, err := util.ParseUUID(claims.UserID)
|
|
if err != nil {
|
|
return claims.ToolkitSlug, fmt.Errorf("composio: state has invalid user id: %w", err)
|
|
}
|
|
|
|
// The auth_config_id was resolved at BeginConnect and signed into the state,
|
|
// so we compare against THAT exact value rather than re-resolving here (a
|
|
// re-resolve that failed or drifted would otherwise fail-open: a missing
|
|
// expected auth config used to skip the check, letting another toolkit's
|
|
// account id be bound under this toolkit's slug). An empty value fails
|
|
// closed in verifyAccountOwnership.
|
|
authConfigID := claims.AuthConfigID
|
|
|
|
// Defense-in-depth (PR 4608 review): the signed state proves *who* started
|
|
// the handshake and *which* toolkit, but connected_account_id rides back as
|
|
// a plain query param Composio appends to our callback URL. A crafted
|
|
// redirect could pair a valid, un-expired state with someone else's account
|
|
// id and we would mirror it verbatim. Before writing, confirm with Composio
|
|
// that this account actually belongs to the state's user (the
|
|
// composio_user_id == multica user id invariant) and was created under the
|
|
// toolkit's auth config. Any mismatch fails closed with ErrAccountVerification.
|
|
if err := s.verifyAccountOwnership(ctx, connectedAccountID, claims.UserID, authConfigID); err != nil {
|
|
return claims.ToolkitSlug, err
|
|
}
|
|
|
|
if _, err := s.store.UpsertUserComposioConnection(ctx, db.UpsertUserComposioConnectionParams{
|
|
UserID: userID,
|
|
ToolkitSlug: claims.ToolkitSlug,
|
|
AuthConfigID: authConfigID,
|
|
ConnectedAccountID: connectedAccountID,
|
|
// Invariant: composio_user_id == Multica user id.
|
|
ComposioUserID: claims.UserID,
|
|
}); err != nil {
|
|
return claims.ToolkitSlug, fmt.Errorf("composio: upsert connection: %w", err)
|
|
}
|
|
return claims.ToolkitSlug, nil
|
|
}
|
|
|
|
// ListConnections returns the user's active connections.
|
|
func (s *Service) ListConnections(ctx context.Context, userID pgtype.UUID) ([]Connection, error) {
|
|
rows, err := s.store.ListActiveUserComposioConnections(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]Connection, 0, len(rows))
|
|
for _, row := range rows {
|
|
out = append(out, rowToConnection(row))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Disconnect revokes and deletes the connection at Composio, then marks the
|
|
// local row revoked. It is idempotent: a Composio 404 (already gone) is treated
|
|
// as success, and re-revoking an already-revoked local row is a no-op.
|
|
//
|
|
// A connection id that does not belong to the user (or does not exist at all)
|
|
// returns ErrConnectionNotFound so the handler can 404 without leaking
|
|
// existence across users.
|
|
func (s *Service) Disconnect(ctx context.Context, userID, connectionID pgtype.UUID) error {
|
|
row, err := s.store.GetUserComposioConnection(ctx, db.GetUserComposioConnectionParams{
|
|
ID: connectionID,
|
|
UserID: userID,
|
|
})
|
|
if err != nil {
|
|
// pgx.ErrNoRows or fake "not found" — treat as not found for the owner.
|
|
return ErrConnectionNotFound
|
|
}
|
|
|
|
// Already disconnected locally: a repeat DELETE is a pure no-op. Short-
|
|
// circuiting here keeps disconnect idempotent even when the upstream now
|
|
// answers revoke/delete with a NON-404 error (PR 4608 review): the account
|
|
// is already gone, so re-hitting Composio could turn a second DELETE into a
|
|
// 502 and break the 204-idempotent contract. The first disconnect already
|
|
// revoked upstream and marked the row.
|
|
if !strings.EqualFold(row.Status, "active") {
|
|
return nil
|
|
}
|
|
|
|
// Revoke the upstream grant first, then delete the Composio record. Both are
|
|
// made idempotent against a 404 so a repeated disconnect (or a connection
|
|
// already removed at Composio) still resolves the local row.
|
|
if err := s.sdk.RevokeConnection(ctx, row.ConnectedAccountID); err != nil && !isNotFound(err) {
|
|
return fmt.Errorf("composio: revoke connection: %w", err)
|
|
}
|
|
// DeleteConnectedAccount already swallows 404 in the SDK, but guard anyway.
|
|
if err := s.sdk.DeleteConnectedAccount(ctx, row.ConnectedAccountID); err != nil && !isNotFound(err) {
|
|
return fmt.Errorf("composio: delete connected account: %w", err)
|
|
}
|
|
|
|
if err := s.store.MarkUserComposioConnectionRevoked(ctx, db.MarkUserComposioConnectionRevokedParams{
|
|
ID: connectionID,
|
|
UserID: userID,
|
|
}); err != nil {
|
|
return fmt.Errorf("composio: mark revoked: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CreateMCPSession opens a Composio tool-router (MCP) session scoped to the
|
|
// user's active connections. It returns (nil, nil) when the user has no active
|
|
// connections — callers treat that as "no MCP overlay for this user".
|
|
//
|
|
// connected_accounts is pinned per toolkit to the user's own connected account
|
|
// id array so the session cannot surface accounts the user did not connect. This
|
|
// helper is NOT yet wired into task dispatch (Stage 3); it exists so that wiring
|
|
// is a pure consumer of an already-tested seam.
|
|
//
|
|
// Single-account constraint (v1, PR 4608 review follow-up): the MVP connect
|
|
// flow assumes AT MOST ONE active connection per (user, toolkit) — there is no
|
|
// UI or API to hold several. Should duplicates ever exist, we must choose
|
|
// deterministically: rows arrive newest-first (ListActive orders by
|
|
// connected_at DESC), so we keep the FIRST occurrence per toolkit (the most
|
|
// recently connected account) instead of letting a later map write silently
|
|
// select an older one.
|
|
//
|
|
// Stage 3 owns the real decision before this is wired into dispatch: either
|
|
// enforce the single-active constraint at connect time (revoke the previous
|
|
// active row for the same toolkit on a new connect) or extend CreateSession to
|
|
// a multi-account request shape. Until then, newest-wins keeps behavior
|
|
// deterministic rather than order-dependent.
|
|
func (s *Service) CreateMCPSession(ctx context.Context, userID pgtype.UUID) (*MCPSession, error) {
|
|
rows, err := s.store.ListActiveUserComposioConnections(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
connectedAccounts := make(map[string]any, len(rows))
|
|
for _, row := range rows {
|
|
// Keep the first (newest) account per toolkit; ignore older duplicates.
|
|
if _, exists := connectedAccounts[row.ToolkitSlug]; exists {
|
|
continue
|
|
}
|
|
connectedAccounts[row.ToolkitSlug] = []string{row.ConnectedAccountID}
|
|
}
|
|
|
|
resp, err := s.sdk.CreateSession(ctx, sdk.CreateSessionRequest{
|
|
UserID: util.UUIDToString(userID),
|
|
ConnectedAccounts: connectedAccounts,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("composio: create session: %w", err)
|
|
}
|
|
return &MCPSession{
|
|
URL: resp.MCP.URL,
|
|
Headers: s.sdk.MCPAuthHeaders(),
|
|
}, nil
|
|
}
|
|
|
|
// CallbackRedirect builds the browser redirect target for the callback handler.
|
|
// On success it points at the settings page (Integrations tab) with the
|
|
// connected toolkit slug; on failure it carries a stable error code. The path
|
|
// is the slug-less `/settings?tab=integrations&...` form on purpose: the web
|
|
// proxy's legacy-route redirect prepends the user's last workspace slug, so it
|
|
// resolves to the real `/{slug}/settings?tab=integrations` route that mounts
|
|
// the Composio tab. The older `/settings/integrations` path was NOT a real
|
|
// route and 404'd after the legacy rewrite. When FrontendBaseURL is unset it
|
|
// returns a site-relative path.
|
|
func (s *Service) CallbackRedirect(slug string, success bool) string {
|
|
var path string
|
|
if success {
|
|
path = "/settings?tab=integrations&connected=" + url.QueryEscape(slug)
|
|
} else {
|
|
path = "/settings?tab=integrations&error=composio_connect_failed"
|
|
}
|
|
return s.frontendURL + path
|
|
}
|
|
|
|
// rowToConnection maps a DB row to the API-facing Connection view.
|
|
func rowToConnection(row db.UserComposioConnection) Connection {
|
|
c := Connection{
|
|
ID: util.UUIDToString(row.ID),
|
|
ToolkitSlug: row.ToolkitSlug,
|
|
Status: row.Status,
|
|
}
|
|
if row.ConnectedAt.Valid {
|
|
c.ConnectedAt = row.ConnectedAt.Time.UTC().Format(time.RFC3339)
|
|
}
|
|
c.LastUsedAt = util.TimestampToPtr(row.LastUsedAt)
|
|
return c
|
|
}
|
|
|
|
// ListToolkits returns only the Composio toolkits the project can actually
|
|
// connect (those with an enabled auth config). Toolkits with no enabled auth
|
|
// config are filtered out entirely (MUL-4009): a card the user can't act on is
|
|
// noise, and the old "Not configured" grey label existed only to avoid a dead
|
|
// button — dropping the entry removes the need for it. It fetches all pages
|
|
// (capped by maxToolkitPages) so the UI gets the complete connectable list in
|
|
// one call; the catalog is a few hundred entries, well within a single JSON
|
|
// response. Every returned entry has Connectable == true, preserving Composio's
|
|
// usage order.
|
|
//
|
|
// Unlike the previous behavior, a resolver error is NOT masked into an
|
|
// everything-not-connectable catalog: with filtering in place that would render
|
|
// as a silent "no apps configured" empty state, which is misleading. We return
|
|
// the error so the handler can surface a 502 and the UI can show its honest
|
|
// load-failed state instead.
|
|
func (s *Service) ListToolkits(ctx context.Context) ([]ToolkitView, error) {
|
|
// connectable is the project's enabled toolkit_slug → auth_config_id map.
|
|
// It is the source of truth for what to surface: only slugs present here
|
|
// are returned.
|
|
connectable, err := s.authConfigMap(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("composio: resolve connectable toolkits: %w", err)
|
|
}
|
|
|
|
out := []ToolkitView{}
|
|
seen := make(map[string]struct{})
|
|
cursor := ""
|
|
for page := 0; page < maxToolkitPages; page++ {
|
|
resp, err := s.sdk.ListToolkits(ctx, sdk.ListToolkitsRequest{
|
|
Limit: listPageLimit,
|
|
Cursor: cursor,
|
|
SortBy: "usage",
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("composio: list toolkits: %w", err)
|
|
}
|
|
for _, tk := range resp.Items {
|
|
slug := strings.ToLower(strings.TrimSpace(tk.Slug))
|
|
if slug == "" {
|
|
continue
|
|
}
|
|
if _, dup := seen[slug]; dup {
|
|
continue
|
|
}
|
|
seen[slug] = struct{}{}
|
|
// Filter out toolkits with no enabled auth config: the user has no
|
|
// working action for them, so they are omitted from the catalog.
|
|
if _, canConnect := connectable[slug]; !canConnect {
|
|
continue
|
|
}
|
|
category := ""
|
|
if len(tk.Categories) > 0 {
|
|
category = tk.Categories[0]
|
|
}
|
|
out = append(out, ToolkitView{
|
|
Slug: tk.Slug,
|
|
Name: tk.Name,
|
|
LogoURL: toolkitLogoURL(slug, tk.LogoURL),
|
|
Category: category,
|
|
// Every surfaced toolkit is connectable by construction. The
|
|
// wire field is kept (see ComposioToolkitResponse) for backward
|
|
// compatibility with older desktop clients that branch on it.
|
|
Connectable: true,
|
|
})
|
|
}
|
|
if resp.NextCursor == "" {
|
|
break
|
|
}
|
|
cursor = resp.NextCursor
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// authConfigForToolkit returns the chosen auth_config_id for a toolkit slug, or
|
|
// "" when the project has no enabled auth config for it. It reads the cached
|
|
// project-wide map (refreshed on TTL).
|
|
func (s *Service) authConfigForToolkit(ctx context.Context, slug string) (string, error) {
|
|
slug = strings.ToLower(strings.TrimSpace(slug))
|
|
if slug == "" {
|
|
return "", nil
|
|
}
|
|
m, err := s.authConfigMap(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return m[slug], nil
|
|
}
|
|
|
|
// authConfigMap returns the toolkit_slug → auth_config_id map for the project,
|
|
// rebuilding it from Composio when the cache is empty or expired. Concurrent
|
|
// callers serialize on authCacheMu; the fetch runs under the lock, which is
|
|
// acceptable for a short-TTL map that is cheap to build and read by a
|
|
// low-traffic settings surface. A new map is assigned on refresh (never mutated
|
|
// in place), so a reference handed to a caller stays safe to read.
|
|
func (s *Service) authConfigMap(ctx context.Context) (map[string]string, error) {
|
|
s.authCacheMu.Lock()
|
|
defer s.authCacheMu.Unlock()
|
|
if s.authCache != nil && s.now().Before(s.authCacheExp) {
|
|
return s.authCache, nil
|
|
}
|
|
m, err := s.fetchAuthConfigMap(ctx)
|
|
if err != nil {
|
|
// Serve a stale snapshot if we have one — a transient /auth_configs
|
|
// blip should not make every toolkit suddenly un-connectable.
|
|
if s.authCache != nil {
|
|
return s.authCache, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
s.authCache = m
|
|
s.authCacheExp = s.now().Add(s.authCacheTTL)
|
|
return m, nil
|
|
}
|
|
|
|
// authCandidate is one project auth config in contention to represent a toolkit
|
|
// during resolution.
|
|
type authCandidate struct {
|
|
id string
|
|
managed bool
|
|
updated string
|
|
}
|
|
|
|
// fetchAuthConfigMap pages through the project's ENABLED auth configs and
|
|
// reduces them to one chosen auth_config_id per toolkit slug. When a toolkit
|
|
// has several (e.g. a Composio-managed one plus a custom white-label one),
|
|
// betterAuthConfig picks the winner.
|
|
func (s *Service) fetchAuthConfigMap(ctx context.Context) (map[string]string, error) {
|
|
best := make(map[string]authCandidate)
|
|
cursor := ""
|
|
for page := 0; page < maxAuthConfigPages; page++ {
|
|
resp, err := s.sdk.ListAuthConfigs(ctx, sdk.ListAuthConfigsRequest{
|
|
ShowDisabled: false,
|
|
Limit: listPageLimit,
|
|
Cursor: cursor,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("composio: list auth configs: %w", err)
|
|
}
|
|
for _, ac := range resp.Items {
|
|
if ac.ID == "" || strings.EqualFold(ac.Status, "DISABLED") {
|
|
continue
|
|
}
|
|
slug := strings.ToLower(strings.TrimSpace(ac.Toolkit.Slug))
|
|
if slug == "" {
|
|
continue
|
|
}
|
|
cand := authCandidate{id: ac.ID, managed: ac.IsComposioManaged, updated: ac.LastUpdatedAt}
|
|
if cur, ok := best[slug]; !ok || betterAuthConfig(cand, cur) {
|
|
best[slug] = cand
|
|
}
|
|
}
|
|
if resp.NextCursor == "" {
|
|
break
|
|
}
|
|
cursor = resp.NextCursor
|
|
}
|
|
out := make(map[string]string, len(best))
|
|
for slug, c := range best {
|
|
out[slug] = c.id
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// betterAuthConfig reports whether candidate a should win over the currently
|
|
// selected b for the same toolkit. A custom (bring-your-own OAuth) config beats
|
|
// a Composio-managed one — it is the white-label path the product wants — and
|
|
// among configs of the same kind the most recently updated wins.
|
|
func betterAuthConfig(a, b authCandidate) bool {
|
|
if a.managed != b.managed {
|
|
return !a.managed
|
|
}
|
|
return a.updated > b.updated
|
|
}
|
|
|
|
// verifyAccountOwnership confirms with Composio that connectedAccountID really
|
|
// belongs to expectedUserID and was created under expectedAuthConfigID, so a
|
|
// tampered or cross-toolkit connected_account_id on the callback cannot smuggle
|
|
// another account into the local mirror. It fails closed: an upstream error, an
|
|
// unknown account, an owner mismatch, an EMPTY expected auth config, or an
|
|
// auth-config mismatch all return ErrAccountVerification. Requiring a non-empty,
|
|
// exactly-matching auth config is what closes the cross-toolkit binding gap —
|
|
// the expected value is the auth_config_id signed into the state at
|
|
// BeginConnect, which is toolkit-specific.
|
|
func (s *Service) verifyAccountOwnership(ctx context.Context, connectedAccountID, expectedUserID, expectedAuthConfigID string) error {
|
|
resp, err := s.sdk.ListConnectedAccounts(ctx, sdk.ListConnectedAccountsRequest{
|
|
ConnectedAccountIDs: []string{connectedAccountID},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("composio: verify connected account: %w", err)
|
|
}
|
|
var acct *sdk.ConnectedAccount
|
|
for i := range resp.Items {
|
|
if resp.Items[i].ID == connectedAccountID {
|
|
acct = &resp.Items[i]
|
|
break
|
|
}
|
|
}
|
|
if acct == nil {
|
|
return ErrAccountVerification
|
|
}
|
|
if acct.UserID != expectedUserID {
|
|
return ErrAccountVerification
|
|
}
|
|
// Fail closed: the account MUST have been created under the exact auth
|
|
// config the connect link used. An empty expected value (state missing it,
|
|
// or a resolver gap) is rejected rather than skipped — skipping is the
|
|
// fail-open hole that let a cross-toolkit account id be bound here.
|
|
accountAuthConfigID := acct.AuthConfigID
|
|
if accountAuthConfigID == "" {
|
|
accountAuthConfigID = acct.AuthConfig.ID
|
|
}
|
|
if expectedAuthConfigID == "" || accountAuthConfigID != expectedAuthConfigID {
|
|
return ErrAccountVerification
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isNotFound reports whether err is a Composio 404 APIError, used to make
|
|
// revoke/delete idempotent.
|
|
func isNotFound(err error) bool {
|
|
var apiErr *sdk.APIError
|
|
return errors.As(err, &apiErr) && apiErr.IsNotFound()
|
|
}
|