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,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
|