aris 1.4.2 → 1.5.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +251 -0
  3. data/README.md +18 -0
  4. data/docs/ADAPTERS.md +478 -0
  5. data/docs/ARCHITECTURE.md +222 -0
  6. data/docs/CONTENT.md +967 -0
  7. data/docs/PERFORMANCE.md +492 -0
  8. data/docs/PLUGIN_DEVELOPMENT.md +688 -0
  9. data/docs/USAGE.md +4998 -0
  10. data/docs/plugins/API_KEY_AUTH.md +232 -0
  11. data/docs/plugins/BASIC_AUTH.md +582 -0
  12. data/docs/plugins/BEARER_AUTH.md +394 -0
  13. data/docs/plugins/CACHE.md +369 -0
  14. data/docs/plugins/COMPRESSION.md +216 -0
  15. data/docs/plugins/COOKIES.md +30 -0
  16. data/docs/plugins/CORS.md +283 -0
  17. data/docs/plugins/CSRF.md +751 -0
  18. data/docs/plugins/ETAG.md +308 -0
  19. data/docs/plugins/FORM_PARSER.md +193 -0
  20. data/docs/plugins/HEALTH_CHECK.md +469 -0
  21. data/docs/plugins/JSON.md +291 -0
  22. data/docs/plugins/MULTIPART.md +427 -0
  23. data/docs/plugins/RATE_LIMITER.md +368 -0
  24. data/docs/plugins/REQUEST_ID.md +369 -0
  25. data/docs/plugins/REQUEST_LOGGER.md +151 -0
  26. data/docs/plugins/SECURITY.md +193 -0
  27. data/docs/plugins/SESSION.md +98 -0
  28. data/lib/aris/adapters/rack/adapter.rb +17 -2
  29. data/lib/aris/adapters/rack/request.rb +29 -11
  30. data/lib/aris/plugins/basic_auth.rb +3 -1
  31. data/lib/aris/plugins/cookies.rb +4 -32
  32. data/lib/aris/plugins/cors.rb +8 -1
  33. data/lib/aris/plugins/csrf.rb +63 -22
  34. data/lib/aris/plugins/flash.rb +3 -1
  35. data/lib/aris/plugins/form_parser.rb +52 -31
  36. data/lib/aris/plugins/multipart.rb +22 -2
  37. data/lib/aris/plugins/request_logger.rb +8 -1
  38. data/lib/aris/plugins/security_headers.rb +8 -1
  39. data/lib/aris/plugins/session.rb +150 -99
  40. data/lib/aris/response_helpers.rb +41 -0
  41. data/lib/aris/version.rb +2 -2
  42. metadata +31 -3
@@ -0,0 +1,151 @@
1
+ # Request Logger Plugin
2
+
3
+ Log incoming HTTP requests in text or JSON format.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ require_relative 'aris/plugins/request_logger'
9
+ ```
10
+
11
+ ## Basic Usage
12
+
13
+ ```ruby
14
+ logger = Aris::Plugins::RequestLogger.build
15
+
16
+ Aris.routes({
17
+ "example.com": {
18
+ use: [logger],
19
+ "/users": { get: { to: UsersHandler } }
20
+ }
21
+ })
22
+ ```
23
+
24
+ **Output (text):**
25
+ ```
26
+ GET /users
27
+ POST /users/123
28
+ DELETE /users/456
29
+ ```
30
+
31
+ ---
32
+
33
+ ## Configuration
34
+
35
+ ```ruby
36
+ logger = Aris::Plugins::RequestLogger.build(
37
+ format: :json, # :text or :json
38
+ exclude: ['/health', '/metrics'], # Skip these paths
39
+ logger: Rails.logger # Custom logger (default: STDOUT)
40
+ )
41
+ ```
42
+
43
+ ---
44
+
45
+ ## JSON Format
46
+
47
+ ```ruby
48
+ logger = Aris::Plugins::RequestLogger.build(format: :json)
49
+ ```
50
+
51
+ **Output:**
52
+ ```json
53
+ {"method":"GET","path":"/users","host":"api.example.com","timestamp":"2025-01-10T12:34:56Z"}
54
+ {"method":"POST","path":"/users","host":"api.example.com","timestamp":"2025-01-10T12:35:02Z"}
55
+ ```
56
+
57
+ ---
58
+
59
+ ## Common Patterns
60
+
61
+ ### Exclude Health Checks
62
+
63
+ ```ruby
64
+ logger = Aris::Plugins::RequestLogger.build(
65
+ exclude: ['/health', '/ping', '/metrics']
66
+ )
67
+ ```
68
+
69
+ ### Custom Logger
70
+
71
+ ```ruby
72
+ # File logger
73
+ file_logger = Logger.new('log/requests.log')
74
+ logger = Aris::Plugins::RequestLogger.build(logger: file_logger)
75
+
76
+ # Rails logger
77
+ logger = Aris::Plugins::RequestLogger.build(logger: Rails.logger)
78
+ ```
79
+
80
+ ### Different Logs Per Domain
81
+
82
+ ```ruby
83
+ api_logger = Aris::Plugins::RequestLogger.build(
84
+ format: :json,
85
+ logger: Logger.new('log/api.log')
86
+ )
87
+
88
+ admin_logger = Aris::Plugins::RequestLogger.build(
89
+ format: :text,
90
+ logger: Logger.new('log/admin.log')
91
+ )
92
+
93
+ Aris.routes({
94
+ "api.example.com": {
95
+ use: [api_logger],
96
+ "/data": { get: { to: DataHandler } }
97
+ },
98
+ "admin.example.com": {
99
+ use: [admin_logger],
100
+ "/dashboard": { get: { to: DashboardHandler } }
101
+ }
102
+ })
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Production Tips
108
+
109
+ **1. JSON Format for Log Aggregation**
110
+
111
+ ```ruby
112
+ # Works great with ELK, Splunk, CloudWatch
113
+ logger = Aris::Plugins::RequestLogger.build(
114
+ format: :json,
115
+ logger: Logger.new(STDOUT) # Docker captures STDOUT
116
+ )
117
+ ```
118
+
119
+ **2. Exclude Noisy Endpoints**
120
+
121
+ ```ruby
122
+ logger = Aris::Plugins::RequestLogger.build(
123
+ exclude: [
124
+ '/health',
125
+ '/metrics',
126
+ '/favicon.ico',
127
+ '/robots.txt'
128
+ ]
129
+ )
130
+ ```
131
+
132
+ **3. Log Level**
133
+
134
+ ```ruby
135
+ custom_logger = Logger.new(STDOUT)
136
+ custom_logger.level = Logger::INFO # or WARN, ERROR
137
+
138
+ logger = Aris::Plugins::RequestLogger.build(logger: custom_logger)
139
+ ```
140
+
141
+ ---
142
+
143
+ ## Limitations
144
+
145
+ - Logs incoming requests only (no response status/duration)
146
+ - For response logging, use Rack middleware or application logs
147
+ - No request body logging (use separate middleware for that)
148
+
149
+ ---
150
+
151
+ Need help? Check out the [full plugin development guide](../docs/plugin-development.md).
@@ -0,0 +1,193 @@
1
+ # Security Headers Plugin
2
+
3
+ Add essential security headers to protect against common web vulnerabilities.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ require_relative 'aris/plugins/security_headers'
9
+ ```
10
+
11
+ ## Basic Usage
12
+
13
+ ### Secure Defaults
14
+
15
+ ```ruby
16
+ security = Aris::Plugins::SecurityHeaders.build
17
+
18
+ Aris.routes({
19
+ "example.com": {
20
+ use: [security],
21
+ "/": { get: { to: HomeHandler } }
22
+ }
23
+ })
24
+ ```
25
+
26
+ **Headers set:**
27
+ ```
28
+ X-Frame-Options: SAMEORIGIN
29
+ X-content-type-Options: nosniff
30
+ X-XSS-Protection: 0
31
+ Referrer-Policy: strict-origin-when-cross-origin
32
+ ```
33
+
34
+ ---
35
+
36
+ ## Configuration
37
+
38
+ ```ruby
39
+ security = Aris::Plugins::SecurityHeaders.build(
40
+ x_frame_options: 'DENY',
41
+ x_content_type_options: 'nosniff',
42
+ hsts: { max_age: 63072000, include_subdomains: true, preload: true },
43
+ csp: "default-src 'self'; script-src 'self' 'unsafe-inline'",
44
+ referrer_policy: 'no-referrer',
45
+ permissions_policy: 'geolocation=(), microphone=()'
46
+ )
47
+ ```
48
+
49
+ ### Options
50
+
51
+ | Option | Default | Description |
52
+ |:---|:---|:---|
53
+ | `x_frame_options` | `'SAMEORIGIN'` | Prevent clickjacking (`DENY`, `SAMEORIGIN`, `nil`) |
54
+ | `x_content_type_options` | `'nosniff'` | Prevent MIME sniffing |
55
+ | `x_xss_protection` | `'0'` | Disable legacy XSS filter (modern browsers ignore) |
56
+ | `hsts` | Not set | HTTP Strict Transport Security |
57
+ | `csp` | Not set | Content Security Policy |
58
+ | `referrer_policy` | `'strict-origin-when-cross-origin'` | Referrer behavior |
59
+ | `permissions_policy` | Not set | Control browser features |
60
+ | `defaults` | `true` | Enable default headers |
61
+
62
+ ---
63
+
64
+ ## Common Patterns
65
+
66
+ ### Production API
67
+
68
+ ```ruby
69
+ api_security = Aris::Plugins::SecurityHeaders.build(
70
+ x_frame_options: 'DENY',
71
+ hsts: { max_age: 31536000, include_subdomains: true },
72
+ csp: "default-src 'none'",
73
+ referrer_policy: 'no-referrer'
74
+ )
75
+ ```
76
+
77
+ ### Web Application
78
+
79
+ ```ruby
80
+ web_security = Aris::Plugins::SecurityHeaders.build(
81
+ x_frame_options: 'SAMEORIGIN',
82
+ hsts: true,
83
+ csp: "default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'",
84
+ referrer_policy: 'strict-origin-when-cross-origin'
85
+ )
86
+ ```
87
+
88
+ ### Different Security Per Route
89
+
90
+ ```ruby
91
+ strict = Aris::Plugins::SecurityHeaders.build(x_frame_options: 'DENY')
92
+ relaxed = Aris::Plugins::SecurityHeaders.build(x_frame_options: 'SAMEORIGIN')
93
+
94
+ Aris.routes({
95
+ "example.com": {
96
+ "/admin": {
97
+ use: [strict],
98
+ get: { to: AdminHandler }
99
+ },
100
+ "/public": {
101
+ use: [relaxed],
102
+ get: { to: PublicHandler }
103
+ }
104
+ }
105
+ })
106
+ ```
107
+
108
+ ---
109
+
110
+ ## Header Details
111
+
112
+ **X-Frame-Options**
113
+ - `DENY` - Cannot be framed at all
114
+ - `SAMEORIGIN` - Can be framed by same origin only
115
+
116
+ **HSTS (HTTP Strict Transport Security)**
117
+ ```ruby
118
+ hsts: true # Simple: max-age=31536000; includeSubDomains
119
+
120
+ hsts: {
121
+ max_age: 63072000, # 2 years
122
+ include_subdomains: true,
123
+ preload: true # Submit to browser preload list
124
+ }
125
+ ```
126
+
127
+ **Content-Security-Policy**
128
+ ```ruby
129
+ csp: "default-src 'self'; script-src 'self' 'unsafe-inline'; img-src * data:"
130
+ ```
131
+
132
+ **Permissions-Policy**
133
+ ```ruby
134
+ permissions_policy: 'camera=(), microphone=(), geolocation=(self)'
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Production Tips
140
+
141
+ **1. Start Strict, Relax as Needed**
142
+
143
+ ```ruby
144
+ # Start with strictest settings
145
+ security = Aris::Plugins::SecurityHeaders.build(
146
+ x_frame_options: 'DENY',
147
+ hsts: { max_age: 31536000, include_subdomains: true },
148
+ csp: "default-src 'self'"
149
+ )
150
+
151
+ # Relax only where necessary
152
+ ```
153
+
154
+ **2. Test CSP in Report-Only Mode First**
155
+
156
+ ```ruby
157
+ # Development/Staging
158
+ security = Aris::Plugins::SecurityHeaders.build(
159
+ csp: "default-src 'self'; report-uri /csp-report"
160
+ )
161
+
162
+ # Monitor violations before enforcing
163
+ ```
164
+
165
+ **3. HSTS Considerations**
166
+
167
+ - Start with short `max_age` (300 seconds) to test
168
+ - Increase gradually (3600 → 86400 → 31536000)
169
+ - Only enable `preload` when confident (irreversible!)
170
+
171
+ **4. Environment-Specific Configs**
172
+
173
+ ```ruby
174
+ security = Aris::Plugins::SecurityHeaders.build(
175
+ hsts: Rails.env.production? ? { max_age: 31536000 } : nil,
176
+ csp: ENV['CSP_POLICY']
177
+ )
178
+ ```
179
+
180
+ ---
181
+
182
+ ## Security Notes
183
+
184
+ - ✅ Essential first layer of defense
185
+ - ✅ Protect against clickjacking, XSS, MIME sniffing
186
+ - ✅ Combine with HTTPS (especially HSTS)
187
+ - ❌ Headers alone don't prevent all attacks
188
+ - ❌ CSP requires careful tuning for complex apps
189
+
190
+
191
+ ---
192
+
193
+ Need help? Check out the [full plugin development guide](../docs/plugin-development.md).
@@ -0,0 +1,98 @@
1
+ # Session Plugin
2
+
3
+ Cookie-based sessions that are safe to use for authentication.
4
+
5
+ The session is stored in a single cookie as an **AES-256-GCM encrypted and authenticated** payload, keyed from `Aris::Config.secret_key_base`. The browser cannot read what is inside, and a modified, forged, expired, or wrong-key cookie loads as an empty session instead of raising.
6
+
7
+ ## Setup
8
+
9
+ ```ruby
10
+ Aris.configure do |c|
11
+ c.secret_key_base = ENV.fetch('SECRET_KEY_BASE') # at least 32 bytes; generate with:
12
+ # ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'
13
+ end
14
+
15
+ Aris.routes({
16
+ "example.com": {
17
+ use: [:session],
18
+ "/login" => { post: { to: LoginHandler } },
19
+ "/logout" => { post: { to: LogoutHandler } },
20
+ "/me" => { get: { to: MeHandler } }
21
+ }
22
+ })
23
+ ```
24
+
25
+ Without a secret, the first write to a session raises `ArgumentError` with a message that says so.
26
+
27
+ ## Reading and writing
28
+
29
+ ```ruby
30
+ class LoginHandler
31
+ def self.call(request, response, params)
32
+ user = Users.authenticate(request.form_params['email'], request.form_params['password'])
33
+ return response.redirect('/login') unless user
34
+ request.session[:user_id] = user[:id]
35
+ response.redirect('/')
36
+ end
37
+ end
38
+
39
+ class MeHandler
40
+ def self.call(request, response, params)
41
+ user_id = request.session[:user_id] # nil when not logged in
42
+ request.session.key?(:user_id) # => true/false
43
+ request.session.fetch(:theme, 'light')
44
+ response.text("You are #{user_id}")
45
+ end
46
+ end
47
+
48
+ class LogoutHandler
49
+ def self.call(request, response, params)
50
+ request.session.destroy # clears the data and deletes the cookie
51
+ response.redirect('/login')
52
+ end
53
+ end
54
+ ```
55
+
56
+ Other methods: `delete(key)`, `clear`, `to_h`, `changed?`, `destroyed?`. Keys are symbols (strings are converted).
57
+
58
+ The cookie is only written when the session changed during the request. An empty session removes the cookie.
59
+
60
+ ## Options
61
+
62
+ Set defaults for every scope:
63
+
64
+ ```ruby
65
+ Aris::Plugins::Session.default_config = Aris::Plugins::Session.default_config.merge(
66
+ key: '_myapp_session',
67
+ expire_after: 7 * 24 * 3600 # seconds; default 14 days
68
+ )
69
+ ```
70
+
71
+ Or build a configured instance for one scope:
72
+
73
+ ```ruby
74
+ use: [Aris::Plugins::Session.build(key: '_admin', expire_after: 3600, same_site: :strict)]
75
+ ```
76
+
77
+ | Option | Default | Meaning |
78
+ |---|---|---|
79
+ | `key` | `'_aris_session'` | Cookie name |
80
+ | `expire_after` | 14 days | Lifetime in seconds — enforced server-side, not just via `Max-Age` |
81
+ | `secret` | `Aris::Config.secret_key_base` | Encryption key source (≥ 32 bytes) |
82
+ | `secure` | from `Config.cookie_options`, else `true` in production | Send the cookie only over HTTPS |
83
+ | `same_site` | `:lax` | `SameSite` attribute |
84
+ | `path` | `'/'` | Cookie path |
85
+
86
+ The cookie is always `HttpOnly`.
87
+
88
+ ## Rotating the secret
89
+
90
+ Changing `secret_key_base` invalidates every existing session (everyone is logged out). That is the intended way to revoke all sessions at once.
91
+
92
+ ## Pairing with CSRF
93
+
94
+ `use: [:session, :form_parser, :csrf]` — the CSRF token lives in the session. See [CSRF.md](CSRF.md).
95
+
96
+ ## Before 1.5
97
+
98
+ Sessions were Base64-encoded JSON (readable and forgeable) and the Rack adapter never read cookies back, so they did not persist at all. Any 1.4 session cookie is ignored by 1.5.
@@ -141,17 +141,32 @@ module Aris
141
141
  body = active_res.body
142
142
  body = [body] if body.is_a?(String)
143
143
 
144
- return [active_res.status, active_res.headers, body]
144
+ return [active_res.status, rack3_headers(active_res.headers), body]
145
145
  end
146
146
 
147
147
  # 3. Final Fallback (If Joys rendered nothing)
148
148
  case result
149
- when Array then result
149
+ when Array then [result[0], rack3_headers(result[1] || {}), result[2]]
150
150
  when Hash then [200, {'content-type' => 'application/json'}, [result.to_json]]
151
151
  else [200, {'content-type' => 'text/plain'}, [result.to_s]]
152
152
  end
153
153
  end
154
154
 
155
+ # Rack 3 requires lowercase header names, and a header with several
156
+ # values (set-cookie) must be an Array, never a comma-joined String.
157
+ def rack3_headers(headers)
158
+ out = {}
159
+ headers.each do |key, value|
160
+ name = key.to_s.downcase
161
+ if out.key?(name)
162
+ out[name] = Array(out[name]) + Array(value)
163
+ else
164
+ out[name] = value
165
+ end
166
+ end
167
+ out
168
+ end
169
+
155
170
 
156
171
  end
157
172
  end
@@ -1,30 +1,37 @@
1
1
  # lib/aris/adapters/rack/request.rb
2
+ require 'rack/utils'
3
+
2
4
  module Aris
3
5
  module Adapters
4
6
  module Rack
5
7
  class Request
6
8
  attr_reader :env
7
9
  attr_accessor :json_body
8
-
10
+
9
11
  def initialize(env)
10
12
  @env = env
11
13
  end
12
-
13
- # Add cookies method to match Mock adapter interface
14
+
15
+ # Cookies sent by the client, parsed from the Cookie header.
16
+ # (Before 1.5 this read env['rack.request.cookie_hash'], which nothing
17
+ # populated, so cookies were always empty under the Rack adapter.)
14
18
  def cookies
15
- @env['rack.request.cookie_hash'] || {}
19
+ @cookies ||= begin
20
+ header = @env['HTTP_COOKIE']
21
+ header && !header.empty? ? ::Rack::Utils.parse_cookies_header(header) : {}
22
+ end
16
23
  end
17
-
24
+
18
25
  def host
19
26
  @env['HTTP_HOST'] || @env['SERVER_NAME']
20
27
  end
21
-
28
+
22
29
  alias_method :domain, :host
23
30
 
24
31
  def request_method
25
32
  @env['REQUEST_METHOD']
26
33
  end
27
-
34
+
28
35
  def method
29
36
  @env['REQUEST_METHOD']
30
37
  end
@@ -32,7 +39,7 @@ module Aris
32
39
  def path_info
33
40
  @env['PATH_INFO']
34
41
  end
35
-
42
+
36
43
  alias_method :path, :path_info
37
44
 
38
45
  def query
@@ -43,14 +50,25 @@ module Aris
43
50
  @env.select { |k, v| k.start_with?('HTTP_') }
44
51
  end
45
52
 
53
+ # The raw request body. Read once and memoized, and the input is
54
+ # rewound afterwards, so plugins and handlers can all call it.
46
55
  def body
47
- @env['rack.input']&.read
56
+ return @body if defined?(@body)
57
+ input = @env['rack.input']
58
+ @body = if input
59
+ input.rewind if input.respond_to?(:rewind)
60
+ data = input.read
61
+ input.rewind if input.respond_to?(:rewind)
62
+ data
63
+ end
48
64
  end
49
65
 
66
+ # Query-string parameters. The FormParser plugin merges parsed form
67
+ # fields into this hash as well, so handlers can read both from here.
50
68
  def params
51
69
  @params ||= ::Rack::Utils.parse_nested_query(@env['QUERY_STRING'] || '')
52
70
  end
53
-
71
+
54
72
  def [](key)
55
73
  case key
56
74
  when :method then method
@@ -63,4 +81,4 @@ module Aris
63
81
  end
64
82
  end
65
83
  end
66
- end
84
+ end
@@ -65,4 +65,6 @@ module Aris
65
65
  end
66
66
  end
67
67
  end
68
- end
68
+ end
69
+ # BasicAuth always needs credentials, so it is used as an instance:
70
+ # use: [Aris::Plugins::BasicAuth.build(username: 'u', password: 'p')]
@@ -1,46 +1,18 @@
1
1
  # lib/aris/plugins/cookies.rb
2
2
  module Aris
3
3
  module Plugins
4
+ # Since 1.5, `set_cookie` and `delete_cookie` live on every response
5
+ # (Aris::ResponseHelpers), so this plugin no longer needs to inject them.
6
+ # It stays registered so `use: [:cookies]` keeps working in existing apps.
4
7
  class Cookies
5
8
  def self.call(request, response)
6
- # Only add cookie helpers when plugin is used
7
- add_cookie_helpers(request, response)
8
9
  nil # Continue pipeline
9
10
  end
10
11
 
11
12
  def self.build(**config)
12
13
  self
13
14
  end
14
-
15
- private
16
-
17
- def self.add_cookie_helpers(request, response)
18
- # Add cookie writing methods to response
19
- response.define_singleton_method(:set_cookie) do |name, value, options = {}|
20
- default_options = Aris::Config.cookie_options || {}
21
- merged_options = default_options.merge(options)
22
-
23
- cookie_parts = ["#{name}=#{value}"]
24
- cookie_parts << "Path=#{merged_options[:path]}" if merged_options[:path]
25
- cookie_parts << "HttpOnly" if merged_options[:httponly]
26
- cookie_parts << "Secure" if merged_options[:secure]
27
- cookie_parts << "Max-Age=#{merged_options[:max_age]}" if merged_options[:max_age]
28
- cookie_parts << "SameSite=#{merged_options[:same_site]}" if merged_options[:same_site]
29
-
30
- cookie_string = cookie_parts.join("; ")
31
-
32
- if headers['Set-Cookie']
33
- headers['Set-Cookie'] = [headers['Set-Cookie'], cookie_string].join(", ")
34
- else
35
- headers['Set-Cookie'] = cookie_string
36
- end
37
- end
38
-
39
- response.define_singleton_method(:delete_cookie) do |name, options = {}|
40
- set_cookie(name, "", options.merge(max_age: 0))
41
- end
42
- end
43
15
  end
44
16
  end
45
17
  end
46
- Aris.register_plugin(:cookies, plugin_class: Aris::Plugins::Cookies)
18
+ Aris.register_plugin(:cookies, plugin_class: Aris::Plugins::Cookies)
@@ -53,6 +53,11 @@ module Aris
53
53
  def self.build(**config)
54
54
  new(**config)
55
55
  end
56
+
57
+ # `use: [:cors]` (the bare symbol) runs an instance with default options.
58
+ def self.call(request, response)
59
+ (@default_instance ||= new).call(request, response)
60
+ end
56
61
 
57
62
  private
58
63
 
@@ -78,4 +83,6 @@ module Aris
78
83
  end
79
84
  end
80
85
  end
81
- end
86
+ end
87
+ # Self-register so `use: [:cors]` works without an explicit register_plugin call.
88
+ Aris.register_plugin(:cors, plugin_class: Aris::Plugins::Cors)