mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-13 05:16:29 +02:00
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720) Compose the merged server/pkg/composio SDK into a user-facing connection manager: signed-state connect handshake, local user_composio_connection mirror, idempotent disconnect, and a per-user MCP session helper (not yet wired into task dispatch). - migration 127_user_composio_connection (no FK/cascade, per DB rules) - sqlc queries: upsert (idempotent on user_id+connected_account_id), list active, owner-scoped get, mark revoked - internal/integrations/composio: signed HMAC-SHA256 state, BeginConnect, CompleteCallback (idempotent upsert), ListConnections, Disconnect (upstream 404 = idempotent success), CreateMCPSession (no-op when empty, pins connected_accounts per toolkit), CallbackRedirect - REST handlers under /api/integrations/composio (user-scoped, 503 when COMPOSIO_API_KEY unset): connect/init, callback (302), connections list, delete - router wiring gated by COMPOSIO_API_KEY; COMPOSIO_AUTH_CONFIGS_JSON maps toolkit->auth_config (MVP: notion); state secret from COMPOSIO_STATE_SECRET or derived from JWT_SECRET; callback base from COMPOSIO_CALLBACK_BASE_URL or MULTICA_PUBLIC_URL - tests: state (expire/tamper/wrong-secret), service (mapping, callback idempotency, non-success, disconnect owner/404 idempotency, MCP pin), handlers (httptest), redact regression for Bearer mcp_ tokens MVP scope: Notion only; no task-dispatch overlay, sharing, or webhook event handling (later stages). Co-authored-by: multica-agent <github@multica.ai> * fix(composio): bind callback account to user + idempotent revoked disconnect (MUL-3720) Address PR 4608 review (CHANGES_REQUESTED): - callback: verify connected_account_id with Composio before mirroring it. The signed state only proved user/toolkit/exp, so a valid state paired with a tampered connected_account_id would be written verbatim. CompleteCallback now calls ListConnectedAccounts and fails closed (ErrAccountVerification) unless the account belongs to the state's user (composio_user_id == multica user id) and was created under the toolkit's auth config. No row is written on mismatch / unknown account / upstream error. - disconnect: short-circuit to a no-op when the local row is already revoked, before touching upstream. Previously a second DELETE re-hit Composio and a non-404 upstream error surfaced as a 502, breaking the 204-idempotent contract. - CreateMCPSession: document the v1 single-active-connection-per-(user,toolkit) constraint and make duplicate selection deterministic (newest-wins, rows are connected_at DESC) instead of order-dependent map overwrite. Stage 3 owns the real single-account-enforcement vs multi-account-shape decision. Tests: tampered/wrong-auth-config/unknown-account callback rejection, revoked-row disconnect no-op (asserts upstream not re-hit). composio pkg 85% coverage; all green. Co-authored-by: multica-agent <github@multica.ai> * feat(composio): list all toolkits + dynamic auth-config resolution (MUL-3720) Yushen's follow-up to the Notion MVP: surface the full Composio toolkit catalog, render it in Settings, and drop the static env mapping in favor of dynamic auth-config discovery. Config correctness (per Composio docs): - Remove COMPOSIO_AUTH_CONFIGS_JSON entirely. The toolkit→auth_config mapping is now resolved at request time from the project's /auth_configs (cached, 5-min TTL), so enabling a toolkit is a dashboard action, not a redeploy. - Do NOT add COMPOSIO_PROJECT_ID. The project API key (x-api-key) authenticates to exactly one project; the project is resolved from the key. Only org-level endpoints use x-org-api-key, which this integration never calls. Backend: - SDK: server/pkg/composio/auth_configs.go — ListAuthConfigs (toolkit_slug, is_composio_managed, show_disabled, limit, cursor). - service: dynamic resolver (authConfigMap cache; betterAuthConfig prefers a custom/white-label config over Composio-managed, newest wins); BeginConnect and CompleteCallback resolve via it; ListToolkits fetches the full catalog (paginated, capped) annotated with connectable = has an enabled auth config, connectable-first ordering. - handler + route: GET /api/integrations/composio/toolkits (user-scoped, 503 when COMPOSIO_API_KEY unset) returning slug/name/logo/category/connectable. Frontend: - core: ComposioToolkit/ComposioConnection types, api client methods, and composio query options (@multica/core/composio). - views: Settings → Integrations now has a Composio section rendering every toolkit as a card with search. Connect is gated on `connectable`; non-connectable toolkits show a muted "not configured" hint instead of a dead button. Connected toolkits show a badge + Disconnect (with confirm). - i18n: composio block added to en/zh-Hans/ja/ko settings. Tests: SDK + service (dynamic resolution, custom-over-managed preference, connectable flag, resolver-error soft-degrade) and handler toolkits endpoint; composio pkg 85.7% coverage. go build/vet/gofmt clean; core+views typecheck, core+views lint, and core tests (691) all green. Co-authored-by: multica-agent <github@multica.ai> * fix(composio): close cross-toolkit callback fail-open by signing auth_config_id into state (MUL-3720) Re-review blocker: CompleteCallback resolved the toolkit's auth config at callback time and ignored a resolve error/empty result, while verifyAccountOwnership skipped the auth-config comparison when the expected value was empty. A user could then pass another toolkit's connected_account_id into this toolkit's callback — the owner check passed and it was written under the wrong toolkit_slug/account binding. Fix: the auth_config_id is already resolved in BeginConnect (before the state is signed), so sign it into the state and compare it exactly at callback. No re-resolve, no fail-open. verifyAccountOwnership now fails closed when the expected auth config is empty (rejects instead of skipping) and requires an exact match — closing the cross-toolkit binding gap. Tests: state round-trips auth_config_id; BeginConnect signs it; callback rejects wrong/cross-toolkit auth config and an empty (no-mapping) auth config fails closed. composio pkg 85.2% coverage, all green. Frontend (non-blocking): the Composio settings tab now surfaces an error when the connections query fails instead of silently rendering everything as unconnected. Co-authored-by: multica-agent <github@multica.ai> * fix(composio): hide Settings section entirely when integration unconfigured (MUL-3720) Decision (option 2, hide-then-merge): don't show a card that leaks the internal COMPOSIO_API_KEY env-var name to every end user. IntegrationsTab now gates the whole Composio section (heading + body) on the toolkits query — a 503 means the key is unset, so the section is withheld instead of rendering the not-configured card. Admin-only setup guidance is a later, role-gated affordance. Removed the notConfigured card (and now-unused ApiError import) from ComposioTab; it only mounts when configured. views typecheck + lint clean. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
156 lines
4.7 KiB
Go
156 lines
4.7 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: composio.sql
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const getUserComposioConnection = `-- name: GetUserComposioConnection :one
|
|
SELECT id, user_id, toolkit_slug, auth_config_id, connected_account_id, composio_user_id, status, connected_at, last_used_at, created_at, updated_at FROM user_composio_connection
|
|
WHERE id = $1 AND user_id = $2
|
|
`
|
|
|
|
type GetUserComposioConnectionParams struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
UserID pgtype.UUID `json:"user_id"`
|
|
}
|
|
|
|
// Owner-scoped lookup: a connection can only be read by the user who owns it,
|
|
// so one user cannot disconnect another's account by guessing the UUID.
|
|
func (q *Queries) GetUserComposioConnection(ctx context.Context, arg GetUserComposioConnectionParams) (UserComposioConnection, error) {
|
|
row := q.db.QueryRow(ctx, getUserComposioConnection, arg.ID, arg.UserID)
|
|
var i UserComposioConnection
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.ToolkitSlug,
|
|
&i.AuthConfigID,
|
|
&i.ConnectedAccountID,
|
|
&i.ComposioUserID,
|
|
&i.Status,
|
|
&i.ConnectedAt,
|
|
&i.LastUsedAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const listActiveUserComposioConnections = `-- name: ListActiveUserComposioConnections :many
|
|
SELECT id, user_id, toolkit_slug, auth_config_id, connected_account_id, composio_user_id, status, connected_at, last_used_at, created_at, updated_at FROM user_composio_connection
|
|
WHERE user_id = $1 AND status = 'active'
|
|
ORDER BY connected_at DESC
|
|
`
|
|
|
|
func (q *Queries) ListActiveUserComposioConnections(ctx context.Context, userID pgtype.UUID) ([]UserComposioConnection, error) {
|
|
rows, err := q.db.Query(ctx, listActiveUserComposioConnections, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []UserComposioConnection{}
|
|
for rows.Next() {
|
|
var i UserComposioConnection
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.ToolkitSlug,
|
|
&i.AuthConfigID,
|
|
&i.ConnectedAccountID,
|
|
&i.ComposioUserID,
|
|
&i.Status,
|
|
&i.ConnectedAt,
|
|
&i.LastUsedAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const markUserComposioConnectionRevoked = `-- name: MarkUserComposioConnectionRevoked :exec
|
|
UPDATE user_composio_connection
|
|
SET status = 'revoked', updated_at = now()
|
|
WHERE id = $1 AND user_id = $2
|
|
`
|
|
|
|
type MarkUserComposioConnectionRevokedParams struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
UserID pgtype.UUID `json:"user_id"`
|
|
}
|
|
|
|
// Idempotent: re-running on an already-revoked row is a no-op write. Scoped to
|
|
// the owner for defense-in-depth.
|
|
func (q *Queries) MarkUserComposioConnectionRevoked(ctx context.Context, arg MarkUserComposioConnectionRevokedParams) error {
|
|
_, err := q.db.Exec(ctx, markUserComposioConnectionRevoked, arg.ID, arg.UserID)
|
|
return err
|
|
}
|
|
|
|
const upsertUserComposioConnection = `-- name: UpsertUserComposioConnection :one
|
|
|
|
INSERT INTO user_composio_connection (
|
|
user_id, toolkit_slug, auth_config_id, connected_account_id, composio_user_id, status
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, 'active'
|
|
)
|
|
ON CONFLICT (user_id, connected_account_id) DO UPDATE SET
|
|
toolkit_slug = EXCLUDED.toolkit_slug,
|
|
auth_config_id = EXCLUDED.auth_config_id,
|
|
composio_user_id = EXCLUDED.composio_user_id,
|
|
status = 'active',
|
|
updated_at = now()
|
|
RETURNING id, user_id, toolkit_slug, auth_config_id, connected_account_id, composio_user_id, status, connected_at, last_used_at, created_at, updated_at
|
|
`
|
|
|
|
type UpsertUserComposioConnectionParams struct {
|
|
UserID pgtype.UUID `json:"user_id"`
|
|
ToolkitSlug string `json:"toolkit_slug"`
|
|
AuthConfigID string `json:"auth_config_id"`
|
|
ConnectedAccountID string `json:"connected_account_id"`
|
|
ComposioUserID string `json:"composio_user_id"`
|
|
}
|
|
|
|
// =====================
|
|
// User Composio Connection
|
|
// =====================
|
|
// Idempotent on (user_id, connected_account_id): a duplicate callback for the
|
|
// same connected account re-activates the row instead of inserting a second
|
|
// one. connected_at is preserved on conflict (first-connect time); updated_at
|
|
// moves so the reactivation is observable.
|
|
func (q *Queries) UpsertUserComposioConnection(ctx context.Context, arg UpsertUserComposioConnectionParams) (UserComposioConnection, error) {
|
|
row := q.db.QueryRow(ctx, upsertUserComposioConnection,
|
|
arg.UserID,
|
|
arg.ToolkitSlug,
|
|
arg.AuthConfigID,
|
|
arg.ConnectedAccountID,
|
|
arg.ComposioUserID,
|
|
)
|
|
var i UserComposioConnection
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.ToolkitSlug,
|
|
&i.AuthConfigID,
|
|
&i.ConnectedAccountID,
|
|
&i.ComposioUserID,
|
|
&i.Status,
|
|
&i.ConnectedAt,
|
|
&i.LastUsedAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|