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,218 @@
|
|
|
1
|
+
module Resend
|
|
2
|
+
# Resend (https://resend.com) — transactional email API.
|
|
3
|
+
# https://resend.com/docs/api-reference/emails/send-email
|
|
4
|
+
#
|
|
5
|
+
# Auth: bearer API key (`re_xxx...`). Single credential field; no OAuth.
|
|
6
|
+
# The host creates a Grant out-of-band (admin paste, env-driven seed,
|
|
7
|
+
# whatever your host wires up).
|
|
8
|
+
class Connector < Connectors::Connector
|
|
9
|
+
connector key: :resend,
|
|
10
|
+
auth: :api_key,
|
|
11
|
+
base_url: "https://api.resend.com",
|
|
12
|
+
display_name: "Resend",
|
|
13
|
+
icon: "https://cdn.resend.com/brand/resend-icon-blacka.svg",
|
|
14
|
+
icon_color: "#000000",
|
|
15
|
+
documentation_url: "https://resend.com/docs/api-reference/emails/send-email",
|
|
16
|
+
llm_docs: "https://resend.com/docs/llms-full.txt",
|
|
17
|
+
instructions: <<~MD
|
|
18
|
+
**Generate an API key:**
|
|
19
|
+
|
|
20
|
+
1. Sign in at [resend.com/api-keys](https://resend.com/api-keys).
|
|
21
|
+
2. Click **Create API Key** and give it a name.
|
|
22
|
+
3. Choose **Full Access** so the "Test connection" button works (Sending Access keys can only `POST /emails`, so they fail every read endpoint).
|
|
23
|
+
4. Copy the key — it starts with `re_` — and paste it below.
|
|
24
|
+
|
|
25
|
+
Keys can be revoked any time from the same dashboard.
|
|
26
|
+
MD
|
|
27
|
+
|
|
28
|
+
credentials do
|
|
29
|
+
field :api_key,
|
|
30
|
+
type: "string",
|
|
31
|
+
display_name: "API Key",
|
|
32
|
+
required: true,
|
|
33
|
+
secret: true,
|
|
34
|
+
placeholder: "re_...",
|
|
35
|
+
description: "Generate at https://resend.com/api-keys."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Phase 1 — declarative auth injection (mirrors n8n's HttpBearerAuth at
|
|
39
|
+
# packages/nodes-base/credentials/HttpBearerAuth.credentials.ts:38-45).
|
|
40
|
+
# The `={{...}}` template is resolved per-request from the Grant's
|
|
41
|
+
# credentials hash by `Middleware::AuthenticateGeneric`.
|
|
42
|
+
authenticate type: :generic, properties: {
|
|
43
|
+
headers: { "Authorization" => "=Bearer {{$credentials.api_key}}" }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Resend's documented rate limit is 2 requests per second.
|
|
47
|
+
# https://resend.com/docs/api-reference/introduction#rate-limit
|
|
48
|
+
rate_limit 2, per: 1.second
|
|
49
|
+
|
|
50
|
+
# ─── actions ─────────────────────────────────────────────────────────
|
|
51
|
+
# Declarative action manifest. Discoverable via /connectors/types/resend
|
|
52
|
+
# → `actions`, invokable via POST /connectors/credentials/:id/actions/send_email.
|
|
53
|
+
# The execute block runs in connector-instance context so we can delegate
|
|
54
|
+
# straight to the existing `send_email` method below (also kept callable
|
|
55
|
+
# directly so spec tests + the workflow executor can use either entry
|
|
56
|
+
# point).
|
|
57
|
+
action :send_email,
|
|
58
|
+
display_name: "Send Email",
|
|
59
|
+
description: "Send a transactional email through Resend." do
|
|
60
|
+
field :from,
|
|
61
|
+
type: "string",
|
|
62
|
+
display_name: "From",
|
|
63
|
+
required: true,
|
|
64
|
+
placeholder: "Updates <updates@yourdomain.com>",
|
|
65
|
+
description: "Sender — must be a verified domain in Resend."
|
|
66
|
+
|
|
67
|
+
field :to,
|
|
68
|
+
type: "string",
|
|
69
|
+
display_name: "To",
|
|
70
|
+
required: true,
|
|
71
|
+
type_options: { multiple_values: true },
|
|
72
|
+
description: "One or more recipient email addresses."
|
|
73
|
+
|
|
74
|
+
field :subject,
|
|
75
|
+
type: "string",
|
|
76
|
+
display_name: "Subject",
|
|
77
|
+
required: true
|
|
78
|
+
|
|
79
|
+
field :html,
|
|
80
|
+
type: "string",
|
|
81
|
+
display_name: "HTML Body",
|
|
82
|
+
type_options: { editor: "html" },
|
|
83
|
+
description: "HTML content. Either html or text is required."
|
|
84
|
+
|
|
85
|
+
field :text,
|
|
86
|
+
type: "string",
|
|
87
|
+
display_name: "Plain Text Body",
|
|
88
|
+
type_options: { rows: 4 },
|
|
89
|
+
description: "Plain text content. Either html or text is required."
|
|
90
|
+
|
|
91
|
+
field :cc,
|
|
92
|
+
type: "string",
|
|
93
|
+
display_name: "CC",
|
|
94
|
+
type_options: { multiple_values: true }
|
|
95
|
+
|
|
96
|
+
field :bcc,
|
|
97
|
+
type: "string",
|
|
98
|
+
display_name: "BCC",
|
|
99
|
+
type_options: { multiple_values: true }
|
|
100
|
+
|
|
101
|
+
field :reply_to,
|
|
102
|
+
type: "string",
|
|
103
|
+
display_name: "Reply To",
|
|
104
|
+
type_options: { multiple_values: true }
|
|
105
|
+
|
|
106
|
+
field :scheduled_at,
|
|
107
|
+
type: "string",
|
|
108
|
+
display_name: "Scheduled At",
|
|
109
|
+
placeholder: "in 1 hour",
|
|
110
|
+
description: "ISO 8601 timestamp OR Resend's natural-language form (e.g. 'in 1 hour')."
|
|
111
|
+
|
|
112
|
+
field :tags,
|
|
113
|
+
type: "json",
|
|
114
|
+
display_name: "Tags",
|
|
115
|
+
description: "Array of `{name, value}` tag objects."
|
|
116
|
+
|
|
117
|
+
field :attachments,
|
|
118
|
+
type: "json",
|
|
119
|
+
display_name: "Attachments",
|
|
120
|
+
description: "Array of `{filename, content, path?, content_type?}` objects."
|
|
121
|
+
|
|
122
|
+
field :headers,
|
|
123
|
+
type: "json",
|
|
124
|
+
display_name: "Custom Headers",
|
|
125
|
+
description: "Hash of header name → value."
|
|
126
|
+
|
|
127
|
+
output do
|
|
128
|
+
field :id, type: "string", description: "Resend message id."
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
execute do |input|
|
|
132
|
+
send_email(**input.symbolize_keys)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# "Test connection" hits Resend's `GET /domains` — a cheap, idempotent
|
|
137
|
+
# read that succeeds for any **Full Access** API key.
|
|
138
|
+
#
|
|
139
|
+
# CAVEAT, per Resend's docs (https://resend.com/docs/dashboard/api-keys):
|
|
140
|
+
# - "Full Access" keys can create/delete/get/update any resource
|
|
141
|
+
# - "Sending Access" keys can ONLY POST /emails (send) — no reads
|
|
142
|
+
#
|
|
143
|
+
# Sending-Access keys will return 401 on EVERY GET endpoint Resend
|
|
144
|
+
# exposes, including this one. So this test reliably verifies Full
|
|
145
|
+
# Access keys; Sending-Access keys will always show as "failed" here
|
|
146
|
+
# even when the key works fine for sending. The only way to truly
|
|
147
|
+
# test a Sending-Access key is to actually send an email — which
|
|
148
|
+
# we don't do because it's destructive.
|
|
149
|
+
test_request method: :get, url: "domains"
|
|
150
|
+
|
|
151
|
+
# Send a transactional email. Returns Resend's `{ "id" => "..." }` payload
|
|
152
|
+
# so downstream nodes can reference the message id.
|
|
153
|
+
#
|
|
154
|
+
# from: "Updates <updates@yourdomain.com>"
|
|
155
|
+
# to: "user@example.com" | [ "a@example.com", "b@example.com" ]
|
|
156
|
+
# reply_to: same — single string OR array
|
|
157
|
+
#
|
|
158
|
+
# `html` and `text` are both optional but at least one must be present —
|
|
159
|
+
# Resend rejects emails with no body. Logical 200-with-error responses
|
|
160
|
+
# are promoted to Connectors::ApiError so the executor records the
|
|
161
|
+
# failure on the Step.
|
|
162
|
+
def send_email(from:, to:, subject:, html: nil, text: nil,
|
|
163
|
+
cc: nil, bcc: nil, reply_to: nil, tags: nil,
|
|
164
|
+
scheduled_at: nil, attachments: nil, headers: nil)
|
|
165
|
+
raise Connectors::ApiError.new("send_email requires html or text") if html.nil? && text.nil?
|
|
166
|
+
|
|
167
|
+
body = {
|
|
168
|
+
"from" => from,
|
|
169
|
+
"to" => Array(to),
|
|
170
|
+
"subject" => subject
|
|
171
|
+
}
|
|
172
|
+
body["html"] = html if html
|
|
173
|
+
body["text"] = text if text
|
|
174
|
+
body["cc"] = Array(cc) if cc
|
|
175
|
+
body["bcc"] = Array(bcc) if bcc
|
|
176
|
+
body["reply_to"] = Array(reply_to) if reply_to # Resend expects string[]
|
|
177
|
+
body["tags"] = tags if tags
|
|
178
|
+
body["scheduled_at"] = scheduled_at if scheduled_at
|
|
179
|
+
body["attachments"] = attachments if attachments
|
|
180
|
+
body["headers"] = headers if headers
|
|
181
|
+
|
|
182
|
+
response = client.post("emails", body)
|
|
183
|
+
payload = response.body
|
|
184
|
+
|
|
185
|
+
if response.status >= 400 || resend_error?(payload)
|
|
186
|
+
raise Connectors::ApiError.new(
|
|
187
|
+
"Resend send_email failed: #{resend_error_message(payload) || payload.inspect}",
|
|
188
|
+
status: response.status,
|
|
189
|
+
body: payload
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
payload
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
# Resend returns errors in two distinct shapes depending on the failure
|
|
199
|
+
# mode (per Resend's own docs at resend.com/docs/api-reference/errors):
|
|
200
|
+
#
|
|
201
|
+
# 1. Top-level: `{ "name": "missing_required_field", "message": "...",
|
|
202
|
+
# "statusCode": 422 }`
|
|
203
|
+
# 2. Nested: `{ "error": { "message": "..." } }`
|
|
204
|
+
#
|
|
205
|
+
# A 200-but-failed response carries shape (1). A non-2xx may carry
|
|
206
|
+
# either. Both have to be recognized or we'd silently treat broken
|
|
207
|
+
# sends as successful.
|
|
208
|
+
def resend_error?(payload)
|
|
209
|
+
return false unless payload.is_a?(Hash)
|
|
210
|
+
payload.key?("error") || payload.key?("name") || payload.key?("statusCode")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def resend_error_message(payload)
|
|
214
|
+
return nil unless payload.is_a?(Hash)
|
|
215
|
+
payload.dig("error", "message") || payload["message"]
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
# Shared access boundary for every credential endpoint. The host supplies
|
|
3
|
+
# identities; the engine applies the same roles to CRUD, actions and OAuth.
|
|
4
|
+
module GrantAccess
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
ROLE_RANK = GrantPolicy::ROLE_RANK
|
|
10
|
+
|
|
11
|
+
def current_owner!
|
|
12
|
+
@connectors_owner ||= Connectors.configuration.resolve_owner(self) or
|
|
13
|
+
raise Connectors::Error, "current_owner_resolver returned nil"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def current_principals
|
|
17
|
+
@connectors_principals ||= Connectors.configuration.resolve_principals(self) || []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def visible_grants
|
|
21
|
+
owner = current_owner!
|
|
22
|
+
shared_ids = CredentialShare.for_principals(current_principals).select(:grant_id)
|
|
23
|
+
Grant.where(owner: owner).or(Grant.where(id: shared_ids))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def find_visible_grant!(id, min_role: :viewer)
|
|
27
|
+
grant = visible_grants.find(id)
|
|
28
|
+
require_grant_role!(grant, min_role)
|
|
29
|
+
grant
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def require_grant_role!(grant, min_role)
|
|
33
|
+
role = role_for(grant, owner: current_owner!)
|
|
34
|
+
if role.nil? || ROLE_RANK.fetch(role) < ROLE_RANK.fetch(min_role.to_s)
|
|
35
|
+
raise Connectors::Error, "credential #{grant.id} requires #{min_role} role (you have #{role})"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def role_for(grant, owner:)
|
|
40
|
+
GrantPolicy.role_for(grant, owner: owner, principals: current_principals)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
# HTTP surface for invoking a connector's declared actions.
|
|
3
|
+
#
|
|
4
|
+
# POST /connectors/credentials/:id/actions/:name
|
|
5
|
+
# { "data": { "to": "alice@example.com", "subject": "Hi", "html": "..." } }
|
|
6
|
+
#
|
|
7
|
+
# Looks up the Grant (scoped to the current owner + any shares), resolves
|
|
8
|
+
# the action on the grant's connector class, validates the input against
|
|
9
|
+
# the action's param schema, then dispatches through `ActionRunner`. The
|
|
10
|
+
# response envelope is always `{ status: "ok"|"error", action:, ... }` so
|
|
11
|
+
# frontends and workflow executors consume the same shape.
|
|
12
|
+
#
|
|
13
|
+
# n8n parity: this is the moral equivalent of the executor's per-node
|
|
14
|
+
# `execute()` step (packages/core/src/node-execute-functions.ts) reachable
|
|
15
|
+
# over HTTP. The workflow engine in `automations/` will eventually call
|
|
16
|
+
# `ActionRunner.call(grant, action, input)` directly without going
|
|
17
|
+
# through HTTP — same path, no controller in the loop.
|
|
18
|
+
class ActionsController < ApplicationController
|
|
19
|
+
# Order matters: Rails `rescue_from` iterates in REVERSE declaration
|
|
20
|
+
# order, so the more-specific subclasses must be declared AFTER
|
|
21
|
+
# `Connectors::Error` to be matched first. UnknownAction is a
|
|
22
|
+
# Connectors::Error subclass — without its own handler, the generic
|
|
23
|
+
# Error path would return 401 instead of 404.
|
|
24
|
+
rescue_from Connectors::Error, with: :render_unauthorized
|
|
25
|
+
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
|
|
26
|
+
rescue_from Connectors::UnknownAction, with: :render_not_found
|
|
27
|
+
|
|
28
|
+
# GET /connectors/credentials/:id/actions
|
|
29
|
+
# Lists the actions available on the grant's connector. Same data as
|
|
30
|
+
# /connectors/types/:name → actions[], but resolved against the actual
|
|
31
|
+
# grant so the frontend can render an "actions for this connection"
|
|
32
|
+
# picker without re-fetching the catalog.
|
|
33
|
+
def index
|
|
34
|
+
grant = find_visible_grant!(params[:id])
|
|
35
|
+
render json: { actions: grant.connector.class.actions.map(&:to_manifest) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# POST /connectors/credentials/:id/actions/:name
|
|
39
|
+
# Body: { data: {...} } — the action's input params.
|
|
40
|
+
def create
|
|
41
|
+
grant = find_visible_grant!(params[:id], min_role: :editor)
|
|
42
|
+
input = (params[:data] || {}).to_unsafe_h
|
|
43
|
+
result = Connectors::ActionRunner.call(grant, params[:name], input)
|
|
44
|
+
|
|
45
|
+
status_code =
|
|
46
|
+
if result[:status] == Connectors::ActionRunner::OK
|
|
47
|
+
:ok
|
|
48
|
+
else
|
|
49
|
+
case result.dig(:error, :type)
|
|
50
|
+
when "invalid_params" then :unprocessable_content
|
|
51
|
+
when "authentication_failed" then :unauthorized
|
|
52
|
+
when "forbidden" then :forbidden
|
|
53
|
+
when "rate_limited" then :too_many_requests
|
|
54
|
+
else :bad_gateway
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
render json: result, status: status_code
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def render_not_found(e); render(json: { error: e.message }, status: :not_found); end
|
|
64
|
+
def render_unauthorized(e); render(json: { error: e.message }, status: :unauthorized); end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
# n8n-shaped credential CRUD. Underneath, a "credential" is a Grant — but
|
|
3
|
+
# this controller exposes it under the vocabulary the frontend uses
|
|
4
|
+
# (matches packages/cli/src/credentials/credentials.controller.ts:67-411).
|
|
5
|
+
#
|
|
6
|
+
# The paste-the-key flow lives here: `POST /credentials` with `{type,
|
|
7
|
+
# name, data}` creates a Grant directly without the OAuth dance.
|
|
8
|
+
# OAuth-issued grants continue to flow through OAuthController#callback
|
|
9
|
+
# and show up in this list automatically.
|
|
10
|
+
class CredentialsController < ApplicationController
|
|
11
|
+
# Order matters: Rails `rescue_from` iterates in REVERSE declaration
|
|
12
|
+
# order, so the more-specific subclasses must be declared AFTER
|
|
13
|
+
# `Connectors::Error` to be matched first.
|
|
14
|
+
rescue_from Connectors::Error, with: :render_unauthorized
|
|
15
|
+
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
|
|
16
|
+
rescue_from Connectors::UnknownConnector, with: :render_not_found
|
|
17
|
+
rescue_from MCP::ValidationError, MCP::ConfigurationRequired do |error|
|
|
18
|
+
render json: { error: error.message }, status: :unprocessable_content
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# GET /connectors/credentials
|
|
22
|
+
# ?type=resend # filter by connector type
|
|
23
|
+
# ?include_data=true # owner-only secrets; MCP public config only
|
|
24
|
+
#
|
|
25
|
+
# Returns the union of: credentials this owner owns + credentials shared
|
|
26
|
+
# with any of the requester's principals (User, Team, Project — whatever
|
|
27
|
+
# the host's `principal_resolver` returns). n8n parity:
|
|
28
|
+
# enterprise/credentials.controller.ee.ts.
|
|
29
|
+
def index
|
|
30
|
+
grants = visible_grants
|
|
31
|
+
grants = grants.for_connector(params[:type]) if params[:type].present?
|
|
32
|
+
include_data = params[:include_data].to_s == "true"
|
|
33
|
+
|
|
34
|
+
render json: { credentials: grants.order(updated_at: :desc).map { |g| serialize(g, include_data: include_data) } }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# GET /connectors/credentials/for-workflow
|
|
38
|
+
def for_workflow
|
|
39
|
+
grants = visible_grants
|
|
40
|
+
grants = grants.for_connector(params[:type]) if params[:type].present?
|
|
41
|
+
render json: { credentials: grants.order(updated_at: :desc).map { |g| serialize(g) } }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# GET /connectors/credentials/:id
|
|
45
|
+
def show
|
|
46
|
+
grant = find_visible_grant!(params[:id])
|
|
47
|
+
render json: serialize(grant, include_data: params[:include_data].to_s == "true")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# POST /connectors/credentials
|
|
51
|
+
# Body: { type, name, data?, external_ref?, is_managed? }
|
|
52
|
+
#
|
|
53
|
+
# When `is_managed: true` is passed (typically alongside `external_ref:
|
|
54
|
+
# "vault/path/foo"`), the DB only stores the reference + non-sensitive
|
|
55
|
+
# field defaults; the actual credential values come from the host's
|
|
56
|
+
# `secrets_resolver` at request time. n8n parity: external-secrets EE
|
|
57
|
+
# module (external-secrets.controller.ee.ts).
|
|
58
|
+
def create
|
|
59
|
+
owner = current_owner!
|
|
60
|
+
type = params.fetch(:type)
|
|
61
|
+
klass = Registry.fetch(type) # raises UnknownConnector → 404 if bogus
|
|
62
|
+
|
|
63
|
+
is_managed = [ true, "true", "1", 1 ].include?(params[:is_managed])
|
|
64
|
+
if is_managed && klass.skip_managed_creation?
|
|
65
|
+
raise Connectors::Error.new("managed credentials are disabled for #{type.inspect}")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
data = (params[:data] || ActionController::Parameters.new).to_unsafe_h
|
|
69
|
+
data = MCP::ConnectionConfig.resolve(data, connector: klass, require_secrets: !is_managed) if klass.mcp?
|
|
70
|
+
grant = Grant.create!(
|
|
71
|
+
owner: owner,
|
|
72
|
+
connector_key: type.to_s,
|
|
73
|
+
display_name: params[:name].presence,
|
|
74
|
+
credentials: data,
|
|
75
|
+
external_ref: params[:external_ref].presence,
|
|
76
|
+
is_managed: is_managed,
|
|
77
|
+
status: :active,
|
|
78
|
+
last_used_at: Time.current
|
|
79
|
+
)
|
|
80
|
+
render json: serialize(grant), status: :created
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# GET /connectors/credentials/new?type=resend
|
|
84
|
+
# Server-generated unique default name ("Resend account 3"). n8n parity:
|
|
85
|
+
# `credentials.controller.ts:100-111`. The frontend pre-fills the name
|
|
86
|
+
# field with the response so the user can rename or accept the default.
|
|
87
|
+
def new
|
|
88
|
+
current_owner!
|
|
89
|
+
type = params.fetch(:type)
|
|
90
|
+
klass = Registry.fetch(type)
|
|
91
|
+
base = klass.display_name || type.to_s.humanize
|
|
92
|
+
base += " account"
|
|
93
|
+
|
|
94
|
+
taken = Grant.where(connector_key: type.to_s)
|
|
95
|
+
.where("display_name LIKE ?", "#{base}%")
|
|
96
|
+
.pluck(:display_name)
|
|
97
|
+
.compact
|
|
98
|
+
n = 1
|
|
99
|
+
n += 1 while taken.include?("#{base} #{n}")
|
|
100
|
+
render json: { name: "#{base} #{n}" }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# PATCH /connectors/credentials/:id
|
|
104
|
+
# Body: { name?, data? } — data is merged into existing credentials.
|
|
105
|
+
# Requires `editor` (or owner) role.
|
|
106
|
+
def update
|
|
107
|
+
grant = find_visible_grant!(params[:id], min_role: :editor)
|
|
108
|
+
|
|
109
|
+
if params.key?(:data)
|
|
110
|
+
new_data = (params[:data] || {}).to_unsafe_h
|
|
111
|
+
klass = Registry.fetch(grant.connector_key)
|
|
112
|
+
if klass.mcp?
|
|
113
|
+
require_grant_role!(grant, :owner)
|
|
114
|
+
MCP::ConnectionConfig.resolve(grant.credentials_hash.except("mcp_oauth").merge(new_data), connector: klass)
|
|
115
|
+
new_data["mcp_oauth"] = nil
|
|
116
|
+
end
|
|
117
|
+
grant.update_credentials!(new_data)
|
|
118
|
+
end
|
|
119
|
+
grant.display_name = params[:name] if params.key?(:name)
|
|
120
|
+
grant.save! if grant.changed?
|
|
121
|
+
|
|
122
|
+
render json: serialize(grant)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# DELETE /connectors/credentials/:id — owner only.
|
|
126
|
+
def destroy
|
|
127
|
+
find_visible_grant!(params[:id], min_role: :owner).destroy
|
|
128
|
+
head :no_content
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# PUT /connectors/credentials/:id/share
|
|
132
|
+
# Body: { principal_type: "User"|"Team"|..., principal_id: <uuid>, role: "viewer"|"editor" }
|
|
133
|
+
# Owner only. Adds (or updates) a share row.
|
|
134
|
+
def share
|
|
135
|
+
grant = find_visible_grant!(params[:id], min_role: :owner)
|
|
136
|
+
share = CredentialShare.find_or_initialize_by(
|
|
137
|
+
grant: grant,
|
|
138
|
+
principal_type: params.fetch(:principal_type),
|
|
139
|
+
principal_id: params.fetch(:principal_id).to_s
|
|
140
|
+
)
|
|
141
|
+
share.role = params.fetch(:role, "viewer")
|
|
142
|
+
share.save!
|
|
143
|
+
render json: serialize_share(share)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# DELETE /connectors/credentials/:id/share
|
|
147
|
+
# Body: { principal_type, principal_id: <uuid> }
|
|
148
|
+
def unshare
|
|
149
|
+
grant = find_visible_grant!(params[:id], min_role: :owner)
|
|
150
|
+
share = grant.shares.find_by!(
|
|
151
|
+
principal_type: params.fetch(:principal_type),
|
|
152
|
+
principal_id: params.fetch(:principal_id).to_s
|
|
153
|
+
)
|
|
154
|
+
share.destroy
|
|
155
|
+
head :no_content
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# PUT /connectors/credentials/:id/transfer
|
|
159
|
+
# Body: { owner_id: <uuid> } — moves ownership to a different owner.
|
|
160
|
+
# n8n parity: enterprise/credentials.controller.ee.ts transfer endpoint.
|
|
161
|
+
def transfer
|
|
162
|
+
grant = find_visible_grant!(params[:id], min_role: :owner)
|
|
163
|
+
new_owner_klass = Connectors.configuration.owner_class_name.constantize
|
|
164
|
+
new_owner = new_owner_klass.find(params.fetch(:owner_id).to_s)
|
|
165
|
+
grant.update!(owner: new_owner)
|
|
166
|
+
render json: serialize(grant)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# POST /connectors/credentials/test
|
|
170
|
+
# Test an UNSAVED credential. Body: { type, data: {...} }
|
|
171
|
+
# Spins up a transient Grant (not persisted) and runs the connector's
|
|
172
|
+
# declared test_request through it.
|
|
173
|
+
def test_unsaved
|
|
174
|
+
type = params.fetch(:type)
|
|
175
|
+
data = (params[:data] || {}).to_unsafe_h
|
|
176
|
+
|
|
177
|
+
transient = Grant.new(
|
|
178
|
+
owner: current_owner!,
|
|
179
|
+
connector_key: type.to_s,
|
|
180
|
+
credentials: data,
|
|
181
|
+
status: :active
|
|
182
|
+
)
|
|
183
|
+
# Bypass save — we just need #connector and #credentials_hash.
|
|
184
|
+
transient.define_singleton_method(:credentials_hash) { credentials || {} }
|
|
185
|
+
|
|
186
|
+
result = Connectors::CredentialTester.run(transient)
|
|
187
|
+
status_code = result[:status] == Connectors::CredentialTester::OK ? :ok : :unprocessable_content
|
|
188
|
+
render json: result, status: status_code
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
def serialize_share(share)
|
|
194
|
+
{
|
|
195
|
+
id: share.id,
|
|
196
|
+
grant_id: share.grant_id,
|
|
197
|
+
principal_type: share.principal_type,
|
|
198
|
+
principal_id: share.principal_id,
|
|
199
|
+
role: share.role
|
|
200
|
+
}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def serialize(grant, include_data: false)
|
|
204
|
+
h = {
|
|
205
|
+
id: grant.id,
|
|
206
|
+
type: grant.connector_key, # n8n name
|
|
207
|
+
connector_key: grant.connector_key, # @deprecated alias
|
|
208
|
+
name: grant.display_name || "#{grant.connector_key} ##{grant.id}",
|
|
209
|
+
external_account_id: grant.external_account_id,
|
|
210
|
+
status: grant.status,
|
|
211
|
+
expires_at: grant.expires_at,
|
|
212
|
+
last_used_at: grant.last_used_at,
|
|
213
|
+
created_at: grant.created_at,
|
|
214
|
+
updated_at: grant.updated_at,
|
|
215
|
+
scopes: extract_scopes(grant),
|
|
216
|
+
# Phase 10 — external secrets manager. `is_managed: true` means the
|
|
217
|
+
# actual credential values are vault-sourced; `external_ref` is the
|
|
218
|
+
# opaque key the host's `secrets_resolver` uses to look them up.
|
|
219
|
+
is_managed: grant.is_managed,
|
|
220
|
+
external_ref: grant.external_ref,
|
|
221
|
+
# n8n's `__overwrittenProperties` (interfaces.ts:382) but per-grant
|
|
222
|
+
# so the editor can show "this field comes from Vault: <api_key>".
|
|
223
|
+
__overwritten_properties: grant.is_managed ? Connectors.configuration.managed_fields_for(grant.connector_key) : []
|
|
224
|
+
}
|
|
225
|
+
if include_data
|
|
226
|
+
if Registry.fetch(grant.connector_key).mcp?
|
|
227
|
+
h[:data] = MCP::ConnectionConfig.public_data(grant.credentials_hash)
|
|
228
|
+
elsif role_for(grant, owner: current_owner!) == "owner"
|
|
229
|
+
h[:data] = grant.credentials_hash
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
h
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def extract_scopes(grant)
|
|
236
|
+
raw = grant.credentials_hash["scope"]
|
|
237
|
+
return nil if raw.nil?
|
|
238
|
+
return raw if raw.is_a?(Array)
|
|
239
|
+
raw.to_s.split(/[,\s]+/).reject(&:empty?)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def render_not_found(e); render(json: { error: e.message }, status: :not_found); end
|
|
243
|
+
def render_unauthorized(e); render(json: { error: e.message }, status: :unauthorized); end
|
|
244
|
+
end
|
|
245
|
+
end
|