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,799 @@
|
|
|
1
|
+
# Connectors Framework Reference
|
|
2
|
+
|
|
3
|
+
Developer reference for the `connectors` Rails engine. Historical n8n source references describe design inspiration; they are not a claim of current n8n parity. For installation and supported environments, start with the [README](README.md).
|
|
4
|
+
|
|
5
|
+
Current behavior is verified by the repository test suite. [Architecture](docs/architecture.md), [connector authoring](docs/adding-connectors.md) and [MCP support](MCP_CLIENT.md) cover the newer action and remote-tool paths.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Architecture
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
flow-api (host Rails app)
|
|
13
|
+
└─ mounts Connectors::Engine at <host-chosen prefix> (e.g. /api/v1/connectors)
|
|
14
|
+
├─ Configuration block (host wires owner / OAuth secrets / vault)
|
|
15
|
+
├─ Registry (loaded at boot from app/connectors/*/)
|
|
16
|
+
├─ Models
|
|
17
|
+
│ ├─ Grant (one row = one authorized account)
|
|
18
|
+
│ ├─ CredentialShare (per-grant sharing with roles)
|
|
19
|
+
│ └─ WebhookEvent (inbound webhook persistence)
|
|
20
|
+
├─ Controllers
|
|
21
|
+
│ ├─ TypesController GET /types[/:name]
|
|
22
|
+
│ ├─ CredentialsController GET/POST/PATCH/DELETE /credentials[/:id][/share|transfer]
|
|
23
|
+
│ ├─ GrantsController /grants[/:id]/{test,webhook_subscribe,poll}
|
|
24
|
+
│ ├─ OAuthController /:key/{authorize,callback} + /credentials/:id/revoke
|
|
25
|
+
│ └─ WebhooksController POST /:key[/:grant_id][/webhook[/:name]]
|
|
26
|
+
├─ Per-connector classes (user code in app/connectors/<key>/connector.rb)
|
|
27
|
+
└─ Faraday middleware stack (auth injection, refresh, rate-limit, error normalization)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Host independence.** The engine never references host constants (`User`, `Team`, ...) directly — it goes through `Connectors.configuration` callables. The host owns the identity model (the `Owner`); the engine owns the credential model.
|
|
31
|
+
|
|
32
|
+
**Mount-path independence.** The host picks where to mount the engine in `config/routes.rb`. The engine introspects the actual mount path at boot (`Connectors::Engine.mount_path`) and every URL builder (OAuth callback, default webhook URL, types-endpoint `redirect_uri` field) is wired through it. No `mount_path` configuration in initializers, no string concatenation traps — the host's routes file is the single source of truth. See §1.1 below.
|
|
33
|
+
|
|
34
|
+
**No expression engine.** Phase 1 ships a tiny `{{$credentials.<field>}}` substitutor (`Connectors::AuthInjection`) scoped to credential templates. The connectors engine stays workflow-independent — anything richer belongs in the automations engine.
|
|
35
|
+
|
|
36
|
+
### 1.1 Mount-path introspection
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# host: config/routes.rb
|
|
40
|
+
Rails.application.routes.draw do
|
|
41
|
+
namespace :api do
|
|
42
|
+
scope "v1" do
|
|
43
|
+
mount Connectors::Engine => "/connectors"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# engine: lazily reads from Rails.application.routes at first call
|
|
49
|
+
Connectors::Engine.mount_path # => "/api/v1/connectors"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The engine walks the host's compiled route table (peeling any `Constraints` wrappers Rails inserts around mounted engines) and caches the result. Reset for spec remounts via `Connectors::Engine.reset_mount_path!`.
|
|
53
|
+
|
|
54
|
+
Concrete consequences:
|
|
55
|
+
|
|
56
|
+
| What | Without `mount_path` | With `mount_path` |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| OAuth `redirect_uri` sent to the provider | hardcoded `<host>/connectors/<key>/callback` | `<host>/api/v1/connectors/<key>/callback` automatically |
|
|
59
|
+
| Default `hook_url` for `POST /grants/:id/webhook_subscribe` | hardcoded `<host>/connectors/...` | matches the mount, whatever it is |
|
|
60
|
+
| `GET /types/:name` `connector.redirect_uri` / `authorize_url` / `authorize_json_url` | hardcoded | matches the mount |
|
|
61
|
+
|
|
62
|
+
The host can remount under any prefix (`/api/v2/connectors`, `/_/integrations`, etc.) and everything Just Works.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## 2. Quick Start
|
|
67
|
+
|
|
68
|
+
Resend is already shipped; do not redefine it to get started. Follow the [installation and first-connection guide](README.md#installation), then use [Adding a connector](docs/adding-connectors.md) for a new provider. The engine loads host connector classes from `app/connectors/<key>/connector.rb`.
|
|
69
|
+
|
|
70
|
+
The host must configure PostgreSQL, UUID owner models, Rails encryption, authenticated owner resolution and the engine mount. See the README for concrete configuration. `GET <mount>/types` lists registered connectors; `POST <mount>/credentials` creates a credential; `POST <mount>/grants/:id/test` performs its declared connection test.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## 3. Connector DSL Reference
|
|
75
|
+
|
|
76
|
+
`Connectors::Connector` — subclass it. Every DSL below is a class-level method.
|
|
77
|
+
|
|
78
|
+
### 3.1 `connector key:, auth:, base_url:, **opts`
|
|
79
|
+
|
|
80
|
+
Registers the connector. **Required** — every connector class calls this first.
|
|
81
|
+
|
|
82
|
+
| Arg | Type | Meaning |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `key:` | symbol | machine identifier, unique per app (`:slack`) |
|
|
85
|
+
| `auth:` | symbol | legacy auth scheme name (`:api_key`, `:oauth2`). Kept for back-compat with pre-Phase-1 connectors. New code should rely on `authenticate` (§3.3). |
|
|
86
|
+
| `base_url:` | string | every `client.get`/`post`/etc. is relative to this |
|
|
87
|
+
| `display_name:` | string | UI label (defaults to `key.humanize`) |
|
|
88
|
+
| `icon:` | string | URL or asset path |
|
|
89
|
+
| `icon_color:` | string | hex |
|
|
90
|
+
| `documentation_url:` | string | external docs link |
|
|
91
|
+
|
|
92
|
+
### 3.2 `credentials do ... end`
|
|
93
|
+
|
|
94
|
+
Declares the credential form. Inner block uses `field` (see §4).
|
|
95
|
+
|
|
96
|
+
### 3.3 `authenticate type: :generic, properties: { ... }`
|
|
97
|
+
|
|
98
|
+
Declarative auth injection. Mirrors n8n's `IAuthenticateGeneric` (`packages/workflow/src/interfaces.ts:269-288`). `properties` accepts any subset of:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
authenticate type: :generic, properties: {
|
|
102
|
+
headers: { "Authorization" => "=Bearer {{$credentials.token}}" },
|
|
103
|
+
qs: { "api_key" => "={{$credentials.api_key}}" },
|
|
104
|
+
body: { "auth_token" => "={{$credentials.token}}" },
|
|
105
|
+
auth: { username: "={{$credentials.user}}",
|
|
106
|
+
password: "={{$credentials.password}}" } # HTTP Basic shortcut
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Strings starting with `=` are templates; `{{$credentials.<field>}}` substitutes at request time. Strings without `=` are passed verbatim. Both keys AND values can be templates (HttpHeaderAuth's user-named-header case).
|
|
111
|
+
|
|
112
|
+
### 3.4 `pre_authentication do |credentials, helpers| ... end`
|
|
113
|
+
|
|
114
|
+
Runs before each request when the grant's `expires_at` is missing or in the past. The block's return value is merged into the grant's credentials before `authenticate` runs.
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
pre_authentication do |credentials, helpers|
|
|
118
|
+
resp = helpers.http_request(
|
|
119
|
+
method: :post,
|
|
120
|
+
url: "#{credentials['url']}/oauth2/token",
|
|
121
|
+
body: { client_id: credentials["client_id"], client_secret: credentials["client_secret"] },
|
|
122
|
+
headers: { "Content-Type" => "application/x-www-form-urlencoded" }
|
|
123
|
+
)
|
|
124
|
+
{ "session_token" => resp["access_token"],
|
|
125
|
+
"expires_at" => Time.now.to_i + resp["expires_in"].to_i }
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Helpers raise `Connectors::ApiError` on non-2xx — the middleware wraps any hook failure as `Connectors::AuthenticationFailed`.
|
|
130
|
+
|
|
131
|
+
### 3.5 `oauth2 ...`
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
oauth2 authorize_url: "https://slack.com/oauth/v2/authorize",
|
|
135
|
+
token_url: "https://slack.com/api/oauth.v2.access",
|
|
136
|
+
scope: "chat:write,channels:read",
|
|
137
|
+
extra_authorize_params: { user_scope: "..." },
|
|
138
|
+
grant_type: "authorizationCode", # or "clientCredentials" / "pkce"
|
|
139
|
+
authentication: "header" # or "body"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`grant_type` selects the runtime flow:
|
|
143
|
+
- `authorizationCode` (default) — standard redirect.
|
|
144
|
+
- `clientCredentials` — server-to-server (no `authorize_url` required).
|
|
145
|
+
- `pkce` — RFC 7636 S256.
|
|
146
|
+
|
|
147
|
+
`authentication` controls code exchange, refresh, and client-credentials requests: `header` = HTTP Basic, `body` = form parameters. Public PKCE clients send only their client ID and verifier. `token_expired_status: 401` defaults to 401; providers may explicitly declare another 4xx expiry status. A default 403 raises `Connectors::Forbidden` without refreshing.
|
|
148
|
+
|
|
149
|
+
### 3.6 `oauth1 ...`
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
oauth1 request_token_url: "https://api.twitter.com/oauth/request_token",
|
|
153
|
+
authorize_url: "https://api.twitter.com/oauth/authorize",
|
|
154
|
+
access_token_url: "https://api.twitter.com/oauth/access_token",
|
|
155
|
+
signature_method: "HMAC-SHA1" # or HMAC-SHA256 / HMAC-SHA512
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Engine signs request-token + access-token legs inline (no `oauth-1.0a` gem). Consumer key/secret come from `Connectors.configuration.oauth_credentials_for(connector_key)` — same slot as OAuth2's client id/secret.
|
|
159
|
+
|
|
160
|
+
### 3.7 `revoke_token_url "..."` / `revoke_token { |grant| ... }`
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
revoke_token_url "https://slack.com/api/auth.revoke"
|
|
164
|
+
|
|
165
|
+
# OR — for providers with a non-RFC-7009 shape:
|
|
166
|
+
revoke_token do |grant|
|
|
167
|
+
Faraday.get("https://oauth2.googleapis.com/revoke",
|
|
168
|
+
{ token: grant.credentials_hash["access_token"] })
|
|
169
|
+
end
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`POST <mount>/credentials/:id/revoke` calls this and flips the grant to `status: :revoked`.
|
|
173
|
+
|
|
174
|
+
### 3.8 `test_request method:, url:, headers:, query:, expect_status:, rules:`
|
|
175
|
+
|
|
176
|
+
Declarative "Test connection". Mirrors n8n's `ICredentialTestRequest` (`interfaces.ts:340-348`).
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
test_request method: :get, url: "auth.test",
|
|
180
|
+
rules: [
|
|
181
|
+
{ type: :response_success_body, key: "ok", value: false,
|
|
182
|
+
message: "Slack token is invalid" }
|
|
183
|
+
]
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Rule types: `:response_code`, `:response_success_body`.
|
|
187
|
+
|
|
188
|
+
### 3.9 `rate_limit count, per: <interval>`
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
rate_limit 5, per: 1.second
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Wraps outbound calls in a token bucket via the rate-limit middleware.
|
|
195
|
+
|
|
196
|
+
### 3.10 `verify_webhooks_with VerifierClass`
|
|
197
|
+
|
|
198
|
+
Attach a `Connectors::Webhooks::Verifier` subclass to validate inbound signatures. When unset, the controller accepts any caller — useful in dev, dangerous in prod.
|
|
199
|
+
|
|
200
|
+
### 3.11 `webhook_challenge` / `resolve_grant_from_webhook` / `external_account_id_from_credentials`
|
|
201
|
+
|
|
202
|
+
Override in subclass:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
# URL-verification ping (Slack)
|
|
206
|
+
def self.webhook_challenge(payload)
|
|
207
|
+
return nil unless payload["type"] == "url_verification"
|
|
208
|
+
{ challenge: payload["challenge"] }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# App-level webhook → grant lookup by payload team_id
|
|
212
|
+
def self.resolve_grant_from_webhook(payload, request)
|
|
213
|
+
team_id = payload.dig("team_id") || payload.dig("team", "id")
|
|
214
|
+
Connectors::Grant.where(connector_key: connector_key).find_by(external_account_id: team_id)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def self.external_account_id_from_credentials(creds)
|
|
218
|
+
creds["team_id"]
|
|
219
|
+
end
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The controller calls `webhook_style` to know whether to expect `:app_level` (single URL, payload-routed) or `:per_grant` (grant id in URL).
|
|
223
|
+
|
|
224
|
+
### 3.12 `webhook_methods(:group) do ... end`
|
|
225
|
+
|
|
226
|
+
Subscription lifecycle. n8n parity: `INodeType.webhookMethods.default.{checkExists, create, delete}`.
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
webhook_methods do # group :default (implicit)
|
|
230
|
+
check_exists do |grant, hook_url, static_data|
|
|
231
|
+
body = grant.connector.client.get("hooks").body
|
|
232
|
+
found = body["Webhooks"].find { |h| h["Url"] == hook_url }
|
|
233
|
+
static_data["webhook_id"] = found["ID"] if found
|
|
234
|
+
!found.nil?
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
create do |grant, hook_url, static_data|
|
|
238
|
+
resp = grant.connector.client.post("hooks", { url: hook_url }.to_json).body
|
|
239
|
+
if resp["ID"]
|
|
240
|
+
static_data["webhook_id"] = resp["ID"]
|
|
241
|
+
true
|
|
242
|
+
else
|
|
243
|
+
false
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
delete do |grant, static_data|
|
|
248
|
+
id = static_data["webhook_id"]
|
|
249
|
+
next true if id.nil?
|
|
250
|
+
grant.connector.client.delete("hooks/#{id}")
|
|
251
|
+
static_data.delete("webhook_id")
|
|
252
|
+
true
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Multi-webhook providers (Slack `setup` + `default`) declare additional named groups:
|
|
258
|
+
|
|
259
|
+
```ruby
|
|
260
|
+
webhook_methods(:setup) do ... end
|
|
261
|
+
webhook_methods(:default) do ... end
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
State persists in `grant.static_data["<group_name>"]` — a per-grant jsonb hash.
|
|
265
|
+
|
|
266
|
+
### 3.13 `polling do |grant, static_data| ... end`
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
polling do |grant, static_data|
|
|
270
|
+
since = static_data["last_id"]
|
|
271
|
+
items = grant.connector.client.get("messages", { since: since }.compact).body["items"]
|
|
272
|
+
static_data["last_id"] = items.first["id"] if items.any?
|
|
273
|
+
items
|
|
274
|
+
end
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Manual fire: `POST <mount>/grants/:id/poll` with optional `instance_key`. Returns `{items: [...], static_data: {...}}`. Supply a stable consumer key for independent cursor and filter state in `Connectors::PollState#data`. Omitting the key retains `grant.static_data["polling"]`.
|
|
278
|
+
|
|
279
|
+
### 3.14 `generic_auth!`, `supported_nodes :...`, `http_request_node ...`
|
|
280
|
+
|
|
281
|
+
n8n credential-visibility flags (`interfaces.ts:379-381`):
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
generic_auth! # eligible for the HTTP-Request node picker
|
|
285
|
+
supported_nodes :slack_send_message, :slack_get_channel # restrict to specific node types
|
|
286
|
+
http_request_node name: "Slack API",
|
|
287
|
+
docs_url: "https://api.slack.com/",
|
|
288
|
+
api_base_url: "https://slack.com/api/"
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Runtime check via `Connectors::PermissionCheck.permit!(grant, node_type:)`. Defaults: `supported_nodes` empty = "no restriction".
|
|
292
|
+
|
|
293
|
+
### 3.15 `skip_managed_creation!`
|
|
294
|
+
|
|
295
|
+
Disables managed-credential (external secrets) creation for this type. The editor hides the toggle; `POST /credentials` with `is_managed: true` against this type returns 401.
|
|
296
|
+
|
|
297
|
+
### 3.16 `post_token_exchange(_raw, normalized)`
|
|
298
|
+
|
|
299
|
+
Override in subclass to merge service-specific fields (Slack `team_id`, GitHub `installation_id`) into the credentials hash after OAuth token exchange:
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
def self.post_token_exchange(raw, normalized)
|
|
303
|
+
normalized.merge(
|
|
304
|
+
"team_id" => raw.dig("team", "id"),
|
|
305
|
+
"team_name" => raw.dig("team", "name")
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### 3.17 Instance methods to override
|
|
311
|
+
|
|
312
|
+
```ruby
|
|
313
|
+
def refresh!; end # called by AutoRefresh middleware on 401
|
|
314
|
+
def poll; end # called by polling scheduler (workflow-roadmap)
|
|
315
|
+
def handle_webhook(ctx); end # called by DeliverWebhookJob; ctx = WebhookContext
|
|
316
|
+
def validate_credentials!; end # auto: runs CredentialSchema.validate! against grant
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## 4. CredentialSchema Field Reference
|
|
322
|
+
|
|
323
|
+
`Connectors::CredentialSchema::Field` mirrors n8n's `INodeProperties` (`interfaces.ts:1773-1812`):
|
|
324
|
+
|
|
325
|
+
```ruby
|
|
326
|
+
field :api_key,
|
|
327
|
+
type: "string", # string|number|boolean|options|multiOptions|json|hidden|notice
|
|
328
|
+
display_name: "API Key", # editor label
|
|
329
|
+
default: "",
|
|
330
|
+
placeholder: "re_...",
|
|
331
|
+
description: "...",
|
|
332
|
+
hint: "...",
|
|
333
|
+
required: true,
|
|
334
|
+
no_data_expression: false,
|
|
335
|
+
secret: true, # convenience for type_options[:password] = true
|
|
336
|
+
type_options: { password: true, expirable: true, redactJsonLeaves: true, resolvable_field: true },
|
|
337
|
+
display_options: { show: { grant_type: %w[authorizationCode pkce] } },
|
|
338
|
+
options: [{ name: "Header", value: "header" }]
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
`type_options` flags propagate to the editor verbatim:
|
|
342
|
+
- `password: true` — masked input + redacted in serializer responses
|
|
343
|
+
- `expirable: true` — UI marks the field as "will rotate"
|
|
344
|
+
- `redactJsonLeaves: true` — JSON leaf values masked in error messages / logs
|
|
345
|
+
- `resolvable_field: true` — field accepts `{{ }}` expressions
|
|
346
|
+
|
|
347
|
+
Inheritance via `extends`:
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
credentials do
|
|
351
|
+
extends :oauth2 # inherit OAuth2Api base fields
|
|
352
|
+
field :authorization_url, type: "hidden", # lock provider endpoint
|
|
353
|
+
default: "https://slack.com/oauth/v2/authorize"
|
|
354
|
+
end
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Children override parents by redeclaring with the same field name. Most-specific wins. Same precedence rule n8n's credential-walker uses (`packages/cli/src/credential-types.ts:26-37`).
|
|
358
|
+
|
|
359
|
+
### Schema-level DSL
|
|
360
|
+
|
|
361
|
+
Inside `credentials do ... end`:
|
|
362
|
+
|
|
363
|
+
- `extends :name` — inherit fields + injection from a registered base type
|
|
364
|
+
- `authenticate type: :generic, properties: {...}` — schema-level injection (inherited by every connector that `extends` this schema)
|
|
365
|
+
- `generic_auth!` — mark eligible for HTTP-Request node
|
|
366
|
+
- `display_name "Bearer Auth"` — UI label for the credential type itself
|
|
367
|
+
- `documentation_url "httprequest"` — n8n docs slug
|
|
368
|
+
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
## 5. Configuration Reference
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
Connectors.configure do |c|
|
|
375
|
+
# ---- Identity (required) ------------------------------------------------
|
|
376
|
+
c.owner_class_name = "User"
|
|
377
|
+
c.current_owner_resolver = ->(ctrl) { ctrl.request.env["connectors.current_owner"] }
|
|
378
|
+
|
|
379
|
+
# ---- OAuth secrets (per connector) -------------------------------------
|
|
380
|
+
c.host_base_url = ENV["APP_BASE_URL"] # the *scheme + host*; the engine adds its mount path automatically
|
|
381
|
+
c.oauth_credentials = {
|
|
382
|
+
slack: { client_id: ENV["SLACK_CLIENT_ID"], client_secret: ENV["SLACK_CLIENT_SECRET"] },
|
|
383
|
+
twitter: { client_id: ENV["TW_CONSUMER_KEY"], client_secret: ENV["TW_CONSUMER_SECRET"] }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
# ---- Optional: webhook dispatch hook -----------------------------------
|
|
387
|
+
c.on_webhook = ->(event) { Inbox::IngestJob.perform_later(event.id) }
|
|
388
|
+
|
|
389
|
+
# ---- Optional: sharing principals (host middleware supplies trusted owner) ----------------------------
|
|
390
|
+
c.principal_resolver = ->(ctrl) {
|
|
391
|
+
owner = ctrl.request.env["connectors.current_owner"]
|
|
392
|
+
owner ? [[owner.class.name, owner.id]] : []
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
# ---- Optional: external secrets manager (Phase 10) ---------------------
|
|
396
|
+
c.secrets_resolver = ->(grant) { vault.read(grant.external_ref) }
|
|
397
|
+
c.secrets_managed_fields_for = ->(key) { { slack: %w[client_secret] }.fetch(key.to_sym, []) }
|
|
398
|
+
end
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
The Rack owner entry in this example must be populated by trusted host authentication middleware; engine controllers do not inherit host controller callbacks. Slack/Twitter configuration here illustrates custom OAuth connectors, not shipped profiles.
|
|
402
|
+
|
|
403
|
+
All callables are invoked with `Connectors.configuration.<name>.call(...)` — the engine never invokes host constants directly.
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
## 6. Built-in Base Credential Types
|
|
408
|
+
|
|
409
|
+
Registered on engine boot in `Connectors::CredentialTypeRegistry`. Connectors `extends` any of these.
|
|
410
|
+
|
|
411
|
+
| Key | n8n source | Engine status |
|
|
412
|
+
|---|---|---|
|
|
413
|
+
| `:oauth2` | `OAuth2Api.credentials.ts:1-238` | ✅ all 3 grant types + JWE form fields |
|
|
414
|
+
| `:oauth1_api` | `OAuth1Api.credentials.ts:1-72` | ✅ HMAC-SHA1/256/512 |
|
|
415
|
+
| `:http_basic_auth` | `HttpBasicAuth.credentials.ts` | ✅ Basic header via `auth:` shortcut |
|
|
416
|
+
| `:http_bearer_auth` | `HttpBearerAuth.credentials.ts` | ✅ Authorization: Bearer |
|
|
417
|
+
| `:http_header_auth` | `HttpHeaderAuth.credentials.ts` | ✅ user-named header (templated key) |
|
|
418
|
+
| `:http_query_auth` | `HttpQueryAuth.credentials.ts` | ✅ user-named query param |
|
|
419
|
+
| `:http_digest_auth` | `HttpDigestAuth.credentials.ts` | ⚠️ schema only (Faraday-Digest gem TBD) |
|
|
420
|
+
| `:http_custom_auth` | `HttpCustomAuth.credentials.ts` | ⚠️ schema only (runtime in Custom-API-Call node) |
|
|
421
|
+
|
|
422
|
+
---
|
|
423
|
+
|
|
424
|
+
## 7. Models
|
|
425
|
+
|
|
426
|
+
### `Connectors::Grant` (table `connectors_grants`)
|
|
427
|
+
|
|
428
|
+
One row = one authorized account. Columns:
|
|
429
|
+
|
|
430
|
+
| Column | Type | Notes |
|
|
431
|
+
|---|---|---|
|
|
432
|
+
| `owner_type`, `owner_id` | polymorphic | host's identity type (User, Team, ...) |
|
|
433
|
+
| `connector_key` | string | foreign key to the in-memory `Registry` |
|
|
434
|
+
| `display_name` | string | UI label |
|
|
435
|
+
| `credentials` | text | **encrypted at rest** via ActiveRecord::Encryption |
|
|
436
|
+
| `status` | enum | `active` / `expired` / `revoked` / `errored` |
|
|
437
|
+
| `expires_at`, `last_used_at` | datetime | |
|
|
438
|
+
| `external_account_id` | string | denormalized provider-side id for webhook routing |
|
|
439
|
+
| `static_data` | jsonb | per-group scratch (webhook + polling cursor state) |
|
|
440
|
+
| `external_ref`, `is_managed` | string + bool | external secrets manager (Phase 10) |
|
|
441
|
+
|
|
442
|
+
Methods:
|
|
443
|
+
|
|
444
|
+
```ruby
|
|
445
|
+
grant.connector # → Connector instance (Registry.fetch + .new(self))
|
|
446
|
+
grant.credentials_hash # decrypted, vault-resolved if is_managed?
|
|
447
|
+
grant.stored_credentials_hash # raw DB view (no vault lookup)
|
|
448
|
+
grant.update_credentials!(patch) # atomic merge + invalidate vault cache
|
|
449
|
+
grant.update_static_data!(group) { |sub| ... } # atomic per-group mutation
|
|
450
|
+
grant.static_data_hash # whole static_data jsonb
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
### `Connectors::CredentialShare` (table `connectors_credential_shares`)
|
|
454
|
+
|
|
455
|
+
```ruby
|
|
456
|
+
CredentialShare.new(grant:, principal_type:, principal_id:, role:)
|
|
457
|
+
# role: "viewer" | "editor" | "owner"
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Unique on `(grant_id, principal_type, principal_id)`. `grant.shares dependent: :destroy`. Scope `for_principals([[type, id], ...])` drives the visibility query.
|
|
461
|
+
|
|
462
|
+
### `Connectors::WebhookEvent` (table `connectors_webhook_events`)
|
|
463
|
+
|
|
464
|
+
One row per inbound webhook. Idempotency via unique `(connector_key, external_event_id)`. Columns:
|
|
465
|
+
|
|
466
|
+
| Column | Type | Notes |
|
|
467
|
+
|---|---|---|
|
|
468
|
+
| `grant_id` | uuid | foreign key |
|
|
469
|
+
| `connector_key` | string | denormalized for fast scoping |
|
|
470
|
+
| `external_event_id` | string | `payload["event_id"]\|"id"\|event.id` — drives idempotency |
|
|
471
|
+
| `payload` | jsonb | parsed body |
|
|
472
|
+
| `raw_body` | text | untouched body string |
|
|
473
|
+
| `headers` | jsonb | lowercased dashed keys (`stripe-signature`) |
|
|
474
|
+
| `query` | jsonb | query string params |
|
|
475
|
+
| `webhook_name` | string | `default` / `setup` / etc. |
|
|
476
|
+
| `signature` | text | pre-extracted provider signature header |
|
|
477
|
+
| `status` | enum | `received` / `processed` / `failed` / `ignored` |
|
|
478
|
+
| `error_message` | text | failure detail (1KB cap) |
|
|
479
|
+
| `received_at`, `processed_at` | datetime | |
|
|
480
|
+
|
|
481
|
+
---
|
|
482
|
+
|
|
483
|
+
## 8. Endpoints
|
|
484
|
+
|
|
485
|
+
The host chooses the mount path. Every URL below is relative to the mount — the engine introspects it via `Connectors::Engine.mount_path` so the same routes table works under any prefix. All responses are JSON unless noted.
|
|
486
|
+
|
|
487
|
+
### 8.1 Catalog
|
|
488
|
+
|
|
489
|
+
| Method | Path | Purpose |
|
|
490
|
+
|---|---|---|
|
|
491
|
+
| GET | `/types` | list every registered connector |
|
|
492
|
+
| GET | `/types/:name` | full credential type description — fields, authenticate, generic_auth, supported_nodes, http_request_node, __overwritten_properties, __skip_managed_creation, connector capabilities |
|
|
493
|
+
|
|
494
|
+
### 8.2 Credentials CRUD
|
|
495
|
+
|
|
496
|
+
| Method | Path | Notes |
|
|
497
|
+
|---|---|---|
|
|
498
|
+
| GET | `/credentials[?type=&include_data=]` | owner-owned + shared-with-me; `include_data=true` returns decrypted data only to the owner role; MCP returns public configuration |
|
|
499
|
+
| GET | `/credentials/for-workflow` | same visibility, for the workflow editor's picker |
|
|
500
|
+
| GET | `/credentials/new?type=X` | server-generated unique default name (`"Resend account 3"`) |
|
|
501
|
+
| GET | `/credentials/:id` | single, viewer-or-better access |
|
|
502
|
+
| POST | `/credentials` | body: `{type, name?, data?, external_ref?, is_managed?}` |
|
|
503
|
+
| PATCH | `/credentials/:id` | body: `{name?, data?}` — editor-or-owner; replacing MCP authentication requires owner |
|
|
504
|
+
| DELETE | `/credentials/:id` | owner only |
|
|
505
|
+
| POST | `/credentials/test` | unsaved-credential test — body: `{type, data}` |
|
|
506
|
+
| POST | `/credentials/:id/revoke` | provider-declared REST revocation or MCP local disconnect; flips status to `:revoked` |
|
|
507
|
+
| PUT | `/credentials/:id/share` | body: `{principal_type, principal_id, role}` — owner only, idempotent |
|
|
508
|
+
| DELETE | `/credentials/:id/share` | body: `{principal_type, principal_id}` |
|
|
509
|
+
| PUT | `/credentials/:id/transfer` | body: `{owner_id}` — moves ownership |
|
|
510
|
+
|
|
511
|
+
### 8.3 Grants (manual harness)
|
|
512
|
+
|
|
513
|
+
| Method | Path | Notes |
|
|
514
|
+
|---|---|---|
|
|
515
|
+
| GET | `/grants[?connector_key=]` | @deprecated alias for credentials index |
|
|
516
|
+
| POST | `/grants/:id/test` | declarative `test_request` against live provider |
|
|
517
|
+
| POST | `/grants/:id/webhook_subscribe` | body: `{webhook_name?, hook_url?}` — runs check_exists + create |
|
|
518
|
+
| DELETE | `/grants/:id/webhook_subscribe` | runs delete; strips static_data |
|
|
519
|
+
| POST | `/grants/:id/poll` | runs the polling block once; returns `{items, static_data}` |
|
|
520
|
+
|
|
521
|
+
### 8.4 OAuth
|
|
522
|
+
|
|
523
|
+
| Method | Path | Notes |
|
|
524
|
+
|---|---|---|
|
|
525
|
+
| GET | `/:key/authorize` | 302 to provider authorize URL (or runs clientCredentials in-place) |
|
|
526
|
+
| GET | `/:key/authorize.json` | same as above but returns `{authorize_url}` JSON for popup-based flows |
|
|
527
|
+
| GET | `/:key/callback?code=&state=` | OAuth2 callback (also handles `oauth_token=&oauth_verifier=&state=` for OAuth1) |
|
|
528
|
+
|
|
529
|
+
### 8.5 Webhooks (inbound)
|
|
530
|
+
|
|
531
|
+
| Method | Path | Notes |
|
|
532
|
+
|---|---|---|
|
|
533
|
+
| POST | `/:key/webhook` | app-level (grant resolved from payload) |
|
|
534
|
+
| POST | `/:key/webhook/:webhook_name` | app-level, named group |
|
|
535
|
+
| POST | `/:key/:grant_id/webhook` | per-grant |
|
|
536
|
+
| POST | `/:key/:grant_id/webhook/:webhook_name` | per-grant, named group |
|
|
537
|
+
|
|
538
|
+
All persist a `WebhookEvent` and enqueue `DeliverWebhookJob` → calls `Connector#handle_webhook(ctx)` with a `WebhookContext`.
|
|
539
|
+
|
|
540
|
+
---
|
|
541
|
+
|
|
542
|
+
## 9. `WebhookContext` (Phase 7)
|
|
543
|
+
|
|
544
|
+
Passed to `Connector#handle_webhook`. Mirrors n8n's `IWebhookFunctions` (`interfaces.ts:1327-1350`).
|
|
545
|
+
|
|
546
|
+
```ruby
|
|
547
|
+
def handle_webhook(ctx)
|
|
548
|
+
ctx.body # parsed JSON (Hash; also: ctx.payload_hash for back-compat)
|
|
549
|
+
ctx.raw_body # untouched request body string
|
|
550
|
+
ctx.headers # all headers, lowercased+dashed: "stripe-signature"
|
|
551
|
+
ctx.query # query string params
|
|
552
|
+
ctx.webhook_name # :default / :setup / ...
|
|
553
|
+
ctx.signature # pre-extracted provider signature header
|
|
554
|
+
ctx.grant # = ctx.event.grant
|
|
555
|
+
ctx.event # the persisted WebhookEvent row
|
|
556
|
+
end
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
---
|
|
560
|
+
|
|
561
|
+
## 10. Middleware Stack
|
|
562
|
+
|
|
563
|
+
`Connectors::ClientBuilder.build` assembles this Faraday stack for every connector's `client`:
|
|
564
|
+
|
|
565
|
+
Declared outermost to innermost:
|
|
566
|
+
|
|
567
|
+
```
|
|
568
|
+
1. JSON request encoder
|
|
569
|
+
2. RateLimit
|
|
570
|
+
3. AutoRefresh
|
|
571
|
+
4. ErrorNormalization
|
|
572
|
+
5. Retry (502/503/504, at most two retries, idempotent methods)
|
|
573
|
+
6. JSON response parser
|
|
574
|
+
7. GrantStatus (revocation check on every attempt)
|
|
575
|
+
8. PreAuthentication (when declared)
|
|
576
|
+
9. AuthenticateGeneric or Auth::Scheme
|
|
577
|
+
10. HTTP adapter
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
Error normalization runs after HTTP retry exhaustion, with the parsed response body. Automatic transient-response retries exclude POST. AutoRefresh coordinates through a grant row lock, rechecks the credentials that failed, and retries once with the original request body. PreAuthentication rechecks expiry under the same lock. RateLimit is a no-op without a configured quota.
|
|
581
|
+
|
|
582
|
+
---
|
|
583
|
+
|
|
584
|
+
## 11. OAuth Flows (detail)
|
|
585
|
+
|
|
586
|
+
Authorization without `grant_id` creates a new connection. Both authorize endpoints accept `grant_id` to reconnect an owned connection for that provider. Completion checks the connection snapshot again under a row lock and rejects changed credentials, ownership, status, or a different known account. Providers without an extracted account ID cannot enforce account matching. Revocation requires the owner role and shares the grant lock with refresh/reconnection.
|
|
587
|
+
|
|
588
|
+
State is authenticated, encrypted, and valid for 15 minutes. Existing signed state must be restarted on upgrade. State is stateless, so encryption does not enforce one-time use.
|
|
589
|
+
|
|
590
|
+
### authorizationCode (default)
|
|
591
|
+
|
|
592
|
+
```
|
|
593
|
+
1. GET /:key/authorize → 302 to provider with `code_challenge` if pkce
|
|
594
|
+
2. user grants consent
|
|
595
|
+
3. provider → GET /:key/callback?code=&state=
|
|
596
|
+
4. State validated → POST <token_url> with code (+ code_verifier if pkce)
|
|
597
|
+
5. post_token_exchange hook merges provider-specific fields
|
|
598
|
+
6. Grant created (or explicit grant_id reconnected); status=:active
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
### clientCredentials
|
|
602
|
+
|
|
603
|
+
```
|
|
604
|
+
1. GET /:key/authorize → POST <token_url> with grant_type=client_credentials
|
|
605
|
+
2. Grant created or explicitly reconnected in same request; no user redirect
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
`authentication: "header"` (default) sends client_id/secret as HTTP Basic; `"body"` sends them in the form.
|
|
609
|
+
|
|
610
|
+
### PKCE (S256)
|
|
611
|
+
|
|
612
|
+
Same as authorizationCode, but step 1 adds `code_challenge=<base64url(sha256(verifier))>` + `code_challenge_method=S256`. Verifier is stored in the encrypted state token (`state["x"]["cv"]`) and sent on the token exchange.
|
|
613
|
+
|
|
614
|
+
### OAuth1.0a
|
|
615
|
+
|
|
616
|
+
```
|
|
617
|
+
1. GET /:key/authorize → POST <request_token_url> (OAuth1-signed)
|
|
618
|
+
→ 302 to <authorize_url>?oauth_token=&state=
|
|
619
|
+
(request-token secret stashed in state["x"]["ts"])
|
|
620
|
+
2. user grants consent
|
|
621
|
+
3. provider → GET /:key/callback?oauth_token=&oauth_verifier=&state=
|
|
622
|
+
4. POST <access_token_url> (OAuth1-signed with consumer + request-token secrets)
|
|
623
|
+
5. Grant created or explicitly reconnected with {oauth_token, oauth_token_secret, signature_method}
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
### Revoke
|
|
627
|
+
|
|
628
|
+
```
|
|
629
|
+
POST /credentials/:id/revoke → revoke_token block OR RFC 7009 POST to revoke_token_url
|
|
630
|
+
→ grant.status = :revoked
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
---
|
|
634
|
+
|
|
635
|
+
## 12. Webhook Subscription Lifecycle (Phase 6)
|
|
636
|
+
|
|
637
|
+
```
|
|
638
|
+
POST /grants/:id/webhook_subscribe (optional: webhook_name, hook_url)
|
|
639
|
+
↓
|
|
640
|
+
WebhookLifecycle.subscribe
|
|
641
|
+
├─ check_exists.call(grant, hook_url, static_data) → true → status: "exists"
|
|
642
|
+
└─ create.call(grant, hook_url, static_data) → true → status: "created"
|
|
643
|
+
→ false → raise Error
|
|
644
|
+
|
|
645
|
+
DELETE /grants/:id/webhook_subscribe
|
|
646
|
+
↓
|
|
647
|
+
WebhookLifecycle.unsubscribe
|
|
648
|
+
└─ delete.call(grant, static_data) → true → status: "deleted"
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
Default `hook_url` is computed from `host_base_url` + `connector_key` + grant_id (per-grant) or omitted (app-level), based on `connector_class.webhook_style`.
|
|
652
|
+
|
|
653
|
+
---
|
|
654
|
+
|
|
655
|
+
## 13. Polling (Phase 8)
|
|
656
|
+
|
|
657
|
+
```
|
|
658
|
+
POST /grants/:id/poll
|
|
659
|
+
↓
|
|
660
|
+
PollRunner.run
|
|
661
|
+
└─ polling_block.call(grant, static_data["polling"])
|
|
662
|
+
→ items returned; static_data persisted in same UPDATE
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
The diagram shows the legacy call without an instance key. For independent consumers, call `PollRunner.run(grant, instance_key: trigger_id)` or send `instance_key` to the endpoint. The key must be a nonempty string; malformed keys return 400. Each `(grant_id, instance_key)` stores its cursor and optional filters in `Connectors::PollState#data`, protected by a unique database index and row lock. Failure rolls back cursor changes. Host scheduling and downstream delivery remain host responsibilities.
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
669
|
+
## 14. Sharing (Phase 9)
|
|
670
|
+
|
|
671
|
+
Role hierarchy: `viewer (0) < editor (1) < owner (2)`. HTTP credential access uses `GrantAccess#find_visible_grant!(id, min_role:)` and `GrantPolicy`; MCP services enforce the same roles through `MCP::Access`. Direct REST Ruby callers must authorize their selected grant.
|
|
672
|
+
|
|
673
|
+
```
|
|
674
|
+
visible_grants = (owner == requester) ∪ (shares.for_principals(resolver.call(ctrl)))
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
Index returns the union. Show/update/destroy escalate the minimum role required. Decrypted regular credentials are returned only to the owner role; shared viewers/editors receive metadata. MCP returns public configuration only. Transfer reassigns `owner_id`; existing shares stay intact (host can prune via unshare).
|
|
678
|
+
|
|
679
|
+
---
|
|
680
|
+
|
|
681
|
+
## 15. External Secrets (Phase 10)
|
|
682
|
+
|
|
683
|
+
```
|
|
684
|
+
grant.is_managed? + grant.external_ref
|
|
685
|
+
↓
|
|
686
|
+
credentials_hash = stored_credentials.merge(secrets_resolver.call(grant))
|
|
687
|
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
688
|
+
vault values win; cached per-instance
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
`AuthenticateGeneric` middleware reads `credentials_hash` per request, so a managed grant automatically gets vault-resolved values injected into outbound HTTP. No extra wiring.
|
|
692
|
+
|
|
693
|
+
`secrets_managed_fields_for(connector_key)` surfaces on `GET /types/:name` as `__overwritten_properties` so the editor can render the vault-sourced fields locked.
|
|
694
|
+
|
|
695
|
+
---
|
|
696
|
+
|
|
697
|
+
## 16. Errors
|
|
698
|
+
|
|
699
|
+
| Class | Status | Raised by |
|
|
700
|
+
|---|---|---|
|
|
701
|
+
| `Connectors::Error` | 401 (CredentialsController) | base |
|
|
702
|
+
| `Connectors::UnknownConnector` | 404 | `Registry.fetch` |
|
|
703
|
+
| `Connectors::UnknownAuthScheme` | 401 | `Auth::Scheme.lookup` |
|
|
704
|
+
| `Connectors::MissingCredentialFields` | 401 | `CredentialSchema#validate!` |
|
|
705
|
+
| `Connectors::ApiError` | 502 in ActionsController; upstream status in error details | ErrorNormalization middleware |
|
|
706
|
+
| `Connectors::AuthenticationFailed < ApiError` | 401 | OAuth flows + AutoRefresh |
|
|
707
|
+
| `Connectors::Forbidden < ApiError` | 403 | Provider permission denial |
|
|
708
|
+
| `Connectors::RateLimited < ApiError` | 429 | RateLimit middleware |
|
|
709
|
+
| `Connectors::CredentialNotPermitted` | — (caller chooses) | `PermissionCheck.permit!` |
|
|
710
|
+
| `Connectors::Webhooks::SignatureInvalid` | 401 (WebhooksController) | per-connector verifier |
|
|
711
|
+
|
|
712
|
+
---
|
|
713
|
+
|
|
714
|
+
## 17. Testing a Connector
|
|
715
|
+
|
|
716
|
+
```ruby
|
|
717
|
+
RSpec.describe MyConnector do
|
|
718
|
+
let(:owner) { Owner.create!(name: "test") }
|
|
719
|
+
let(:grant) do
|
|
720
|
+
Connectors::Grant.create!(owner: owner, connector_key: "my",
|
|
721
|
+
credentials: { "api_key" => "test" })
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
it "sends the API key" do
|
|
725
|
+
stub_request(:get, "https://api.my.test/me")
|
|
726
|
+
.with(headers: { "Authorization" => "Bearer test" })
|
|
727
|
+
.to_return(status: 200, body: '{"ok":true}', headers: { "Content-Type" => "application/json" })
|
|
728
|
+
|
|
729
|
+
expect(grant.connector.client.get("me").body).to eq("ok" => true)
|
|
730
|
+
end
|
|
731
|
+
end
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
Patterns used throughout `spec/connectors/`:
|
|
735
|
+
- **Anonymous classes** for per-spec test connectors → registry cleanup in `after`
|
|
736
|
+
- **`WebMock`** for outbound HTTP
|
|
737
|
+
- **`stub_request(...)` with `.with(headers: ...)`** verifies the auth-injecting middleware did its job
|
|
738
|
+
- **`Connectors::OAuth::State.decode(token)`** to peek inside the encrypted state for round-trip testing
|
|
739
|
+
|
|
740
|
+
---
|
|
741
|
+
|
|
742
|
+
## 18. Reference Index
|
|
743
|
+
|
|
744
|
+
Every n8n source citation used across the engine:
|
|
745
|
+
|
|
746
|
+
```
|
|
747
|
+
packages/workflow/src/interfaces.ts
|
|
748
|
+
:197-208 IRequestOptionsSimplifiedAuth (authenticate properties shape)
|
|
749
|
+
:269-288 IAuthenticateGeneric (declarative auth shape)
|
|
750
|
+
:340-348 ICredentialTestRequest (test_request)
|
|
751
|
+
:350-354 ICredentialHttpRequestNode (http_request_node)
|
|
752
|
+
:356-382 ICredentialType (envelope: name, displayName, etc.)
|
|
753
|
+
:374-377 preAuthentication (hook)
|
|
754
|
+
:379-381 genericAuth/supportedNodes/httpRequestNode
|
|
755
|
+
:382 __overwrittenProperties (managed fields)
|
|
756
|
+
:1257-1274 getWorkflowStaticData (static_data parity)
|
|
757
|
+
:1327-1350 IWebhookFunctions (WebhookContext)
|
|
758
|
+
:1561-1584 NodePropertyTypes (field type enum)
|
|
759
|
+
:1730-1771 IDisplayOptions + _cnd (show/hide)
|
|
760
|
+
:1773-1812 INodeProperties (field model)
|
|
761
|
+
:2017 webhookMethods (declaration site)
|
|
762
|
+
:2060 polling (poll() method)
|
|
763
|
+
:2091-2095 webhookMethods.default (checkExists/create/delete)
|
|
764
|
+
:2600 IWebhookDescription (named-group routing)
|
|
765
|
+
|
|
766
|
+
packages/cli/src/credential-types.ts:26-37 extends walker
|
|
767
|
+
|
|
768
|
+
packages/cli/src/credentials/credentials.controller.ts:67-411 CRUD endpoints
|
|
769
|
+
:100-111 GET /new (unique default name)
|
|
770
|
+
:141-152 POST /test
|
|
771
|
+
|
|
772
|
+
packages/cli/src/controllers/oauth/oauth1-credential.controller.ts:43-101 OAuth1 callback
|
|
773
|
+
packages/cli/src/controllers/oauth/oauth2-credential.controller.ts:26-37 authorize.json
|
|
774
|
+
|
|
775
|
+
packages/cli/src/oauth/oauth.service.ts:511-525, 578-586, 765-825 OAuth2 grant types + PKCE
|
|
776
|
+
:601-700 OAuth1 generate auth uri
|
|
777
|
+
:528, :690 redirect URI computation
|
|
778
|
+
|
|
779
|
+
packages/cli/src/services/frontend.service.ts:681-705 __overwrittenProperties population
|
|
780
|
+
:707-711 __skipManagedCreation
|
|
781
|
+
|
|
782
|
+
packages/cli/src/webhooks/webhook.service.ts:418 IWebhookFunctions consumption
|
|
783
|
+
|
|
784
|
+
packages/nodes-base/credentials/
|
|
785
|
+
OAuth2Api.credentials.ts:1-238 canonical OAuth2 (incl. JWE fields :208-237)
|
|
786
|
+
OAuth1Api.credentials.ts:1-72 canonical OAuth1
|
|
787
|
+
HttpBasicAuth.credentials.ts basic
|
|
788
|
+
HttpBearerAuth.credentials.ts bearer
|
|
789
|
+
HttpHeaderAuth.credentials.ts user-named header
|
|
790
|
+
HttpQueryAuth.credentials.ts user-named query
|
|
791
|
+
HttpDigestAuth.credentials.ts digest
|
|
792
|
+
HttpCustomAuth.credentials.ts custom JSON
|
|
793
|
+
CrowdStrikeOAuth2Api.credentials.ts:62-76 preAuthentication reference
|
|
794
|
+
SlackApi.credentials.ts:56-71 test_request reference
|
|
795
|
+
|
|
796
|
+
packages/nodes-base/nodes/
|
|
797
|
+
Postmark/PostmarkTrigger.node.ts:114-247 webhookMethods reference
|
|
798
|
+
Google/Gmail/GmailTrigger.node.ts:65, 281-553 polling reference
|
|
799
|
+
```
|