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,40 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
module Connectors
|
|
5
|
+
module OAuth
|
|
6
|
+
# Both OAuth2 token flows accept JSON and form-encoded provider responses.
|
|
7
|
+
# Validate their shape before any credentials can be persisted.
|
|
8
|
+
class TokenResponse
|
|
9
|
+
def self.parse(response)
|
|
10
|
+
body = begin
|
|
11
|
+
JSON.parse(response.body.to_s)
|
|
12
|
+
rescue JSON::ParserError
|
|
13
|
+
URI.decode_www_form(response.body.to_s).to_h
|
|
14
|
+
end
|
|
15
|
+
body = {} unless body.is_a?(Hash)
|
|
16
|
+
|
|
17
|
+
token = body["access_token"]
|
|
18
|
+
if !response.status.between?(200, 299) || body["ok"] == false || body["error"] || !token.is_a?(String) || token.blank?
|
|
19
|
+
reason = body["error"].presence || "invalid token response"
|
|
20
|
+
raise Connectors::AuthenticationFailed.new(
|
|
21
|
+
"OAuth token exchange failed (#{response.status}): #{reason}",
|
|
22
|
+
status: response.status, body: body
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
body
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.normalize(body)
|
|
29
|
+
expires_at = body["expires_in"] ? Time.now.to_i + body["expires_in"].to_i : body["expires_at"]&.to_i
|
|
30
|
+
{
|
|
31
|
+
"access_token" => body["access_token"],
|
|
32
|
+
"refresh_token" => body["refresh_token"],
|
|
33
|
+
"expires_at" => expires_at,
|
|
34
|
+
"scope" => body["scope"],
|
|
35
|
+
"token_type" => body["token_type"]
|
|
36
|
+
}.compact
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
require "base64"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "faraday"
|
|
6
|
+
|
|
7
|
+
module Connectors
|
|
8
|
+
# OAuth1.0a — RFC 5849. Two-leg request-token / access-token flow with
|
|
9
|
+
# HMAC-SHA1/SHA256/SHA512 request signing. n8n parity:
|
|
10
|
+
# `oauth.service.ts:601-700` (request-token leg) +
|
|
11
|
+
# `oauth1-credential.controller.ts:43-101` (callback / access-token leg).
|
|
12
|
+
# n8n uses the `oauth-1.0a` npm package; we implement the signing inline
|
|
13
|
+
# to keep the engine dependency-free.
|
|
14
|
+
module OAuth1
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
SIG_ALGORITHMS = {
|
|
18
|
+
"HMAC-SHA1" => "SHA1",
|
|
19
|
+
"HMAC-SHA256" => "SHA256",
|
|
20
|
+
"HMAC-SHA512" => "SHA512"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# Phase 1 of the flow: request the unauthorized request token. Sends an
|
|
24
|
+
# OAuth1-signed POST to `requestTokenUrl`. Provider returns
|
|
25
|
+
# `oauth_token` + `oauth_token_secret` (URL-encoded form). We need both
|
|
26
|
+
# to (a) build the authorize URL the owner is redirected to and (b)
|
|
27
|
+
# sign the access-token request when the owner returns via callback.
|
|
28
|
+
def request_token(connector_class, callback_url:)
|
|
29
|
+
config = connector_class.oauth1_config or
|
|
30
|
+
raise Connectors::Error.new("#{connector_class}: oauth1 ... DSL not declared")
|
|
31
|
+
secrets = Connectors.configuration.oauth_credentials_for(connector_class.connector_key)
|
|
32
|
+
|
|
33
|
+
headers = build_authorization_header(
|
|
34
|
+
method: "POST",
|
|
35
|
+
url: config[:request_token_url],
|
|
36
|
+
consumer_key: secrets[:client_id],
|
|
37
|
+
consumer_secret: secrets[:client_secret],
|
|
38
|
+
token: nil,
|
|
39
|
+
token_secret: nil,
|
|
40
|
+
signature_method: config[:signature_method],
|
|
41
|
+
extra_params: { "oauth_callback" => callback_url }
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
response = Faraday.post(config[:request_token_url], "", headers)
|
|
45
|
+
|
|
46
|
+
if response.status >= 400
|
|
47
|
+
raise Connectors::AuthenticationFailed.new(
|
|
48
|
+
"OAuth1 request_token failed (#{response.status}): #{response.body}",
|
|
49
|
+
status: response.status, body: response.body
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
parsed = URI.decode_www_form(response.body.to_s).to_h
|
|
54
|
+
raise Connectors::AuthenticationFailed.new("OAuth1 request_token response missing oauth_token") \
|
|
55
|
+
unless parsed["oauth_token"]
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
"oauth_token" => parsed["oauth_token"],
|
|
59
|
+
"oauth_token_secret" => parsed["oauth_token_secret"]
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Phase 2: trade the verifier + token for the long-lived access token.
|
|
64
|
+
def exchange_access_token(connector_class, oauth_token:, oauth_verifier:, oauth_token_secret:)
|
|
65
|
+
config = connector_class.oauth1_config or
|
|
66
|
+
raise Connectors::Error.new("#{connector_class}: oauth1 ... DSL not declared")
|
|
67
|
+
secrets = Connectors.configuration.oauth_credentials_for(connector_class.connector_key)
|
|
68
|
+
|
|
69
|
+
headers = build_authorization_header(
|
|
70
|
+
method: "POST",
|
|
71
|
+
url: config[:access_token_url],
|
|
72
|
+
consumer_key: secrets[:client_id],
|
|
73
|
+
consumer_secret: secrets[:client_secret],
|
|
74
|
+
token: oauth_token,
|
|
75
|
+
token_secret: oauth_token_secret,
|
|
76
|
+
signature_method: config[:signature_method],
|
|
77
|
+
extra_params: { "oauth_verifier" => oauth_verifier }
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
response = Faraday.post(config[:access_token_url], "", headers)
|
|
81
|
+
|
|
82
|
+
if response.status >= 400
|
|
83
|
+
raise Connectors::AuthenticationFailed.new(
|
|
84
|
+
"OAuth1 access_token exchange failed (#{response.status}): #{response.body}",
|
|
85
|
+
status: response.status, body: response.body
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
parsed = URI.decode_www_form(response.body.to_s).to_h
|
|
90
|
+
raise Connectors::AuthenticationFailed.new("OAuth1 access_token response missing oauth_token") \
|
|
91
|
+
unless parsed["oauth_token"]
|
|
92
|
+
|
|
93
|
+
normalized = {
|
|
94
|
+
"oauth_token" => parsed["oauth_token"],
|
|
95
|
+
"oauth_token_secret" => parsed["oauth_token_secret"],
|
|
96
|
+
"signature_method" => config[:signature_method]
|
|
97
|
+
}
|
|
98
|
+
connector_class.post_token_exchange(parsed, normalized)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Build an `Authorization: OAuth ...` header for a signed request. Each
|
|
102
|
+
# `extra_params` entry (e.g. `oauth_callback`, `oauth_verifier`) takes
|
|
103
|
+
# part in the signature base string and the final header.
|
|
104
|
+
def build_authorization_header(method:, url:, consumer_key:, consumer_secret:,
|
|
105
|
+
token:, token_secret:, signature_method:, extra_params: {})
|
|
106
|
+
oauth_params = {
|
|
107
|
+
"oauth_consumer_key" => consumer_key.to_s,
|
|
108
|
+
"oauth_nonce" => SecureRandom.hex(16),
|
|
109
|
+
"oauth_signature_method" => signature_method,
|
|
110
|
+
"oauth_timestamp" => Time.now.to_i.to_s,
|
|
111
|
+
"oauth_version" => "1.0"
|
|
112
|
+
}
|
|
113
|
+
oauth_params["oauth_token"] = token if token
|
|
114
|
+
oauth_params.merge!(extra_params)
|
|
115
|
+
|
|
116
|
+
signature = sign(
|
|
117
|
+
method: method,
|
|
118
|
+
url: url,
|
|
119
|
+
params: oauth_params,
|
|
120
|
+
consumer_secret: consumer_secret,
|
|
121
|
+
token_secret: token_secret,
|
|
122
|
+
signature_method: signature_method
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
header_params = oauth_params.merge("oauth_signature" => signature)
|
|
126
|
+
header_value = header_params.sort.map { |k, v| %(#{percent_encode(k)}="#{percent_encode(v)}") }.join(", ")
|
|
127
|
+
|
|
128
|
+
{ "Authorization" => "OAuth #{header_value}", "Accept" => "*/*" }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# RFC 5849 §3.4.1: signature base string = method & URL & sorted params.
|
|
132
|
+
# All three are percent-encoded individually then joined by `&`.
|
|
133
|
+
def sign(method:, url:, params:, consumer_secret:, token_secret:, signature_method:)
|
|
134
|
+
algo = SIG_ALGORITHMS.fetch(signature_method) do
|
|
135
|
+
raise Connectors::Error.new("unsupported OAuth1 signature_method #{signature_method.inspect}")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
sorted_params = params.sort.map { |k, v| "#{percent_encode(k)}=#{percent_encode(v)}" }.join("&")
|
|
139
|
+
base = [ method.to_s.upcase, percent_encode(url), percent_encode(sorted_params) ].join("&")
|
|
140
|
+
key = "#{percent_encode(consumer_secret.to_s)}&#{percent_encode(token_secret.to_s)}"
|
|
141
|
+
|
|
142
|
+
Base64.strict_encode64(OpenSSL::HMAC.digest(algo, key, base))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# RFC 3986 percent-encoding (stricter than CGI.escape — encodes `*`,
|
|
146
|
+
# leaves `-._~` intact, uses `%20` not `+` for spaces).
|
|
147
|
+
def percent_encode(str)
|
|
148
|
+
str.to_s.b.gsub(/[^A-Za-z0-9\-._~]/) { |c| "%%%02X" % c.ord }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
# Visibility gate for credentials. Mirrors n8n's two-pronged check
|
|
3
|
+
# (interfaces.ts:379 + :381) — `genericAuth: true` lets the generic
|
|
4
|
+
# HTTP-Request node use any credential, and `supportedNodes: [...]`
|
|
5
|
+
# restricts a credential to a specific node allowlist when set.
|
|
6
|
+
#
|
|
7
|
+
# Call from inside a node's execution wrapper before it touches the grant:
|
|
8
|
+
#
|
|
9
|
+
# Connectors::PermissionCheck.permit!(grant, node_type: :slack_send_message)
|
|
10
|
+
#
|
|
11
|
+
# Raises `Connectors::CredentialNotPermitted` on a denied match so callers
|
|
12
|
+
# see one consistent failure mode.
|
|
13
|
+
module PermissionCheck
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def permits?(grant, node_type:)
|
|
17
|
+
connector_class = Registry.fetch(grant.connector_key)
|
|
18
|
+
connector_class.supports_node?(node_type)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def permit!(grant, node_type:)
|
|
22
|
+
connector_class = Registry.fetch(grant.connector_key)
|
|
23
|
+
return true if connector_class.supports_node?(node_type)
|
|
24
|
+
reason =
|
|
25
|
+
if connector_class.supported_nodes.any?
|
|
26
|
+
"supported_nodes: #{connector_class.supported_nodes.inspect}"
|
|
27
|
+
else
|
|
28
|
+
"credential generic-auth-only"
|
|
29
|
+
end
|
|
30
|
+
raise CredentialNotPermitted.new(grant.connector_key, node_type, reason: reason)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
# Fires one consumer's polling block. Explicit instance keys isolate cursors
|
|
3
|
+
# and filters even when consumers share credentials. Omitting the key keeps
|
|
4
|
+
# the legacy, per-grant cursor for existing single-consumer integrations.
|
|
5
|
+
#
|
|
6
|
+
# n8n parity: `INodeType#poll(this: IPollFunctions)` (interfaces.ts:2060),
|
|
7
|
+
# cursor persistence via `getWorkflowStaticData('node')`
|
|
8
|
+
# (interfaces.ts:1257-1274). The scheduler that decides *when* to fire
|
|
9
|
+
# this is a workflow-roadmap concern; this primitive just exposes the
|
|
10
|
+
# one-shot contract.
|
|
11
|
+
class PollRunner
|
|
12
|
+
GROUP = "polling".freeze
|
|
13
|
+
|
|
14
|
+
def self.run(grant, instance_key: nil)
|
|
15
|
+
new(grant, instance_key: instance_key).run
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(grant, instance_key: nil)
|
|
19
|
+
@grant = grant
|
|
20
|
+
@instance_key = instance_key
|
|
21
|
+
if !instance_key.nil? && (!instance_key.is_a?(String) || instance_key.blank?)
|
|
22
|
+
raise ArgumentError, "instance_key must be a nonempty string"
|
|
23
|
+
end
|
|
24
|
+
@connector = Registry.fetch(grant.connector_key)
|
|
25
|
+
@block = @connector.polling_block or
|
|
26
|
+
raise Connectors::Error.new("#{@connector}: no polling block declared")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run
|
|
30
|
+
items_out = nil
|
|
31
|
+
static_data_out = nil
|
|
32
|
+
update_cursor do |sub|
|
|
33
|
+
items_out = @block.call(@grant, sub)
|
|
34
|
+
static_data_out = sub.dup
|
|
35
|
+
end
|
|
36
|
+
{ items: items_out.is_a?(Array) ? items_out : [], static_data: static_data_out }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def update_cursor(&block)
|
|
42
|
+
if @instance_key
|
|
43
|
+
state = @grant.poll_states.create_or_find_by!(instance_key: @instance_key)
|
|
44
|
+
state.update_cursor!(&block)
|
|
45
|
+
else
|
|
46
|
+
@grant.update_static_data!(GROUP, &block)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
# Helpers passed to a connector's `pre_authentication` block — mirrors
|
|
3
|
+
# n8n's `IHttpRequestHelper.helpers` surface (n8n source:
|
|
4
|
+
# packages/workflow/src/interfaces.ts:210-212). The block uses this to
|
|
5
|
+
# fetch a fresh token from an arbitrary endpoint without needing the
|
|
6
|
+
# full connector client (which itself depends on credentials that may
|
|
7
|
+
# not exist yet on first auth).
|
|
8
|
+
#
|
|
9
|
+
# pre_authentication do |credentials, helpers|
|
|
10
|
+
# response = helpers.http_request(
|
|
11
|
+
# method: :post,
|
|
12
|
+
# url: "#{credentials['url']}/oauth2/token",
|
|
13
|
+
# body: { client_id: credentials['client_id'],
|
|
14
|
+
# client_secret: credentials['client_secret'] },
|
|
15
|
+
# headers: { "Content-Type" => "application/x-www-form-urlencoded" }
|
|
16
|
+
# )
|
|
17
|
+
# { "session_token" => response["access_token"],
|
|
18
|
+
# "expires_at" => Time.now.to_i + response["expires_in"].to_i }
|
|
19
|
+
# end
|
|
20
|
+
class PreAuthenticationHelpers
|
|
21
|
+
DEFAULT_TIMEOUT = 30
|
|
22
|
+
|
|
23
|
+
# Performs the HTTP request through a fresh Faraday connection (no
|
|
24
|
+
# auth/middleware stack — this runs BEFORE auth injection by design).
|
|
25
|
+
# Body content-type is inferred from the headers; defaults to JSON.
|
|
26
|
+
def http_request(method:, url:, body: nil, headers: nil, query: nil, timeout: DEFAULT_TIMEOUT)
|
|
27
|
+
connection = Faraday.new do |f|
|
|
28
|
+
f.options.timeout = timeout
|
|
29
|
+
if json?(headers)
|
|
30
|
+
f.request :json
|
|
31
|
+
f.response :json, content_type: /\bjson\b/
|
|
32
|
+
elsif form_urlencoded?(headers)
|
|
33
|
+
f.request :url_encoded
|
|
34
|
+
f.response :json, content_type: /\bjson\b/
|
|
35
|
+
else
|
|
36
|
+
f.response :json, content_type: /\bjson\b/
|
|
37
|
+
end
|
|
38
|
+
f.adapter Faraday.default_adapter
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
response = connection.public_send(method.to_sym, url) do |req|
|
|
42
|
+
(headers || {}).each { |k, v| req.headers[k.to_s] = v.to_s }
|
|
43
|
+
req.params = query if query
|
|
44
|
+
req.body = body if body
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Match n8n's `helpers.httpRequest` default: non-2xx becomes an error
|
|
48
|
+
# the pre_authentication hook can catch. Keeps "auth endpoint
|
|
49
|
+
# exploded" failures visible instead of silently merging a nil token.
|
|
50
|
+
if response.status >= 400
|
|
51
|
+
body_preview = response.body.is_a?(String) ? response.body.byteslice(0, 200) : response.body.inspect
|
|
52
|
+
raise Connectors::ApiError.new(
|
|
53
|
+
"pre_authentication HTTP #{response.status}: #{body_preview}",
|
|
54
|
+
status: response.status,
|
|
55
|
+
body: response.body
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
response.body
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def json?(headers)
|
|
65
|
+
return true if headers.nil?
|
|
66
|
+
content_type = (headers["Content-Type"] || headers[:"Content-Type"] || "").to_s
|
|
67
|
+
content_type.empty? || content_type.include?("json")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def form_urlencoded?(headers)
|
|
71
|
+
(headers || {}).any? do |k, v|
|
|
72
|
+
k.to_s.downcase == "content-type" && v.to_s.include?("application/x-www-form-urlencoded")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
# In-memory map of connector_key => Connector subclass. Populated when each
|
|
3
|
+
# connector class is autoloaded (via the `connector key:, ...` DSL call in
|
|
4
|
+
# the class body).
|
|
5
|
+
class Registry
|
|
6
|
+
class << self
|
|
7
|
+
def register(key, klass)
|
|
8
|
+
store[key.to_sym] = klass
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def fetch(key)
|
|
12
|
+
store.fetch(key.to_sym) { raise UnknownConnector.new(key, known: store.keys) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def registered?(key)
|
|
16
|
+
store.key?(key.to_sym)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def all
|
|
20
|
+
store.dup
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def keys
|
|
24
|
+
store.keys
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def clear!
|
|
28
|
+
store.clear
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def store
|
|
34
|
+
@store ||= {}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
# Rich context object passed to `Connector#handle_webhook`. Mirrors n8n's
|
|
3
|
+
# `IWebhookFunctions` (packages/workflow/src/interfaces.ts:1327-1350): the
|
|
4
|
+
# handler gets the parsed body AND headers AND query AND a stable
|
|
5
|
+
# `webhook_name` for multi-webhook providers, without having to thread
|
|
6
|
+
# `event.request_object.foo` through everywhere.
|
|
7
|
+
#
|
|
8
|
+
# Forwards a small set of methods to the underlying `WebhookEvent` so
|
|
9
|
+
# legacy handlers written against `event.payload_hash` keep working — see
|
|
10
|
+
# `Connector#handle_webhook` arity detection in `DeliverWebhookJob`.
|
|
11
|
+
class WebhookContext
|
|
12
|
+
attr_reader :event
|
|
13
|
+
|
|
14
|
+
def initialize(event)
|
|
15
|
+
@event = event
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Parsed body (n8n: `getBodyData()`). Always a Hash — falls back to {}.
|
|
19
|
+
def body
|
|
20
|
+
@event.payload_hash
|
|
21
|
+
end
|
|
22
|
+
alias payload body
|
|
23
|
+
alias payload_hash body
|
|
24
|
+
|
|
25
|
+
# Raw (untouched) request body string. nil when the controller couldn't
|
|
26
|
+
# capture it (form-encoded payloads where Rails consumed the stream).
|
|
27
|
+
# n8n: `getRequestObject().rawBody`.
|
|
28
|
+
def raw_body
|
|
29
|
+
@event.raw_body
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# All request headers (n8n: `getHeaderData()`). Lowercased keys — same
|
|
33
|
+
# normalization n8n applies via `req.headers`.
|
|
34
|
+
def headers
|
|
35
|
+
@event.headers || {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Query string parameters (n8n: `getQueryData()`).
|
|
39
|
+
def query
|
|
40
|
+
@event.query || {}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Webhook group name that the request hit — `:default` / `:setup` / etc.
|
|
44
|
+
# n8n: `getWebhookName()`.
|
|
45
|
+
def webhook_name
|
|
46
|
+
(@event.webhook_name || "default").to_sym
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Provider-side signature header, if present. Convenience accessor —
|
|
50
|
+
# the controller already extracted this off the raw request for the
|
|
51
|
+
# idempotency / signature-verification path.
|
|
52
|
+
def signature
|
|
53
|
+
@event.signature
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The underlying Grant — same as `event.grant`. Lets handlers post back
|
|
57
|
+
# to the provider via `ctx.grant.connector.client`.
|
|
58
|
+
def grant
|
|
59
|
+
@event.grant
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
# Runs a connector's webhook_methods callbacks: subscribe = run create
|
|
3
|
+
# (skipping when check_exists already returns true), unsubscribe = run
|
|
4
|
+
# delete. State is persisted into `grant.static_data[group_name]`.
|
|
5
|
+
#
|
|
6
|
+
# n8n parity: this is the per-trigger activation orchestration that
|
|
7
|
+
# `cli/src/active-workflow-runner.ts` performs around each
|
|
8
|
+
# `INodeType#webhookMethods.default.*` call. We provide it as a connector-
|
|
9
|
+
# side primitive so the eventual workflow-side activation manager can
|
|
10
|
+
# call into it without re-implementing the gating.
|
|
11
|
+
class WebhookLifecycle
|
|
12
|
+
def self.subscribe(grant, hook_url:, webhook_name: :default)
|
|
13
|
+
new(grant, webhook_name).subscribe(hook_url: hook_url)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.unsubscribe(grant, webhook_name: :default)
|
|
17
|
+
new(grant, webhook_name).unsubscribe
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(grant, webhook_name)
|
|
21
|
+
@grant = grant
|
|
22
|
+
@webhook_name = webhook_name.to_sym
|
|
23
|
+
@connector = Registry.fetch(grant.connector_key)
|
|
24
|
+
@group = @connector.webhook_group(@webhook_name) or
|
|
25
|
+
raise Connectors::Error.new(
|
|
26
|
+
"#{@connector}: no webhook_methods declared for group #{@webhook_name.inspect}"
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Mirrors n8n: if checkExists returns true, skip create — provider
|
|
31
|
+
# already has an equivalent subscription. Otherwise run create.
|
|
32
|
+
def subscribe(hook_url:)
|
|
33
|
+
result = nil
|
|
34
|
+
@grant.update_static_data!(@webhook_name) do |sub|
|
|
35
|
+
if @group.check_exists && @group.check_exists.call(@grant, hook_url, sub)
|
|
36
|
+
result = { status: "exists", static_data: sub.dup }
|
|
37
|
+
next
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if @group.create.nil?
|
|
41
|
+
raise Connectors::Error.new("#{@connector}: webhook_methods #{@webhook_name.inspect} has no `create` block")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
created = @group.create.call(@grant, hook_url, sub)
|
|
45
|
+
unless created
|
|
46
|
+
raise Connectors::Error.new("#{@connector}: webhook_methods #{@webhook_name.inspect} create returned false")
|
|
47
|
+
end
|
|
48
|
+
result = { status: "created", static_data: sub.dup }
|
|
49
|
+
end
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def unsubscribe
|
|
54
|
+
if @group.delete.nil?
|
|
55
|
+
raise Connectors::Error.new("#{@connector}: webhook_methods #{@webhook_name.inspect} has no `delete` block")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
result = nil
|
|
59
|
+
@grant.update_static_data!(@webhook_name) do |sub|
|
|
60
|
+
ok = @group.delete.call(@grant, sub)
|
|
61
|
+
unless ok
|
|
62
|
+
raise Connectors::Error.new("#{@connector}: webhook_methods #{@webhook_name.inspect} delete returned false")
|
|
63
|
+
end
|
|
64
|
+
result = { status: "deleted", static_data: sub.dup }
|
|
65
|
+
end
|
|
66
|
+
result
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
# Declarative webhook subscription lifecycle. Mirrors n8n's
|
|
3
|
+
# `INodeType.webhookMethods.default.{checkExists, create, delete}`
|
|
4
|
+
# (interfaces.ts:2017, 2091-2095; reference impl in
|
|
5
|
+
# `packages/nodes-base/nodes/Postmark/PostmarkTrigger.node.ts:114-247`).
|
|
6
|
+
#
|
|
7
|
+
# Provider-specific contract:
|
|
8
|
+
#
|
|
9
|
+
# webhook_methods do # implicit group: :default
|
|
10
|
+
# check_exists do |grant, hook_url, static_data|
|
|
11
|
+
# webhooks = grant.connector.client.get("hooks").body["Webhooks"]
|
|
12
|
+
# found = webhooks.find { |h| h["Url"] == hook_url }
|
|
13
|
+
# static_data["webhook_id"] = found["ID"] if found
|
|
14
|
+
# !found.nil?
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# create do |grant, hook_url, static_data|
|
|
18
|
+
# resp = grant.connector.client.post("hooks", { url: hook_url }).body
|
|
19
|
+
# static_data["webhook_id"] = resp["ID"]
|
|
20
|
+
# true
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# delete do |grant, static_data|
|
|
24
|
+
# grant.connector.client.delete("hooks/#{static_data['webhook_id']}")
|
|
25
|
+
# static_data.delete("webhook_id")
|
|
26
|
+
# true
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# Multi-webhook providers (Slack has separate `default` event-delivery
|
|
31
|
+
# group + `setup` URL-verification group — n8n's `webhooks: IWebhookDescription[]`
|
|
32
|
+
# at interfaces.ts:2600) declare additional named groups:
|
|
33
|
+
#
|
|
34
|
+
# webhook_methods :setup do ... end
|
|
35
|
+
# webhook_methods :default do ... end
|
|
36
|
+
#
|
|
37
|
+
# The DSL builder for the inner block of `webhook_methods`. Captures each
|
|
38
|
+
# of the three callbacks into a Group struct.
|
|
39
|
+
class WebhookMethodsBuilder
|
|
40
|
+
attr_reader :check_exists_block, :create_block, :delete_block
|
|
41
|
+
|
|
42
|
+
def check_exists(&block) @check_exists_block = block end
|
|
43
|
+
def create(&block) @create_block = block end
|
|
44
|
+
def delete(&block) @delete_block = block end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
WebhookGroup = Struct.new(:name, :check_exists, :create, :delete)
|
|
48
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
module Webhooks
|
|
3
|
+
# Base class for connector-specific webhook signature verifiers.
|
|
4
|
+
# Subclass and override #verify! to raise Connectors::Webhooks::SignatureInvalid
|
|
5
|
+
# when the request's signature doesn't match. Typical implementation:
|
|
6
|
+
#
|
|
7
|
+
# class StripeWebhookVerifier < Connectors::Webhooks::Verifier
|
|
8
|
+
# def verify!(request)
|
|
9
|
+
# signature = request.headers["Stripe-Signature"]
|
|
10
|
+
# secret = @grant.credentials_hash["webhook_secret"]
|
|
11
|
+
# expected = OpenSSL::HMAC.hexdigest("SHA256", secret, request.raw_post)
|
|
12
|
+
# unless ActiveSupport::SecurityUtils.secure_compare(signature.to_s, expected)
|
|
13
|
+
# raise Connectors::Webhooks::SignatureInvalid.new("bad signature")
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
# end
|
|
17
|
+
class Verifier
|
|
18
|
+
def self.verify!(grant, request)
|
|
19
|
+
new(grant).verify!(request)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(grant)
|
|
23
|
+
@grant = grant
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def verify!(_request)
|
|
27
|
+
# No-op by default — subclasses override.
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/connectors.rb
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require "connectors/version"
|
|
2
|
+
require "connectors/engine"
|
|
3
|
+
|
|
4
|
+
require "connectors/errors"
|
|
5
|
+
require "connectors/configuration"
|
|
6
|
+
require "connectors/grant_policy"
|
|
7
|
+
require "connectors/registry"
|
|
8
|
+
require "connectors/credential_schema"
|
|
9
|
+
require "connectors/credential_type_registry"
|
|
10
|
+
require "connectors/credential_types/http_auth"
|
|
11
|
+
require "connectors/credential_tester"
|
|
12
|
+
require "connectors/auth"
|
|
13
|
+
require "connectors/auth/scheme"
|
|
14
|
+
require "connectors/auth/scheme/oauth2"
|
|
15
|
+
require "connectors/auth/scheme/api_key"
|
|
16
|
+
require "connectors/auth_injection"
|
|
17
|
+
require "connectors/pre_authentication_helpers"
|
|
18
|
+
require "connectors/middleware/auto_refresh"
|
|
19
|
+
require "connectors/middleware/grant_status"
|
|
20
|
+
require "connectors/middleware/rate_limit"
|
|
21
|
+
require "connectors/middleware/error_normalization"
|
|
22
|
+
require "connectors/middleware/authenticate_generic"
|
|
23
|
+
require "connectors/middleware/pre_authentication"
|
|
24
|
+
require "connectors/client_builder"
|
|
25
|
+
require "connectors/action"
|
|
26
|
+
require "connectors/action_builder"
|
|
27
|
+
require "connectors/connector"
|
|
28
|
+
require "connectors/action_runner"
|
|
29
|
+
require "connectors/permission_check"
|
|
30
|
+
require "connectors/oauth"
|
|
31
|
+
require "connectors/oauth/state"
|
|
32
|
+
require "connectors/oauth/grant_writer"
|
|
33
|
+
require "connectors/oauth/authorize_url"
|
|
34
|
+
require "connectors/oauth/client_authentication"
|
|
35
|
+
require "connectors/oauth/token_response"
|
|
36
|
+
require "connectors/oauth/token_exchange"
|
|
37
|
+
require "connectors/oauth/client_credentials"
|
|
38
|
+
require "connectors/oauth/pkce"
|
|
39
|
+
require "connectors/oauth/revoke"
|
|
40
|
+
require "connectors/oauth1"
|
|
41
|
+
require "connectors/webhooks"
|
|
42
|
+
require "connectors/webhooks/verifier"
|
|
43
|
+
require "connectors/webhook_methods"
|
|
44
|
+
require "connectors/webhook_lifecycle"
|
|
45
|
+
require "connectors/webhook_context"
|
|
46
|
+
require "connectors/poll_runner"
|
|
47
|
+
require "connectors/mcp"
|
|
48
|
+
|
|
49
|
+
module Connectors
|
|
50
|
+
end
|