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,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,5 @@
1
+ module Connectors
2
+ class ApplicationController < ActionController::API
3
+ include GrantAccess
4
+ end
5
+ 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