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