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,565 @@
1
+ module Connectors
2
+ # Base class for every connector (Slack, Linear, Stripe, ...). Subclasses
3
+ # declare themselves with the `connector` DSL and define instance methods
4
+ # that wrap the third-party API.
5
+ #
6
+ # class SlackConnector < Connectors::Connector
7
+ # connector key: :slack, auth: :oauth2, base_url: "https://slack.com/api"
8
+ #
9
+ # credentials do
10
+ # field :access_token, required: true, secret: true
11
+ # field :refresh_token, required: true, secret: true
12
+ # end
13
+ #
14
+ # def post_message(channel:, text:)
15
+ # client.post("chat.postMessage", channel: channel, text: text).body
16
+ # end
17
+ #
18
+ # def refresh!
19
+ # # ... call the OAuth token endpoint, then:
20
+ # grant.update_credentials!(access_token: "...", refresh_token: "...")
21
+ # end
22
+ # end
23
+ class Connector
24
+ class << self
25
+ attr_reader :connector_key, :base_url, :credential_schema, :rate_limit_config, :test_request_config,
26
+ :display_name, :icon, :icon_color, :documentation_url, :llm_docs, :instructions,
27
+ :authenticate_config, :mcp_config
28
+
29
+ def connector(key:, auth:, base_url:,
30
+ display_name: nil, icon: nil, icon_color: nil,
31
+ documentation_url: nil, llm_docs: nil, instructions: nil)
32
+ @connector_key = key.to_sym
33
+ @auth_scheme_name = auth.to_sym
34
+ @base_url = base_url
35
+ @display_name = display_name
36
+ @icon = icon
37
+ @icon_color = icon_color
38
+ @documentation_url = documentation_url
39
+ # URL to an LLM-friendly docs bundle (typically the provider's
40
+ # `llms.txt` or `llms-full.txt` per llmstxt.org). Surfaced on the
41
+ # types endpoint so the frontend / agents can fetch authoritative
42
+ # API behavior without scraping HTML docs.
43
+ @llm_docs = llm_docs
44
+ # Optional markdown-formatted instructions shown at the top of the
45
+ # "Add connection" dialog — use it for connectors that need the user
46
+ # to do something on the provider's side first (generate an API
47
+ # key, install an app, register a webhook URL, …). Nil when the
48
+ # connector doesn't need guidance; the frontend renders nothing.
49
+ @instructions = instructions
50
+ Connectors::Registry.register(@connector_key, self)
51
+ end
52
+
53
+ def credentials(&block)
54
+ @credential_schema = CredentialSchema.build(&block)
55
+ end
56
+
57
+ # Remote MCP types share one client and OAuth flow. A named provider can
58
+ # pin its endpoint/authentication; the generic type leaves both editable.
59
+ def mcp(server_url: nil, auth_mode: nil)
60
+ @mcp_config = { "server_url" => server_url, "auth_mode" => auth_mode }.compact.freeze
61
+ revoke_token do |grant|
62
+ grant.update!(credentials: Connectors::MCP::ConnectionConfig.public_data(grant.stored_credentials_hash))
63
+ end
64
+ end
65
+
66
+ def mcp?
67
+ !mcp_config.nil?
68
+ end
69
+
70
+ def rate_limit(count, per:)
71
+ @rate_limit_config = { limit: count, per: per }
72
+ end
73
+
74
+ # Declarative "Test connection" — mirrors n8n's `ICredentialTestRequest`
75
+ # (packages/workflow/src/interfaces.ts:340-348). The test fires a real
76
+ # HTTP request via the connector's middleware stack (so credentials are
77
+ # injected the same way they would be at runtime) and applies the
78
+ # configured rules to decide pass/fail.
79
+ #
80
+ # test_request method: :get, url: "domains"
81
+ #
82
+ # test_request method: :get, url: "auth.test",
83
+ # rules: [
84
+ # { type: :response_success_body, key: "ok", value: false,
85
+ # message: "Slack token is invalid" }
86
+ # ]
87
+ #
88
+ # Status-code rules: a successful test is any 2xx by default. Pass
89
+ # `expect_status: 200` to require an exact code.
90
+ def test_request(method: :get, url:, headers: nil, query: nil,
91
+ expect_status: nil, rules: [])
92
+ @test_request_config = {
93
+ method: method.to_sym,
94
+ url: url,
95
+ headers: headers,
96
+ query: query,
97
+ expect_status: expect_status,
98
+ rules: Array(rules)
99
+ }
100
+ end
101
+
102
+ # n8n-style declarative auth injection (mirrors `IAuthenticateGeneric`
103
+ # at packages/workflow/src/interfaces.ts:278-288 + the shape of
104
+ # `IRequestOptionsSimplifiedAuth` at :197-208).
105
+ #
106
+ # `properties` accepts any subset of:
107
+ # headers: { "Authorization" => "=Bearer {{$credentials.api_key}}" }
108
+ # qs: { "api_key" => "={{$credentials.api_key}}" }
109
+ # body: { "auth_token" => "={{$credentials.token}}" }
110
+ # auth: { username: "...", password: "..." } # Basic-auth shortcut
111
+ # skip_ssl_certificate_validation: true
112
+ #
113
+ # Strings beginning with `=` are templates; `{{$credentials.field}}`
114
+ # substitutes the grant's credential field at request time. Strings
115
+ # without `=` are passed verbatim.
116
+ #
117
+ # authenticate type: :generic, properties: {
118
+ # headers: { "Authorization" => "=Bearer {{$credentials.api_key}}" }
119
+ # }
120
+ #
121
+ # When declared, this replaces the old `api_key_in` imperative shim
122
+ # at runtime — both the schema serializer and the Faraday client
123
+ # honor the declarative form first, falling back to `api_key_in` only
124
+ # if `authenticate` is absent.
125
+ def authenticate(type:, properties:)
126
+ raise ArgumentError, "only type: :generic is supported (n8n's IAuthenticateGeneric)" \
127
+ unless type.to_sym == :generic
128
+ @authenticate_config = { "type" => "generic", "properties" => properties }
129
+ end
130
+
131
+ # Resolved authenticate block: connector-level override wins, otherwise
132
+ # delegate to the credential schema (which walks `extends`). Lets a
133
+ # connector that only does `extends :http_bearer_auth` inherit both
134
+ # the `token` field AND the injection contract with no extra DSL.
135
+ def resolved_authenticate_config
136
+ @authenticate_config || @credential_schema&.resolved_authenticate
137
+ end
138
+
139
+ # n8n's `preAuthentication` hook (interfaces.ts:374-377). The block
140
+ # runs BEFORE each outgoing request, but only when the grant's
141
+ # credentials look stale (`expires_at` missing or in the past).
142
+ # Returns a hash merged into the grant's credentials before the
143
+ # AuthenticateGeneric step injects them into the request.
144
+ #
145
+ # Reference impl: CrowdStrikeOAuth2Api.credentials.ts:62-76 —
146
+ # fetches a session token from /oauth2/token using client_id +
147
+ # client_secret, returns `{ session_token, expires_at }`.
148
+ #
149
+ # pre_authentication do |credentials, helpers|
150
+ # response = helpers.http_request(
151
+ # method: :post,
152
+ # url: "#{credentials['url']}/oauth2/token",
153
+ # body: { client_id: credentials['client_id'],
154
+ # client_secret: credentials['client_secret'] }
155
+ # )
156
+ # { "session_token" => response["access_token"],
157
+ # "expires_at" => Time.now.to_i + response["expires_in"].to_i }
158
+ # end
159
+ def pre_authentication(&block)
160
+ @pre_authentication_block = block
161
+ end
162
+
163
+ attr_reader :pre_authentication_block
164
+
165
+ # Imperative API-key shim — kept for back-compat with connectors that
166
+ # declared `api_key_in` before Phase 1. New code should use the
167
+ # declarative `authenticate` DSL above. When both are declared,
168
+ # `authenticate` wins.
169
+ #
170
+ # api_key_in :header, name: "Authorization", prefix: "token "
171
+ # api_key_in :query, name: "api_key"
172
+ def api_key_in(location, name:, prefix: nil)
173
+ @api_key_options = { location: location, name: name, prefix: prefix }
174
+ end
175
+
176
+ attr_reader :api_key_options
177
+
178
+ # Declares OAuth2 provider endpoints + default scope. Client id/secret
179
+ # are supplied by the host via Connectors.configuration.oauth_credentials.
180
+ #
181
+ # `grant_type:` mirrors n8n's `OAuth2Api.credentials.ts:33-46` enum and
182
+ # selects the runtime flow:
183
+ # - "authorizationCode" (default) — standard redirect flow
184
+ # - "clientCredentials" — server-to-server, no user redirect
185
+ # - "pkce" — RFC 7636 with S256 challenge
186
+ #
187
+ # `authentication:` (header | body) — how client credentials are sent
188
+ # to the token endpoint. Matches n8n's same-named field. Default
189
+ # `"header"` (HTTP Basic).
190
+ #
191
+ # oauth2 authorize_url: "https://slack.com/oauth/v2/authorize",
192
+ # token_url: "https://slack.com/api/oauth.v2.access",
193
+ # scope: "chat:write,channels:read"
194
+ def oauth2(authorize_url: nil, token_url:, scope: nil, extra_authorize_params: {},
195
+ grant_type: "authorizationCode", authentication: "header", token_expired_status: 401)
196
+ unless %w[header body].include?(authentication.to_s)
197
+ raise ArgumentError, "OAuth authentication must be header or body"
198
+ end
199
+ unless token_expired_status.is_a?(Integer) && (400..499).cover?(token_expired_status)
200
+ raise ArgumentError, "token_expired_status must be an HTTP 4xx status"
201
+ end
202
+ @oauth2_config = {
203
+ authorize_url: authorize_url,
204
+ token_url: token_url,
205
+ scope: scope,
206
+ extra_authorize_params: extra_authorize_params,
207
+ grant_type: grant_type.to_s,
208
+ authentication: authentication.to_s,
209
+ token_expired_status: token_expired_status
210
+ }
211
+ end
212
+
213
+ attr_reader :oauth2_config
214
+
215
+ # Declares OAuth1.0a provider endpoints + consumer signature method.
216
+ # Consumer key/secret come from `Connectors.configuration.oauth_credentials`
217
+ # (same `client_id` / `client_secret` slot as OAuth2 — they're
218
+ # semantically identical in n8n's storage too).
219
+ #
220
+ # oauth1 request_token_url: "https://api.twitter.com/oauth/request_token",
221
+ # authorize_url: "https://api.twitter.com/oauth/authorize",
222
+ # access_token_url: "https://api.twitter.com/oauth/access_token",
223
+ # signature_method: "HMAC-SHA1" # default
224
+ def oauth1(request_token_url:, authorize_url:, access_token_url:,
225
+ signature_method: "HMAC-SHA1")
226
+ @oauth1_config = {
227
+ request_token_url: request_token_url,
228
+ authorize_url: authorize_url,
229
+ access_token_url: access_token_url,
230
+ signature_method: signature_method
231
+ }
232
+ end
233
+
234
+ attr_reader :oauth1_config
235
+
236
+ # Optional token-revocation surface. EITHER set `revoke_token_url`
237
+ # (RFC 7009: POST `token=<...>&client_id=<...>&client_secret=<...>`
238
+ # to the configured URL) OR pass a block to `revoke_token` for
239
+ # providers with a non-standard revoke shape.
240
+ #
241
+ # revoke_token_url "https://slack.com/api/auth.revoke"
242
+ #
243
+ # # or, for a provider whose revoke endpoint is GET'd with the
244
+ # # token in the query string (e.g. Google):
245
+ # revoke_token do |grant|
246
+ # Faraday.get("https://oauth2.googleapis.com/revoke",
247
+ # { token: grant.credentials_hash["access_token"] })
248
+ # end
249
+ def revoke_token_url(url = nil)
250
+ @revoke_token_url = url if url
251
+ @revoke_token_url
252
+ end
253
+
254
+ def revoke_token(&block)
255
+ @revoke_token_block = block
256
+ end
257
+
258
+ attr_reader :revoke_token_block
259
+
260
+ # n8n's `__skipManagedCreation` (frontend.service.ts:707-711). When
261
+ # an admin disables managed-credential creation for this type, the
262
+ # editor hides the "Use external secret" toggle AND the API refuses
263
+ # POSTs that pass `is_managed: true`. Surfaces on the types endpoint.
264
+ def skip_managed_creation!
265
+ @skip_managed_creation = true
266
+ end
267
+
268
+ def skip_managed_creation?
269
+ @skip_managed_creation == true
270
+ end
271
+
272
+ # n8n's `genericAuth: boolean` flag on `ICredentialType`
273
+ # (interfaces.ts:379). Marks the credential as eligible for the future
274
+ # generic HTTP-Request node's picker. Most provider-specific connectors
275
+ # leave this off; generic HTTP types (HttpBearerAuth etc.) flip it on.
276
+ # Connector-level call so non-`extends` connectors can opt in directly:
277
+ #
278
+ # generic_auth!
279
+ #
280
+ # When the connector `extends` an Http*Auth schema, the schema's flag
281
+ # already propagates — but this stays available as a per-connector override.
282
+ def generic_auth!
283
+ @generic_auth = true
284
+ end
285
+
286
+ def generic_auth?
287
+ return true if @generic_auth
288
+ @credential_schema&.generic_auth? || false
289
+ end
290
+
291
+ # n8n's `supportedNodes: string[]` (interfaces.ts:381). When set, only
292
+ # the listed node types are allowed to use this credential. Empty/unset
293
+ # means "any node may use it" — same default as n8n.
294
+ #
295
+ # supported_nodes :slack_send_message, :slack_get_channel
296
+ #
297
+ # The check itself is `Connectors::PermissionCheck.permit!` — call it
298
+ # from your node's execution wrapper to enforce.
299
+ def supported_nodes(*names)
300
+ if names.empty?
301
+ @supported_nodes || []
302
+ else
303
+ @supported_nodes = names.flatten.map(&:to_sym)
304
+ end
305
+ end
306
+
307
+ # n8n's `httpRequestNode: { name, docsUrl, apiBaseUrl | apiBaseUrlPlaceholder, hidden? }`
308
+ # (interfaces.ts:380; type `ICredentialHttpRequestNode` at :350-354).
309
+ # Advertises the connector to the generic HTTP-Request node's
310
+ # "credential picker" UI — the user sees "Linear API" alongside the
311
+ # docs link + a pre-filled base URL.
312
+ #
313
+ # http_request_node name: "Linear API",
314
+ # docs_url: "https://developers.linear.app/",
315
+ # api_base_url: "https://api.linear.app/"
316
+ def http_request_node(name: nil, docs_url: nil, api_base_url: nil, api_base_url_placeholder: nil, hidden: false)
317
+ # Reader form: `klass.http_request_node` with no args returns the
318
+ # stored config (nil when undeclared). Writer form supplies `name:`
319
+ # + `docs_url:` + one of the base-URL slots.
320
+ return @http_request_node if name.nil? && docs_url.nil?
321
+ unless api_base_url || api_base_url_placeholder
322
+ raise ArgumentError, "http_request_node requires either api_base_url or api_base_url_placeholder " \
323
+ "(n8n's `ICredentialHttpRequestNode` union, interfaces.ts:350-354)"
324
+ end
325
+ @http_request_node = {
326
+ "name" => name,
327
+ "docsUrl" => docs_url,
328
+ "apiBaseUrl" => api_base_url,
329
+ "apiBaseUrlPlaceholder" => api_base_url_placeholder,
330
+ "hidden" => hidden == true
331
+ }.compact
332
+ end
333
+
334
+ # Combined predicate used by `Connectors::PermissionCheck.permit!`.
335
+ # n8n's logic (oauth/credentials.controller flow, applied at credential
336
+ # picker render time): if `supportedNodes` is non-empty, the requesting
337
+ # node must be in it. The generic HTTP-Request node bypasses this
338
+ # check entirely when `genericAuth` is true on the credential type.
339
+ def supports_node?(node_type)
340
+ node_type = node_type.to_sym
341
+ return true if supported_nodes.empty? # no allowlist → permitted
342
+ return true if generic_auth? && node_type == :http_request
343
+ supported_nodes.include?(node_type)
344
+ end
345
+
346
+ # Hook called by TokenExchange after each successful token exchange or
347
+ # refresh. Default is a no-op (returns `normalized` unchanged). Override
348
+ # in subclasses to merge service-specific fields (Slack's team.id,
349
+ # bot_user_id, GitHub's installation_id, etc.) into the credentials hash.
350
+ #
351
+ # Receives the raw provider response and the normalized OAuth2 fields
352
+ # (access_token, refresh_token, expires_at, scope, token_type). Returns
353
+ # the final hash to write into Grant#credentials.
354
+ def post_token_exchange(_raw_response, normalized)
355
+ normalized
356
+ end
357
+
358
+ # Resolved lazily so connector files can be loaded before auth schemes are.
359
+ def auth_scheme
360
+ Connectors::Auth::Scheme.lookup(@auth_scheme_name)
361
+ end
362
+
363
+ # Attach a Connectors::Webhooks::Verifier subclass for signature checks
364
+ # on inbound webhooks. If unset, the WebhooksController accepts any
365
+ # caller — useful in development, dangerous in production.
366
+ def verify_webhooks_with(verifier_class)
367
+ @webhook_verifier = verifier_class
368
+ end
369
+
370
+ attr_reader :webhook_verifier
371
+
372
+ # Declares the provider-side webhook subscription lifecycle. See
373
+ # `Connectors::WebhookMethodsBuilder` for the block API. Implicit
374
+ # group name is `:default` so single-webhook providers don't need
375
+ # to think about groups:
376
+ #
377
+ # webhook_methods do
378
+ # check_exists { |grant, hook_url, static_data| ... }
379
+ # create { |grant, hook_url, static_data| ... }
380
+ # delete { |grant, static_data| ... }
381
+ # end
382
+ #
383
+ # For multi-webhook providers (Slack/Linear with separate `setup`
384
+ # and `default` groups) call `webhook_methods :setup do ... end`.
385
+ def webhook_methods(name = :default, &block)
386
+ builder = WebhookMethodsBuilder.new
387
+ builder.instance_eval(&block)
388
+ @webhook_groups ||= {}
389
+ @webhook_groups[name.to_sym] = WebhookGroup.new(
390
+ name.to_sym, builder.check_exists_block, builder.create_block, builder.delete_block
391
+ )
392
+ end
393
+
394
+ def webhook_group(name)
395
+ (@webhook_groups || {})[name.to_sym]
396
+ end
397
+
398
+ def webhook_group_names
399
+ (@webhook_groups || {}).keys
400
+ end
401
+
402
+ # Polling primitive — n8n's `polling: true` flag + `poll()` method
403
+ # on trigger nodes (interfaces.ts:2060; reference impl
404
+ # `nodes-base/nodes/Google/Gmail/GmailTrigger.node.ts:65, 281-553`).
405
+ # The block runs once per scheduler tick (or once per manual POST to
406
+ # /poll), receives the per-grant scratch hash for cursor persistence,
407
+ # and returns the new items.
408
+ #
409
+ # polling do |grant, static_data|
410
+ # since = static_data["last_id"]
411
+ # items = grant.connector.client.get("issues", since: since).body
412
+ # static_data["last_id"] = items.first["id"] if items.any?
413
+ # items
414
+ # end
415
+ #
416
+ # The scheduler itself is a workflow-roadmap concern; the connector
417
+ # side just provides the block + cursor contract.
418
+ def polling(&block)
419
+ @polling_block = block
420
+ end
421
+
422
+ attr_reader :polling_block
423
+
424
+ def polling?
425
+ !@polling_block.nil?
426
+ end
427
+
428
+ # Some providers send a one-time URL-verification ping when you register
429
+ # the webhook URL and expect a specific response (e.g. Slack:
430
+ # {"type":"url_verification","challenge":"..."} → {"challenge":"..."}).
431
+ # Return a hash to render as JSON, or nil to fall through to normal
432
+ # event processing. Default: nil.
433
+ def webhook_challenge(_payload)
434
+ nil
435
+ end
436
+
437
+ # Most providers (Slack, GitHub Apps, Linear, Notion) only let you set
438
+ # ONE webhook URL for your app — the grant must be inferred from the
439
+ # payload. Override to look up the Grant from `payload`/`request`.
440
+ # Return nil to refuse the event with 404. Default: nil.
441
+ def resolve_grant_from_webhook(_payload, _request)
442
+ nil
443
+ end
444
+
445
+ # Optional hook: extract a stable provider-side account identifier from
446
+ # the credentials hash (Slack team_id, GitHub installation_id, etc.).
447
+ # OAuthController persists the return value to Grant#external_account_id
448
+ # so webhook routing can look it up without decrypting credentials.
449
+ def external_account_id_from_credentials(_credentials)
450
+ nil
451
+ end
452
+
453
+ # :app_level — subclass overrode resolve_grant_from_webhook, so every
454
+ # team posts to a single /:connector_key/webhook URL.
455
+ # :per_grant — subclass uses /:connector_key/:grant_id/webhook (the
456
+ # grant id is in the URL itself).
457
+ # Used by the /connectors/types catalog so the frontend can render
458
+ # the right URL hint when installing a provider.
459
+ def webhook_style
460
+ if method(:resolve_grant_from_webhook).owner != Connectors::Connector.singleton_class
461
+ :app_level
462
+ else
463
+ :per_grant
464
+ end
465
+ end
466
+
467
+ # Declarative action manifest — what this connector can DO with a
468
+ # credential. Mirrors n8n's `(resource, operation)` slot
469
+ # (packages/workflow/src/interfaces.ts NodeProperties) and Activepieces'
470
+ # `createAction(...)`. Each action gets a typed param schema, an
471
+ # optional output schema, and an execute block that runs in the
472
+ # connector instance's context.
473
+ #
474
+ # action :send_email,
475
+ # display_name: "Send Email",
476
+ # description: "Send a transactional email." do
477
+ # field :to, type: "string", required: true
478
+ # field :subject, type: "string", required: true
479
+ # field :html, type: "string"
480
+ #
481
+ # output do
482
+ # field :id, type: "string", description: "Resend message id."
483
+ # end
484
+ #
485
+ # execute { |input| send_email(**input.symbolize_keys) }
486
+ # end
487
+ #
488
+ # Surfaced on `/connectors/types/:name` as `actions: [...]` so frontends
489
+ # and agents can discover everything the connector exposes; invocation
490
+ # goes through `POST /connectors/credentials/:id/actions/:name`.
491
+ def action(key, display_name: nil, description: nil, tags: [], deprecated: false, &block)
492
+ action = ActionBuilder.build(key,
493
+ display_name: display_name,
494
+ description: description,
495
+ tags: tags,
496
+ deprecated: deprecated,
497
+ &block)
498
+ (@actions ||= {})[action.key] = action
499
+ action
500
+ end
501
+
502
+ # All actions declared on this connector, in declaration order.
503
+ def actions
504
+ (@actions || {}).values
505
+ end
506
+
507
+ def action_lookup(name)
508
+ (@actions || {})[name.to_sym] or
509
+ raise Connectors::UnknownAction.new(name, connector_key: @connector_key,
510
+ known: (@actions || {}).keys)
511
+ end
512
+
513
+ def action?(name)
514
+ (@actions || {}).key?(name.to_sym)
515
+ end
516
+ end
517
+
518
+ attr_reader :grant
519
+
520
+ def initialize(grant)
521
+ @grant = grant
522
+ end
523
+
524
+ # The Faraday client with the full middleware stack assembled. When the
525
+ # connector class (or its inherited credential schema, via Phase 2)
526
+ # declares an `authenticate` block, AuthenticateGeneric middleware
527
+ # applies it; otherwise the imperative `auth_scheme` is used.
528
+ def client
529
+ @client ||= ClientBuilder.new(
530
+ base_url: self.class.base_url,
531
+ grant: grant,
532
+ auth_scheme: self.class.auth_scheme,
533
+ authenticate_config: self.class.resolved_authenticate_config,
534
+ pre_auth_block: self.class.pre_authentication_block
535
+ ).build
536
+ end
537
+
538
+ # Override in subclass. Called by the AutoRefresh middleware on 401.
539
+ # Should fetch new tokens from the provider and persist via
540
+ # `grant.update_credentials!(...)`.
541
+ def refresh!
542
+ raise NotImplementedError, "#{self.class.name}#refresh! is not implemented"
543
+ end
544
+
545
+ # Override in subclass. Called periodically by Connectors::PollJob if
546
+ # the connector opts into polling.
547
+ def poll; end
548
+
549
+ # Override in subclass. Called by the webhook dispatcher when an event
550
+ # arrives for this grant. The argument is a `Connectors::WebhookContext`
551
+ # (Phase 7) exposing `.body`, `.headers`, `.query`, `.raw_body`,
552
+ # `.webhook_name`, `.signature`, `.grant`, plus `.event` for direct
553
+ # access to the persisted `Connectors::WebhookEvent`. The context
554
+ # delegates `.payload_hash` / `.payload` for back-compat with handlers
555
+ # written before Phase 7 — the same method works on both.
556
+ def handle_webhook(ctx); end
557
+
558
+ # Convenience — validates the grant's credentials against the connector's
559
+ # declared schema. Use in #refresh! callbacks or before risky calls.
560
+ def validate_credentials!
561
+ schema = self.class.credential_schema
562
+ schema&.validate!(grant.credentials_hash)
563
+ end
564
+ end
565
+ end