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.
Files changed (112) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +35 -0
  3. data/CONNECTORS_FRAMEWORK.md +799 -0
  4. data/CONTRIBUTING.md +60 -0
  5. data/MCP_CLIENT.md +168 -0
  6. data/MIT-LICENSE +20 -0
  7. data/README.md +146 -0
  8. data/app/connectors/clickup/connector.rb +13 -0
  9. data/app/connectors/gmail/api.rb +114 -0
  10. data/app/connectors/gmail/connector.rb +921 -0
  11. data/app/connectors/gmail/mime_builder.rb +261 -0
  12. data/app/connectors/gmail/mime_parser.rb +106 -0
  13. data/app/connectors/gmail/polling.rb +154 -0
  14. data/app/connectors/remote_mcp/connector.rb +18 -0
  15. data/app/connectors/resend/connector.rb +218 -0
  16. data/app/controllers/concerns/connectors/grant_access.rb +43 -0
  17. data/app/controllers/connectors/actions_controller.rb +66 -0
  18. data/app/controllers/connectors/application_controller.rb +5 -0
  19. data/app/controllers/connectors/credentials_controller.rb +245 -0
  20. data/app/controllers/connectors/grants_controller.rb +123 -0
  21. data/app/controllers/connectors/mcp_controller.rb +88 -0
  22. data/app/controllers/connectors/oauth_controller.rb +134 -0
  23. data/app/controllers/connectors/types_controller.rb +144 -0
  24. data/app/controllers/connectors/webhooks_controller.rb +105 -0
  25. data/app/jobs/connectors/application_job.rb +4 -0
  26. data/app/jobs/connectors/deliver_webhook_job.rb +27 -0
  27. data/app/jobs/connectors/poll_job.rb +49 -0
  28. data/app/models/connectors/application_record.rb +5 -0
  29. data/app/models/connectors/credential_share.rb +31 -0
  30. data/app/models/connectors/grant.rb +103 -0
  31. data/app/models/connectors/mcp_authorization.rb +6 -0
  32. data/app/models/connectors/mcp_interaction.rb +6 -0
  33. data/app/models/connectors/poll_state.rb +17 -0
  34. data/app/models/connectors/webhook_event.rb +19 -0
  35. data/config/routes.rb +68 -0
  36. data/db/migrate/20260518210324_create_connectors_grants.rb +49 -0
  37. data/db/migrate/20260518214609_create_connectors_webhook_events.rb +35 -0
  38. data/db/migrate/20260521140000_create_connectors_credential_shares.rb +26 -0
  39. data/db/migrate/20260922120000_create_connectors_poll_states.rb +12 -0
  40. data/db/migrate/20260922130000_create_connectors_mcp_transactions.rb +18 -0
  41. data/docs/adding-connectors.md +92 -0
  42. data/docs/architecture.md +71 -0
  43. data/docs/releasing.md +60 -0
  44. data/lib/connectors/action.rb +90 -0
  45. data/lib/connectors/action_builder.rb +125 -0
  46. data/lib/connectors/action_runner.rb +99 -0
  47. data/lib/connectors/auth/scheme/api_key.rb +51 -0
  48. data/lib/connectors/auth/scheme/oauth2.rb +23 -0
  49. data/lib/connectors/auth/scheme.rb +35 -0
  50. data/lib/connectors/auth.rb +4 -0
  51. data/lib/connectors/auth_injection.rb +65 -0
  52. data/lib/connectors/client_builder.rb +87 -0
  53. data/lib/connectors/configuration.rb +138 -0
  54. data/lib/connectors/connector.rb +565 -0
  55. data/lib/connectors/credential_schema.rb +219 -0
  56. data/lib/connectors/credential_tester.rb +85 -0
  57. data/lib/connectors/credential_type_registry.rb +162 -0
  58. data/lib/connectors/credential_types/http_auth.rb +171 -0
  59. data/lib/connectors/engine.rb +123 -0
  60. data/lib/connectors/errors.rb +88 -0
  61. data/lib/connectors/grant_policy.rb +13 -0
  62. data/lib/connectors/mcp/access.rb +50 -0
  63. data/lib/connectors/mcp/authorization.rb +177 -0
  64. data/lib/connectors/mcp/authorization_context.rb +25 -0
  65. data/lib/connectors/mcp/authorization_discovery.rb +42 -0
  66. data/lib/connectors/mcp/cancellation.rb +36 -0
  67. data/lib/connectors/mcp/client.rb +137 -0
  68. data/lib/connectors/mcp/connection_config.rb +48 -0
  69. data/lib/connectors/mcp/http.rb +108 -0
  70. data/lib/connectors/mcp/interaction.rb +82 -0
  71. data/lib/connectors/mcp/pending_transaction.rb +26 -0
  72. data/lib/connectors/mcp/protocol/2026-07-28.json +3963 -0
  73. data/lib/connectors/mcp/protocol/LICENSE +216 -0
  74. data/lib/connectors/mcp/protocol/README.md +8 -0
  75. data/lib/connectors/mcp/protocol_schema.rb +30 -0
  76. data/lib/connectors/mcp/schema.rb +45 -0
  77. data/lib/connectors/mcp/settings.rb +27 -0
  78. data/lib/connectors/mcp/token_endpoint.rb +48 -0
  79. data/lib/connectors/mcp/transport.rb +105 -0
  80. data/lib/connectors/mcp.rb +69 -0
  81. data/lib/connectors/middleware/authenticate_generic.rb +79 -0
  82. data/lib/connectors/middleware/auto_refresh.rb +71 -0
  83. data/lib/connectors/middleware/error_normalization.rb +45 -0
  84. data/lib/connectors/middleware/grant_status.rb +19 -0
  85. data/lib/connectors/middleware/pre_authentication.rb +54 -0
  86. data/lib/connectors/middleware/rate_limit.rb +40 -0
  87. data/lib/connectors/oauth/authorize_url.rb +78 -0
  88. data/lib/connectors/oauth/client_authentication.rb +24 -0
  89. data/lib/connectors/oauth/client_credentials.rb +43 -0
  90. data/lib/connectors/oauth/grant_writer.rb +73 -0
  91. data/lib/connectors/oauth/pkce.rb +32 -0
  92. data/lib/connectors/oauth/revoke.rb +72 -0
  93. data/lib/connectors/oauth/state.rb +41 -0
  94. data/lib/connectors/oauth/token_exchange.rb +69 -0
  95. data/lib/connectors/oauth/token_response.rb +40 -0
  96. data/lib/connectors/oauth.rb +4 -0
  97. data/lib/connectors/oauth1.rb +151 -0
  98. data/lib/connectors/permission_check.rb +33 -0
  99. data/lib/connectors/poll_runner.rb +50 -0
  100. data/lib/connectors/pre_authentication_helpers.rb +76 -0
  101. data/lib/connectors/registry.rb +38 -0
  102. data/lib/connectors/version.rb +3 -0
  103. data/lib/connectors/webhook_context.rb +62 -0
  104. data/lib/connectors/webhook_lifecycle.rb +69 -0
  105. data/lib/connectors/webhook_methods.rb +48 -0
  106. data/lib/connectors/webhooks/verifier.rb +31 -0
  107. data/lib/connectors/webhooks.rb +5 -0
  108. data/lib/connectors.rb +50 -0
  109. data/lib/tasks/connectors_mcp.rake +9 -0
  110. data/lib/tasks/connectors_tasks.rake +4 -0
  111. data/openapi.yaml +1099 -0
  112. 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,4 @@
1
+ module Connectors
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ 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
@@ -0,0 +1,5 @@
1
+ module Connectors
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end