connectors 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +35 -0
- data/CONNECTORS_FRAMEWORK.md +799 -0
- data/CONTRIBUTING.md +60 -0
- data/MCP_CLIENT.md +168 -0
- data/MIT-LICENSE +20 -0
- data/README.md +146 -0
- data/app/connectors/clickup/connector.rb +13 -0
- data/app/connectors/gmail/api.rb +114 -0
- data/app/connectors/gmail/connector.rb +921 -0
- data/app/connectors/gmail/mime_builder.rb +261 -0
- data/app/connectors/gmail/mime_parser.rb +106 -0
- data/app/connectors/gmail/polling.rb +154 -0
- data/app/connectors/remote_mcp/connector.rb +18 -0
- data/app/connectors/resend/connector.rb +218 -0
- data/app/controllers/concerns/connectors/grant_access.rb +43 -0
- data/app/controllers/connectors/actions_controller.rb +66 -0
- data/app/controllers/connectors/application_controller.rb +5 -0
- data/app/controllers/connectors/credentials_controller.rb +245 -0
- data/app/controllers/connectors/grants_controller.rb +123 -0
- data/app/controllers/connectors/mcp_controller.rb +88 -0
- data/app/controllers/connectors/oauth_controller.rb +134 -0
- data/app/controllers/connectors/types_controller.rb +144 -0
- data/app/controllers/connectors/webhooks_controller.rb +105 -0
- data/app/jobs/connectors/application_job.rb +4 -0
- data/app/jobs/connectors/deliver_webhook_job.rb +27 -0
- data/app/jobs/connectors/poll_job.rb +49 -0
- data/app/models/connectors/application_record.rb +5 -0
- data/app/models/connectors/credential_share.rb +31 -0
- data/app/models/connectors/grant.rb +103 -0
- data/app/models/connectors/mcp_authorization.rb +6 -0
- data/app/models/connectors/mcp_interaction.rb +6 -0
- data/app/models/connectors/poll_state.rb +17 -0
- data/app/models/connectors/webhook_event.rb +19 -0
- data/config/routes.rb +68 -0
- data/db/migrate/20260518210324_create_connectors_grants.rb +49 -0
- data/db/migrate/20260518214609_create_connectors_webhook_events.rb +35 -0
- data/db/migrate/20260521140000_create_connectors_credential_shares.rb +26 -0
- data/db/migrate/20260922120000_create_connectors_poll_states.rb +12 -0
- data/db/migrate/20260922130000_create_connectors_mcp_transactions.rb +18 -0
- data/docs/adding-connectors.md +92 -0
- data/docs/architecture.md +71 -0
- data/docs/releasing.md +60 -0
- data/lib/connectors/action.rb +90 -0
- data/lib/connectors/action_builder.rb +125 -0
- data/lib/connectors/action_runner.rb +99 -0
- data/lib/connectors/auth/scheme/api_key.rb +51 -0
- data/lib/connectors/auth/scheme/oauth2.rb +23 -0
- data/lib/connectors/auth/scheme.rb +35 -0
- data/lib/connectors/auth.rb +4 -0
- data/lib/connectors/auth_injection.rb +65 -0
- data/lib/connectors/client_builder.rb +87 -0
- data/lib/connectors/configuration.rb +138 -0
- data/lib/connectors/connector.rb +565 -0
- data/lib/connectors/credential_schema.rb +219 -0
- data/lib/connectors/credential_tester.rb +85 -0
- data/lib/connectors/credential_type_registry.rb +162 -0
- data/lib/connectors/credential_types/http_auth.rb +171 -0
- data/lib/connectors/engine.rb +123 -0
- data/lib/connectors/errors.rb +88 -0
- data/lib/connectors/grant_policy.rb +13 -0
- data/lib/connectors/mcp/access.rb +50 -0
- data/lib/connectors/mcp/authorization.rb +177 -0
- data/lib/connectors/mcp/authorization_context.rb +25 -0
- data/lib/connectors/mcp/authorization_discovery.rb +42 -0
- data/lib/connectors/mcp/cancellation.rb +36 -0
- data/lib/connectors/mcp/client.rb +137 -0
- data/lib/connectors/mcp/connection_config.rb +48 -0
- data/lib/connectors/mcp/http.rb +108 -0
- data/lib/connectors/mcp/interaction.rb +82 -0
- data/lib/connectors/mcp/pending_transaction.rb +26 -0
- data/lib/connectors/mcp/protocol/2026-07-28.json +3963 -0
- data/lib/connectors/mcp/protocol/LICENSE +216 -0
- data/lib/connectors/mcp/protocol/README.md +8 -0
- data/lib/connectors/mcp/protocol_schema.rb +30 -0
- data/lib/connectors/mcp/schema.rb +45 -0
- data/lib/connectors/mcp/settings.rb +27 -0
- data/lib/connectors/mcp/token_endpoint.rb +48 -0
- data/lib/connectors/mcp/transport.rb +105 -0
- data/lib/connectors/mcp.rb +69 -0
- data/lib/connectors/middleware/authenticate_generic.rb +79 -0
- data/lib/connectors/middleware/auto_refresh.rb +71 -0
- data/lib/connectors/middleware/error_normalization.rb +45 -0
- data/lib/connectors/middleware/grant_status.rb +19 -0
- data/lib/connectors/middleware/pre_authentication.rb +54 -0
- data/lib/connectors/middleware/rate_limit.rb +40 -0
- data/lib/connectors/oauth/authorize_url.rb +78 -0
- data/lib/connectors/oauth/client_authentication.rb +24 -0
- data/lib/connectors/oauth/client_credentials.rb +43 -0
- data/lib/connectors/oauth/grant_writer.rb +73 -0
- data/lib/connectors/oauth/pkce.rb +32 -0
- data/lib/connectors/oauth/revoke.rb +72 -0
- data/lib/connectors/oauth/state.rb +41 -0
- data/lib/connectors/oauth/token_exchange.rb +69 -0
- data/lib/connectors/oauth/token_response.rb +40 -0
- data/lib/connectors/oauth.rb +4 -0
- data/lib/connectors/oauth1.rb +151 -0
- data/lib/connectors/permission_check.rb +33 -0
- data/lib/connectors/poll_runner.rb +50 -0
- data/lib/connectors/pre_authentication_helpers.rb +76 -0
- data/lib/connectors/registry.rb +38 -0
- data/lib/connectors/version.rb +3 -0
- data/lib/connectors/webhook_context.rb +62 -0
- data/lib/connectors/webhook_lifecycle.rb +69 -0
- data/lib/connectors/webhook_methods.rb +48 -0
- data/lib/connectors/webhooks/verifier.rb +31 -0
- data/lib/connectors/webhooks.rb +5 -0
- data/lib/connectors.rb +50 -0
- data/lib/tasks/connectors_mcp.rake +9 -0
- data/lib/tasks/connectors_tasks.rake +4 -0
- data/openapi.yaml +1099 -0
- metadata +263 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
# Lists the current owner's authorized third-party accounts. The frontend
|
|
3
|
+
# uses this to render the "Connected accounts" picker on credential fields
|
|
4
|
+
# and the Settings → Connections page.
|
|
5
|
+
#
|
|
6
|
+
# Filter by connector with `?connector_key=slack`.
|
|
7
|
+
#
|
|
8
|
+
# Credentials are never serialized — only metadata (status, expiry, the
|
|
9
|
+
# provider-side account id, and the granted scope list).
|
|
10
|
+
class GrantsController < ApplicationController
|
|
11
|
+
rescue_from Connectors::Error, with: :render_unauthorized
|
|
12
|
+
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
|
|
13
|
+
rescue_from ArgumentError, with: :render_bad_request
|
|
14
|
+
|
|
15
|
+
# GET /connectors/grants
|
|
16
|
+
def index
|
|
17
|
+
owner = current_owner!
|
|
18
|
+
grants = Grant.where(owner: owner)
|
|
19
|
+
grants = grants.for_connector(params[:connector_key]) if params[:connector_key].present?
|
|
20
|
+
render json: { grants: grants.order(updated_at: :desc).map { |g| serialize(g) } }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# POST /connectors/grants/:id/test
|
|
24
|
+
# Runs the connector's declared `test_request` against the live
|
|
25
|
+
# provider using THIS grant's credentials. Returns `{status, message}`
|
|
26
|
+
# — n8n parity (packages/cli/src/credentials/credentials.controller.ts:141-152).
|
|
27
|
+
def test
|
|
28
|
+
owner = current_owner!
|
|
29
|
+
grant = Grant.where(owner: owner).find(params[:id])
|
|
30
|
+
result = Connectors::CredentialTester.run(grant)
|
|
31
|
+
status_code = result[:status] == Connectors::CredentialTester::OK ? :ok : :unprocessable_content
|
|
32
|
+
render json: result, status: status_code
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# POST /connectors/grants/:id/webhook_subscribe
|
|
36
|
+
# Runs the connector's `webhook_methods` create flow (skipping when
|
|
37
|
+
# check_exists already returns true). Manual entry point for Phase 6 —
|
|
38
|
+
# the workflow activation manager will call into this same lifecycle
|
|
39
|
+
# primitive (`Connectors::WebhookLifecycle.subscribe`) once it ships.
|
|
40
|
+
#
|
|
41
|
+
# Params:
|
|
42
|
+
# webhook_name=:default — group name (multi-webhook providers)
|
|
43
|
+
# hook_url=<override> — defaults to the per-grant or app-level URL
|
|
44
|
+
def webhook_subscribe
|
|
45
|
+
owner = current_owner!
|
|
46
|
+
grant = Grant.where(owner: owner).find(params[:id])
|
|
47
|
+
|
|
48
|
+
name = (params[:webhook_name].presence || :default).to_sym
|
|
49
|
+
url = params[:hook_url].presence || default_hook_url(grant)
|
|
50
|
+
|
|
51
|
+
result = Connectors::WebhookLifecycle.subscribe(grant, hook_url: url, webhook_name: name)
|
|
52
|
+
render json: result.merge(webhook_name: name, hook_url: url)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# DELETE /connectors/grants/:id/webhook_subscribe
|
|
56
|
+
def webhook_unsubscribe
|
|
57
|
+
owner = current_owner!
|
|
58
|
+
grant = Grant.where(owner: owner).find(params[:id])
|
|
59
|
+
name = (params[:webhook_name].presence || :default).to_sym
|
|
60
|
+
result = Connectors::WebhookLifecycle.unsubscribe(grant, webhook_name: name)
|
|
61
|
+
render json: result.merge(webhook_name: name)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# POST /connectors/grants/:id/poll
|
|
65
|
+
# Runs the connector's `polling` block once. Manual harness for Phase 8 —
|
|
66
|
+
# the workflow-side scheduler will call into `Connectors::PollRunner.run`
|
|
67
|
+
# directly once it ships.
|
|
68
|
+
def poll
|
|
69
|
+
owner = current_owner!
|
|
70
|
+
grant = Grant.where(owner: owner).find(params[:id])
|
|
71
|
+
result = Connectors::PollRunner.run(grant, instance_key: params[:instance_key])
|
|
72
|
+
render json: result
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def default_hook_url(grant)
|
|
78
|
+
base = Connectors.configuration.host_base_url.to_s.chomp("/")
|
|
79
|
+
mount = Connectors::Engine.mount_path
|
|
80
|
+
connector_class = Registry.fetch(grant.connector_key)
|
|
81
|
+
if connector_class.webhook_style == :app_level
|
|
82
|
+
"#{base}#{mount}/#{grant.connector_key}/webhook"
|
|
83
|
+
else
|
|
84
|
+
"#{base}#{mount}/#{grant.connector_key}/#{grant.id}/webhook"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def serialize(grant)
|
|
89
|
+
{
|
|
90
|
+
id: grant.id,
|
|
91
|
+
connector_key: grant.connector_key,
|
|
92
|
+
display_name: grant.display_name,
|
|
93
|
+
external_account_id: grant.external_account_id,
|
|
94
|
+
status: grant.status,
|
|
95
|
+
expires_at: grant.expires_at,
|
|
96
|
+
last_used_at: grant.last_used_at,
|
|
97
|
+
scopes: extract_scopes(grant)
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Most OAuth2 providers return scopes as a comma- or space-separated string
|
|
102
|
+
# in the token response and we store it verbatim. Normalize to an array so
|
|
103
|
+
# the frontend doesn't have to know the provider's separator convention.
|
|
104
|
+
def extract_scopes(grant)
|
|
105
|
+
raw = grant.credentials_hash["scope"]
|
|
106
|
+
return nil if raw.nil?
|
|
107
|
+
return raw if raw.is_a?(Array)
|
|
108
|
+
raw.to_s.split(/[,\s]+/).reject(&:empty?)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def render_unauthorized(exception)
|
|
112
|
+
render json: { error: exception.message }, status: :unauthorized
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def render_not_found(exception)
|
|
116
|
+
render json: { error: exception.message }, status: :not_found
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def render_bad_request(exception)
|
|
120
|
+
render json: { error: exception.message }, status: :bad_request
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
class McpController < ApplicationController
|
|
3
|
+
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
|
4
|
+
rescue_from MCP::Error, with: :mcp_error
|
|
5
|
+
|
|
6
|
+
def tools
|
|
7
|
+
render json: { tools: client.tools }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call_tool
|
|
11
|
+
render json: client.call_tool(name: params.require(:name), arguments: json_object(:arguments))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def resume
|
|
15
|
+
render json: client.resume(interaction_id: params.require(:interaction_id), responses: json_object(:responses))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def authorize
|
|
19
|
+
grant = Grant.find(params[:id])
|
|
20
|
+
access = MCP::Access.new(grant: grant, actor: current_owner!, principals: current_principals)
|
|
21
|
+
access.require!(:owner)
|
|
22
|
+
if params[:authorization_context].present?
|
|
23
|
+
challenge = MCP::AuthorizationContext.decode(params[:authorization_context], access: access)
|
|
24
|
+
return render json: { authorization_url: authorization.start(challenge: challenge) }
|
|
25
|
+
end
|
|
26
|
+
# Challenge data is obtained from the MCP endpoint, not supplied as arbitrary URLs by the caller.
|
|
27
|
+
service = client
|
|
28
|
+
challenge = begin
|
|
29
|
+
service.tools
|
|
30
|
+
{}
|
|
31
|
+
rescue MCP::AuthorizationRequired => error
|
|
32
|
+
error.challenge
|
|
33
|
+
end
|
|
34
|
+
render json: { authorization_url: authorization.start(challenge: challenge) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def callback
|
|
38
|
+
state = params.require(:state)
|
|
39
|
+
record = McpAuthorization.find_by!(state_digest: Digest::SHA256.hexdigest(state))
|
|
40
|
+
service = MCP::Authorization.new(grant: record.grant, actor: current_owner!, principals: current_principals)
|
|
41
|
+
service.complete(state: state, code: params[:code], iss: params[:iss], error: params[:error])
|
|
42
|
+
render json: { status: "authorized" }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def json_object(key)
|
|
48
|
+
value = params[key]
|
|
49
|
+
raise MCP::ValidationError, "#{key} must be an object" unless value.is_a?(ActionController::Parameters)
|
|
50
|
+
value.to_unsafe_h
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def client
|
|
54
|
+
MCP::Client.new(grant: Grant.find(params[:id]), actor: current_owner!, principals: current_principals)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def authorization
|
|
58
|
+
MCP::Authorization.new(grant: Grant.find(params[:id]), actor: current_owner!, principals: current_principals)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def mcp_error(error)
|
|
62
|
+
status, kind = case error
|
|
63
|
+
when MCP::AccessDenied then [ :forbidden, "access_denied" ]
|
|
64
|
+
when MCP::AuthorizationRequired then [ :unauthorized, "authorization_required" ]
|
|
65
|
+
when MCP::ConfigurationRequired then [ :unprocessable_content, "configuration_required" ]
|
|
66
|
+
when MCP::ValidationError then [ :unprocessable_content, "validation_error" ]
|
|
67
|
+
when MCP::ProtocolError then [ :bad_gateway, "protocol_error" ]
|
|
68
|
+
when MCP::HTTPError
|
|
69
|
+
error.status == 429 ? [ :too_many_requests, "rate_limited" ] : [ :bad_gateway, "transport_error" ]
|
|
70
|
+
else [ :bad_gateway, "transport_error" ]
|
|
71
|
+
end
|
|
72
|
+
body = { error: { type: kind, message: error.message } }
|
|
73
|
+
if kind == "rate_limited" && (retry_after = error.retry_after)
|
|
74
|
+
response.headers["Retry-After"] = retry_after
|
|
75
|
+
body[:error][:retry_after] = retry_after
|
|
76
|
+
end
|
|
77
|
+
if error.is_a?(MCP::AuthorizationRequired)
|
|
78
|
+
grant = Grant.find(params[:id])
|
|
79
|
+
body[:authorization_context] = MCP::AuthorizationContext.encode(grant: grant, challenge: error.challenge, fingerprint: error.fingerprint)
|
|
80
|
+
end
|
|
81
|
+
render json: body, status: status
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def not_found
|
|
85
|
+
head :not_found
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
require "global_id"
|
|
2
|
+
|
|
3
|
+
module Connectors
|
|
4
|
+
class OAuthController < ApplicationController
|
|
5
|
+
rescue_from Connectors::Error, with: :render_auth_error
|
|
6
|
+
rescue_from ActiveRecord::RecordNotFound, Connectors::UnknownConnector, with: :render_not_found
|
|
7
|
+
|
|
8
|
+
# Omit grant_id to connect a new account; supply it to reconnect an owned
|
|
9
|
+
# grant. Both popup and redirect entry points use the same authorization.
|
|
10
|
+
def authorize
|
|
11
|
+
start_authorization(json: false)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def authorize_json
|
|
15
|
+
start_authorization(json: true)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Split frontend/backend completion. Authorization is carried by the
|
|
19
|
+
# authenticated, encrypted state issued to the owner at the start.
|
|
20
|
+
def exchange
|
|
21
|
+
state = OAuth::State.decode(params[:state])
|
|
22
|
+
return invalid_state if state.nil?
|
|
23
|
+
|
|
24
|
+
grant = complete_authorization(state)
|
|
25
|
+
render json: grant_payload(grant).merge(
|
|
26
|
+
name: grant.display_name || "#{grant.connector_key} ##{grant.id}",
|
|
27
|
+
external_account_id: grant.external_account_id
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def callback
|
|
32
|
+
state = OAuth::State.decode(params[:state])
|
|
33
|
+
return invalid_state if state.nil? || state["k"] != params[:connector_key].to_s
|
|
34
|
+
|
|
35
|
+
grant = complete_authorization(state)
|
|
36
|
+
if state["r"].present?
|
|
37
|
+
redirect_to state["r"], allow_other_host: true
|
|
38
|
+
else
|
|
39
|
+
render json: grant_payload(grant)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def revoke
|
|
44
|
+
grant = find_visible_grant!(params[:id], min_role: :owner)
|
|
45
|
+
# Serialize revocation with refresh/reconnection on the same grant.
|
|
46
|
+
grant.with_lock do
|
|
47
|
+
require_grant_role!(grant, :owner)
|
|
48
|
+
OAuth::Revoke.call(Registry.fetch(grant.connector_key), grant)
|
|
49
|
+
grant.update!(status: :revoked)
|
|
50
|
+
end
|
|
51
|
+
render json: { grant_id: grant.id, status: grant.status }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def start_authorization(json:)
|
|
57
|
+
klass = Registry.fetch(params[:connector_key])
|
|
58
|
+
owner = current_owner!
|
|
59
|
+
grant = Grant.where(owner: owner).for_connector(klass.connector_key).find(params[:grant_id]) if params[:grant_id].present?
|
|
60
|
+
|
|
61
|
+
if klass.oauth2_config&.dig(:grant_type) == "clientCredentials"
|
|
62
|
+
writer = OAuth::GrantWriter.new(owner: owner, connector_class: klass,
|
|
63
|
+
grant_id: grant&.id, fingerprint: (OAuth::GrantWriter.fingerprint(grant) if grant))
|
|
64
|
+
connected = writer.call(OAuth::ClientCredentials.exchange(klass), name: params[:name])
|
|
65
|
+
return render json: grant_payload(connected)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
url = if klass.oauth1_config
|
|
69
|
+
oauth1_authorize_url(klass, owner, grant)
|
|
70
|
+
else
|
|
71
|
+
OAuth::AuthorizeUrl.for(klass, owner: owner, grant: grant,
|
|
72
|
+
return_to: params[:return_to], scope: params[:scope], name: params[:name])
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if json
|
|
76
|
+
render json: { authorize_url: url, connector_key: klass.connector_key.to_s }
|
|
77
|
+
else
|
|
78
|
+
redirect_to url, allow_other_host: true
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def oauth1_authorize_url(klass, owner, grant)
|
|
83
|
+
callback_url = Connectors.configuration.resolved_app_callback_url(klass.connector_key)
|
|
84
|
+
token = OAuth1.request_token(klass, callback_url: callback_url)
|
|
85
|
+
extras = { "ts" => token["oauth_token_secret"], "n" => params[:name] }
|
|
86
|
+
extras.merge!("g" => grant.id, "v" => OAuth::GrantWriter.fingerprint(grant)) if grant
|
|
87
|
+
state = OAuth::State.encode(connector_key: klass.connector_key,
|
|
88
|
+
owner_gid: owner.to_global_id.to_s, return_to: params[:return_to], extra: extras)
|
|
89
|
+
uri = URI.parse(klass.oauth1_config[:authorize_url])
|
|
90
|
+
uri.query = URI.encode_www_form(URI.decode_www_form(uri.query.to_s) +
|
|
91
|
+
[ [ "oauth_token", token["oauth_token"] ], [ "state", state ] ])
|
|
92
|
+
uri.to_s
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def complete_authorization(state)
|
|
96
|
+
klass = Registry.fetch(state["k"])
|
|
97
|
+
owner = locate_owner(state["o"])
|
|
98
|
+
raise ActiveRecord::RecordNotFound, "owner_not_found" unless owner
|
|
99
|
+
|
|
100
|
+
writer = OAuth::GrantWriter.new(owner: owner, connector_class: klass,
|
|
101
|
+
grant_id: state.dig("x", "g"), fingerprint: state.dig("x", "v"))
|
|
102
|
+
writer.validate!
|
|
103
|
+
tokens = if klass.oauth1_config
|
|
104
|
+
OAuth1.exchange_access_token(klass, oauth_token: params[:oauth_token],
|
|
105
|
+
oauth_verifier: params[:oauth_verifier], oauth_token_secret: state.dig("x", "ts"))
|
|
106
|
+
else
|
|
107
|
+
OAuth::TokenExchange.exchange_code(klass, code: params[:code], code_verifier: state.dig("x", "cv"))
|
|
108
|
+
end
|
|
109
|
+
writer.call(tokens, name: state.dig("x", "n"))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def locate_owner(gid)
|
|
113
|
+
GlobalID::Locator.locate(gid)
|
|
114
|
+
rescue ActiveRecord::RecordNotFound
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def grant_payload(grant)
|
|
119
|
+
{ grant_id: grant.id, connector_key: grant.connector_key, status: grant.status }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def invalid_state
|
|
123
|
+
render json: { error: "invalid_state" }, status: :unauthorized
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def render_not_found(exception)
|
|
127
|
+
render json: { error: exception.message }, status: :not_found
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def render_auth_error(exception)
|
|
131
|
+
render json: { error: exception.message }, status: :unauthorized
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
# Catalog of every connector class registered in the engine. Read by the
|
|
3
|
+
# frontend at startup to render the "Add credential" form, the connect-OAuth
|
|
4
|
+
# popup, the credential picker on nodes — purely by reading this metadata.
|
|
5
|
+
# No per-connector frontend logic.
|
|
6
|
+
#
|
|
7
|
+
# Response shape mirrors n8n's `ICredentialType` description (n8n source:
|
|
8
|
+
# packages/workflow/src/interfaces.ts:356-382) so a single frontend renderer
|
|
9
|
+
# handles every connector. Returns only static metadata — never any secrets
|
|
10
|
+
# or per-user state.
|
|
11
|
+
class TypesController < ApplicationController
|
|
12
|
+
# GET /connectors/types
|
|
13
|
+
def index
|
|
14
|
+
types = Registry.all.sort_by { |key, _| key.to_s }.map { |key, klass| serialize(key, klass) }
|
|
15
|
+
render json: { types: types }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# GET /connectors/types/:name
|
|
19
|
+
def show
|
|
20
|
+
klass = Registry.fetch(params[:name])
|
|
21
|
+
render json: serialize(klass.connector_key, klass)
|
|
22
|
+
rescue Connectors::UnknownConnector => e
|
|
23
|
+
render json: { error: e.message }, status: :not_found
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# The OAuth provider's "Authorized Redirect URI". This MUST match what
|
|
29
|
+
# `AuthorizeUrl` and `TokenExchange` actually send — both read from
|
|
30
|
+
# `Connectors.configuration.resolved_app_callback_url(...)`. Surfacing a
|
|
31
|
+
# different value here would mislead admins into registering a URL the
|
|
32
|
+
# engine never uses.
|
|
33
|
+
def redirect_uri_for(connector_key)
|
|
34
|
+
Connectors.configuration.resolved_app_callback_url(connector_key)
|
|
35
|
+
rescue Connectors::Error
|
|
36
|
+
# `host_base_url` not configured — return the engine's own callback
|
|
37
|
+
# path so introspection still works in headless setups.
|
|
38
|
+
"#{Connectors::Engine.mount_path}/#{connector_key}/callback"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def serialize(key, klass)
|
|
42
|
+
schema = klass.credential_schema
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
# `name` is the n8n vocabulary for the machine identifier; `display_name`
|
|
46
|
+
# is the picker label. We keep `key`/`label` as aliases for back-compat
|
|
47
|
+
# with the existing /grants response — frontend should prefer the n8n
|
|
48
|
+
# names going forward.
|
|
49
|
+
name: key.to_s,
|
|
50
|
+
display_name: klass.display_name || key.to_s.humanize,
|
|
51
|
+
key: key.to_s, # @deprecated — use `name`
|
|
52
|
+
label: klass.display_name || key.to_s.humanize, # @deprecated — use `display_name`
|
|
53
|
+
icon: klass.icon,
|
|
54
|
+
icon_color: klass.icon_color,
|
|
55
|
+
documentation_url: klass.documentation_url,
|
|
56
|
+
# URL to an LLM-optimized docs bundle (provider's `llms.txt` /
|
|
57
|
+
# `llms-full.txt`, per llmstxt.org). Lets agents + frontend tools
|
|
58
|
+
# fetch authoritative API behavior without scraping HTML.
|
|
59
|
+
llm_docs: klass.llm_docs,
|
|
60
|
+
# Optional markdown the frontend renders at the top of the
|
|
61
|
+
# "Add connection" dialog. Backend-driven: nil when the
|
|
62
|
+
# connector doesn't need to walk the user through setup
|
|
63
|
+
# (e.g. Gmail's OAuth dance is self-explanatory); populated
|
|
64
|
+
# for connectors where the user needs to generate an API key
|
|
65
|
+
# or install an app first (e.g. Resend).
|
|
66
|
+
instructions: klass.instructions,
|
|
67
|
+
|
|
68
|
+
# n8n inheritance chain. Empty array when the credential type stands
|
|
69
|
+
# alone (e.g., API-key connectors). Frontend can fetch the parent's
|
|
70
|
+
# schema via `/connectors/types/:parent` OR rely on the resolved
|
|
71
|
+
# `properties` array below which already merges parent + own.
|
|
72
|
+
extends: schema ? schema.extends.map(&:to_s) : [],
|
|
73
|
+
properties: schema ? schema.resolved_fields.map(&:to_property) : [],
|
|
74
|
+
|
|
75
|
+
# Declarative auth injection — mirrors n8n's `IAuthenticateGeneric`
|
|
76
|
+
# at packages/workflow/src/interfaces.ts:278-288. Reads the resolved
|
|
77
|
+
# config (connector-level override OR inherited from the credential
|
|
78
|
+
# type via `extends`). nil when the connector still uses the
|
|
79
|
+
# imperative `api_key_in` shim AND no parent declared one.
|
|
80
|
+
authenticate: klass.resolved_authenticate_config,
|
|
81
|
+
# n8n's `genericAuth: boolean` (`interfaces.ts:379`). True when the
|
|
82
|
+
# connector itself flips `generic_auth!` OR when it extends a schema
|
|
83
|
+
# that already does (HttpBearerAuth etc.).
|
|
84
|
+
generic_auth: klass.generic_auth?,
|
|
85
|
+
|
|
86
|
+
# n8n's `supportedNodes: string[]` (`interfaces.ts:381`). Empty array
|
|
87
|
+
# means "no restriction" — same default as n8n. The frontend uses
|
|
88
|
+
# this to filter the credential picker per node type.
|
|
89
|
+
supported_nodes: klass.supported_nodes.map(&:to_s),
|
|
90
|
+
|
|
91
|
+
# n8n's `httpRequestNode: ICredentialHttpRequestNode` (interfaces.ts:380,
|
|
92
|
+
# union shape at :350-354). Tells the generic HTTP-Request node's
|
|
93
|
+
# credential picker how to label + link this credential. nil when the
|
|
94
|
+
# connector hasn't declared it.
|
|
95
|
+
http_request_node: klass.http_request_node,
|
|
96
|
+
|
|
97
|
+
# n8n's `__overwrittenProperties: string[]` (`interfaces.ts:382`;
|
|
98
|
+
# populated at `frontend.service.ts:681-705`). Field names whose
|
|
99
|
+
# values are sourced from the external secrets manager — the editor
|
|
100
|
+
# renders them locked / hidden. Empty array when no vault is
|
|
101
|
+
# configured for this connector type.
|
|
102
|
+
__overwritten_properties: Connectors.configuration.managed_fields_for(key),
|
|
103
|
+
|
|
104
|
+
# n8n's `__skipManagedCreation` (frontend.service.ts:707-711). When
|
|
105
|
+
# true the editor hides the "Use external secret" toggle.
|
|
106
|
+
__skip_managed_creation: klass.skip_managed_creation?,
|
|
107
|
+
|
|
108
|
+
# Action manifest — what this connector can DO with a credential.
|
|
109
|
+
# Each entry includes its own typed `properties` (input schema), an
|
|
110
|
+
# optional `output` schema, and metadata for the picker UI. Frontend
|
|
111
|
+
# browses these at design time; runtime invocation goes through
|
|
112
|
+
# `POST /credentials/:id/actions/:name`. Empty array when the
|
|
113
|
+
# connector hasn't declared any actions yet.
|
|
114
|
+
actions: klass.actions.map(&:to_manifest),
|
|
115
|
+
|
|
116
|
+
# Connector capabilities — orthogonal to credentials. Lives in its own
|
|
117
|
+
# block so frontend code that only cares about credential rendering
|
|
118
|
+
# can ignore it. For OAuth connectors, `redirect_uri` is the URL the
|
|
119
|
+
# admin must paste into the provider's app console (computed from the
|
|
120
|
+
# host's base URL — n8n does the same server-side at
|
|
121
|
+
# packages/cli/src/oauth/oauth.service.ts:528,690).
|
|
122
|
+
connector: {
|
|
123
|
+
base_url: klass.base_url,
|
|
124
|
+
webhook_style: klass.webhook_style.to_s,
|
|
125
|
+
rate_limit: klass.rate_limit_config,
|
|
126
|
+
authorize_url: klass.oauth2_config ? "#{Connectors::Engine.mount_path}/#{key}/authorize" : nil,
|
|
127
|
+
authorize_json_url: klass.oauth2_config ? "#{Connectors::Engine.mount_path}/#{key}/authorize.json" : nil,
|
|
128
|
+
redirect_uri: klass.mcp? ? Connectors.configuration.resolved_mcp_callback_url : (klass.oauth2_config ? redirect_uri_for(key) : nil),
|
|
129
|
+
mcp: mcp_metadata(klass),
|
|
130
|
+
# Frontend gates the "Test connection" button on this flag.
|
|
131
|
+
test_supported: !klass.test_request_config.nil?
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def mcp_metadata(klass)
|
|
137
|
+
return unless klass.mcp?
|
|
138
|
+
base = "#{Connectors::Engine.mount_path}/credentials/:id/mcp"
|
|
139
|
+
klass.mcp_config.merge("protocol_version" => MCP::PROTOCOL_VERSION,
|
|
140
|
+
"tools_url" => "#{base}/tools", "call_url" => "#{base}/tools/call",
|
|
141
|
+
"resume_url" => "#{base}/interactions/resume", "authorize_url" => "#{base}/authorize")
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
class WebhooksController < ApplicationController
|
|
3
|
+
rescue_from Connectors::UnknownConnector, with: :render_not_found
|
|
4
|
+
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
|
|
5
|
+
rescue_from Connectors::Webhooks::SignatureInvalid, with: :render_unauthorized
|
|
6
|
+
|
|
7
|
+
# POST /:connector_key/webhook (app-level: grant resolved from payload)
|
|
8
|
+
# POST /:connector_key/:grant_id/webhook (grant_id explicit in URL)
|
|
9
|
+
#
|
|
10
|
+
# Providers like Slack only allow one webhook URL per app — for those,
|
|
11
|
+
# leave grant_id out and implement Connector.resolve_grant_from_webhook
|
|
12
|
+
# to look up the grant by the payload's team/account id.
|
|
13
|
+
def receive
|
|
14
|
+
connector_class = Registry.fetch(params[:connector_key])
|
|
15
|
+
payload = request_payload
|
|
16
|
+
|
|
17
|
+
grant = params[:grant_id].present? ? Grant.for_connector(connector_class.connector_key).find(params[:grant_id]) : nil
|
|
18
|
+
|
|
19
|
+
verify_signature!(connector_class, grant)
|
|
20
|
+
|
|
21
|
+
# URL-verification ping (Slack et al.) — short-circuit before grant lookup.
|
|
22
|
+
if (challenge = connector_class.webhook_challenge(payload))
|
|
23
|
+
return render(json: challenge)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
grant ||= connector_class.resolve_grant_from_webhook(payload, request)
|
|
27
|
+
if grant.nil? || grant.connector_key != connector_class.connector_key.to_s
|
|
28
|
+
return render(json: { error: "no matching grant for webhook" }, status: :not_found)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
external_id = extract_external_event_id(payload)
|
|
32
|
+
if external_id.present? &&
|
|
33
|
+
(existing = WebhookEvent.find_by(connector_key: grant.connector_key, external_event_id: external_id))
|
|
34
|
+
return render(json: { event_id: existing.id, status: "duplicate" }, status: :ok)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
event = WebhookEvent.create!(
|
|
38
|
+
grant: grant,
|
|
39
|
+
connector_key: grant.connector_key,
|
|
40
|
+
external_event_id: external_id,
|
|
41
|
+
payload: payload,
|
|
42
|
+
raw_body: request.raw_post,
|
|
43
|
+
headers: inbound_headers,
|
|
44
|
+
query: request.query_parameters.to_h,
|
|
45
|
+
webhook_name: (params[:webhook_name].presence || "default").to_s,
|
|
46
|
+
signature: signature_header,
|
|
47
|
+
received_at: Time.current,
|
|
48
|
+
status: :received
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
DeliverWebhookJob.perform_later(event.id)
|
|
52
|
+
render json: { event_id: event.id, status: "accepted" }, status: :accepted
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def verify_signature!(connector_class, grant)
|
|
58
|
+
verifier = connector_class.webhook_verifier
|
|
59
|
+
return if verifier.nil?
|
|
60
|
+
verifier.verify!(grant, request)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def request_payload
|
|
64
|
+
ct = request.content_type.to_s
|
|
65
|
+
if ct.include?("json")
|
|
66
|
+
body = request.raw_post
|
|
67
|
+
body.empty? ? {} : (JSON.parse(body) rescue {})
|
|
68
|
+
else
|
|
69
|
+
request.request_parameters.to_unsafe_h
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def extract_external_event_id(payload)
|
|
74
|
+
return nil unless payload.is_a?(Hash)
|
|
75
|
+
payload["event_id"] || payload["id"] || payload.dig("event", "id")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def signature_header
|
|
79
|
+
request.headers["X-Slack-Signature"] ||
|
|
80
|
+
request.headers["Stripe-Signature"] ||
|
|
81
|
+
request.headers["X-Hub-Signature-256"] ||
|
|
82
|
+
request.headers["X-Signature"] ||
|
|
83
|
+
request.headers["Signature"]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Capture every HTTP_* header on the inbound request, normalized to
|
|
87
|
+
# lowercase + dashed keys (`http_x_hub_signature` → `x-hub-signature`),
|
|
88
|
+
# so the n8n-style `ctx.headers["stripe-signature"]` access works
|
|
89
|
+
# without callers having to know about Rack's mangling.
|
|
90
|
+
def inbound_headers
|
|
91
|
+
request.headers.env.each_with_object({}) do |(k, v), out|
|
|
92
|
+
next unless k.start_with?("HTTP_")
|
|
93
|
+
name = k.sub("HTTP_", "").downcase.tr("_", "-")
|
|
94
|
+
out[name] = v.is_a?(String) ? v : v.to_s
|
|
95
|
+
end.merge(
|
|
96
|
+
# Content-Type/Length aren't prefixed `HTTP_` in Rack
|
|
97
|
+
"content-type" => request.content_type.to_s,
|
|
98
|
+
"content-length" => request.content_length.to_s
|
|
99
|
+
).reject { |_, v| v.nil? || v.empty? }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def render_not_found(e); render(json: { error: e.message }, status: :not_found); end
|
|
103
|
+
def render_unauthorized(e); render(json: { error: e.message }, status: :unauthorized); end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
class DeliverWebhookJob < ApplicationJob
|
|
3
|
+
queue_as :default
|
|
4
|
+
|
|
5
|
+
discard_on ActiveJob::DeserializationError
|
|
6
|
+
|
|
7
|
+
def perform(event_id)
|
|
8
|
+
event = WebhookEvent.find(event_id)
|
|
9
|
+
return if event.processed? || event.ignored?
|
|
10
|
+
|
|
11
|
+
# Phase 7: always hand the connector a `WebhookContext` (n8n parity
|
|
12
|
+
# with `IWebhookFunctions`). The context still exposes `.event` /
|
|
13
|
+
# `.payload_hash` so handlers written against the old `event` arg
|
|
14
|
+
# keep working without changes — the new accessors are additive.
|
|
15
|
+
event.grant.connector.handle_webhook(Connectors::WebhookContext.new(event))
|
|
16
|
+
event.update!(status: :processed, processed_at: Time.current, error_message: nil)
|
|
17
|
+
|
|
18
|
+
Connectors.configuration.on_webhook&.call(event)
|
|
19
|
+
rescue => e
|
|
20
|
+
event&.update_columns(
|
|
21
|
+
status: WebhookEvent.statuses[:failed],
|
|
22
|
+
error_message: "#{e.class}: #{e.message}".byteslice(0, 1000)
|
|
23
|
+
)
|
|
24
|
+
raise
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
# Periodic poller for connectors that opt into polling. Two modes:
|
|
3
|
+
#
|
|
4
|
+
# PollJob.perform_later # fan-out: enqueues one PollJob per eligible grant
|
|
5
|
+
# PollJob.perform_later(grant.id) # poll one grant
|
|
6
|
+
#
|
|
7
|
+
# In production, schedule the fan-out from Solid Queue's recurring config:
|
|
8
|
+
#
|
|
9
|
+
# # config/recurring.yml (in the host app)
|
|
10
|
+
# production:
|
|
11
|
+
# poll_connectors:
|
|
12
|
+
# class: Connectors::PollJob
|
|
13
|
+
# queue: default
|
|
14
|
+
# schedule: every 5 minutes
|
|
15
|
+
#
|
|
16
|
+
# Grants whose connector class hasn't overridden #poll are skipped so we
|
|
17
|
+
# don't enqueue dead work.
|
|
18
|
+
class PollJob < ApplicationJob
|
|
19
|
+
queue_as :default
|
|
20
|
+
|
|
21
|
+
def perform(grant_id = nil)
|
|
22
|
+
grant_id ? poll_one(grant_id) : fan_out
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.poll_implemented?(connector_key)
|
|
26
|
+
klass = Registry.fetch(connector_key)
|
|
27
|
+
klass.instance_method(:poll).owner != Connectors::Connector
|
|
28
|
+
rescue Connectors::UnknownConnector
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def fan_out
|
|
35
|
+
Grant.active.find_each do |grant|
|
|
36
|
+
next unless self.class.poll_implemented?(grant.connector_key)
|
|
37
|
+
self.class.perform_later(grant.id)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def poll_one(grant_id)
|
|
42
|
+
grant = Grant.find(grant_id)
|
|
43
|
+
return unless grant.active?
|
|
44
|
+
|
|
45
|
+
grant.connector.poll
|
|
46
|
+
grant.update_columns(last_used_at: Time.current)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|