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,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,4 @@
1
+ module Connectors
2
+ module OAuth
3
+ end
4
+ 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,3 @@
1
+ module Connectors
2
+ VERSION = "0.1.0"
3
+ 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
@@ -0,0 +1,5 @@
1
+ module Connectors
2
+ module Webhooks
3
+ class SignatureInvalid < Connectors::Error; end
4
+ end
5
+ 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