otto 2.0.0.pre9 → 2.0.0.pre10

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.
data/lib/otto.rb CHANGED
@@ -11,6 +11,8 @@ require 'rack/request'
11
11
  require 'rack/response'
12
12
  require 'rack/utils'
13
13
 
14
+ require_relative 'otto/request'
15
+ require_relative 'otto/response'
14
16
  require_relative 'otto/route_definition'
15
17
  require_relative 'otto/route'
16
18
  require_relative 'otto/static'
@@ -54,6 +56,12 @@ class Otto
54
56
  include Otto::Core::Configuration
55
57
  include Otto::Core::ErrorHandler
56
58
  include Otto::Core::UriGenerator
59
+ include Otto::Core::HelperRegistry
60
+ include Otto::Core::MiddlewareManagement
61
+ include Otto::Core::LifecycleHooks
62
+ include Otto::Security::Core
63
+ include Otto::Privacy::Core
64
+ include Otto::MCP::Core
57
65
 
58
66
  LIB_HOME = __dir__ unless defined?(Otto::LIB_HOME)
59
67
 
@@ -68,7 +76,7 @@ class Otto
68
76
  attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option,
69
77
  :static_route, :security_config, :locale_config, :auth_config,
70
78
  :route_handler_factory, :mcp_server, :security, :middleware,
71
- :error_handlers
79
+ :error_handlers, :request_class, :response_class
72
80
  attr_accessor :not_found, :server_error
73
81
 
74
82
  def initialize(path = nil, opts = {})
@@ -116,7 +124,7 @@ class Otto
116
124
 
117
125
  # Track request timing for lifecycle hooks
118
126
  start_time = Otto::Utils.now_in_μs
119
- request = Rack::Request.new(env)
127
+ request = @request_class.new(env)
120
128
  response_raw = nil
121
129
 
122
130
  begin
@@ -129,9 +137,9 @@ class Otto
129
137
  unless @request_complete_callbacks.empty?
130
138
  begin
131
139
  duration = Otto::Utils.now_in_μs - start_time
132
- # Wrap response tuple in Rack::Response for developer-friendly API
133
- # Otto's hook API should provide nice abstractions like Rack::Request/Response
134
- response = Rack::Response.new(response_raw[2], response_raw[0], response_raw[1])
140
+ # Wrap response tuple in Otto::Response for developer-friendly API
141
+ # Otto's hook API should provide nice abstractions like Otto::Request/Response
142
+ response = @response_class.new(response_raw[2], response_raw[0], response_raw[1])
135
143
  @request_complete_callbacks.each do |callback|
136
144
  callback.call(request, response, duration)
137
145
  end
@@ -145,402 +153,8 @@ class Otto
145
153
  response_raw
146
154
  end
147
155
 
148
- # Builds the middleware application chain
149
- # Called once at initialization and whenever middleware stack changes
150
- #
151
- # IMPORTANT: If you have routes with auth requirements, you MUST add session
152
- # middleware to your middleware stack BEFORE Otto processes requests.
153
- #
154
- # Session middleware is required for RouteAuthWrapper to correctly persist
155
- # session changes during authentication. Common options include:
156
- # - Rack::Session::Cookie (requires rack-session gem)
157
- # - Rack::Session::Pool
158
- # - Rack::Session::Memcache
159
- # - Any Rack-compatible session middleware
160
- #
161
- # Example:
162
- # use Rack::Session::Cookie, secret: ENV['SESSION_SECRET']
163
- # otto = Otto.new('routes.txt')
164
- #
165
- def build_app!
166
- base_app = method(:handle_request)
167
- @app = @middleware.wrap(base_app, @security_config)
168
- end
169
-
170
- # Middleware Management
171
- def use(middleware, ...)
172
- ensure_not_frozen!
173
- @middleware.add(middleware, ...)
174
-
175
- # NOTE: If build_app! is triggered during a request (via use() or
176
- # middleware_stack=), the @app instance variable could be swapped
177
- # mid-request in a multi-threaded environment.
178
-
179
- build_app! if @app # Rebuild app if already initialized
180
- end
181
-
182
- # Compatibility method for existing tests
183
- def middleware_stack
184
- @middleware.middleware_list
185
- end
186
-
187
- # Compatibility method for existing tests
188
- def middleware_stack=(stack)
189
- @middleware.clear!
190
- Array(stack).each { |middleware| @middleware.add(middleware) }
191
- build_app! if @app # Rebuild app if already initialized
192
- end
193
-
194
- # Compatibility method for middleware detection
195
- def middleware_enabled?(middleware_class)
196
- @middleware.includes?(middleware_class)
197
- end
198
-
199
- # Security Configuration Methods
200
-
201
- # Enable CSRF protection for POST, PUT, DELETE, and PATCH requests.
202
- # This will automatically add CSRF tokens to HTML forms and validate
203
- # them on unsafe HTTP methods.
204
- #
205
- # @example
206
- # otto.enable_csrf_protection!
207
- def enable_csrf_protection!
208
- ensure_not_frozen!
209
- return if @middleware.includes?(Otto::Security::Middleware::CSRFMiddleware)
210
-
211
- @security_config.enable_csrf_protection!
212
- use Otto::Security::Middleware::CSRFMiddleware
213
- end
214
-
215
- # Enable request validation including input sanitization, size limits,
216
- # and protection against XSS and SQL injection attacks.
217
- #
218
- # @example
219
- # otto.enable_request_validation!
220
- def enable_request_validation!
221
- ensure_not_frozen!
222
- return if @middleware.includes?(Otto::Security::Middleware::ValidationMiddleware)
223
-
224
- @security_config.input_validation = true
225
- use Otto::Security::Middleware::ValidationMiddleware
226
- end
227
-
228
- # Enable rate limiting to protect against abuse and DDoS attacks.
229
- # This will automatically add rate limiting rules based on client IP.
230
- #
231
- # @param options [Hash] Rate limiting configuration options
232
- # @option options [Integer] :requests_per_minute Maximum requests per minute per IP (default: 100)
233
- # @option options [Hash] :custom_rules Custom rate limiting rules
234
- # @example
235
- # otto.enable_rate_limiting!(requests_per_minute: 50)
236
- def enable_rate_limiting!(options = {})
237
- ensure_not_frozen!
238
- return if @middleware.includes?(Otto::Security::Middleware::RateLimitMiddleware)
239
-
240
- @security.configure_rate_limiting(options)
241
- use Otto::Security::Middleware::RateLimitMiddleware
242
- end
243
-
244
- # Add a custom rate limiting rule.
245
- #
246
- # @param name [String, Symbol] Rule name
247
- # @param options [Hash] Rule configuration
248
- # @option options [Integer] :limit Maximum requests
249
- # @option options [Integer] :period Time period in seconds (default: 60)
250
- # @option options [Proc] :condition Optional condition proc that receives request
251
- # @example
252
- # otto.add_rate_limit_rule('uploads', limit: 5, period: 300, condition: ->(req) { req.post? && req.path.include?('upload') })
253
- def add_rate_limit_rule(name, options)
254
- ensure_not_frozen!
255
- @security_config.rate_limiting_config[:custom_rules][name.to_s] = options
256
- end
257
-
258
- # Add a trusted proxy server for accurate client IP detection.
259
- # Only requests from trusted proxies will have their forwarded headers honored.
260
- #
261
- # @param proxy [String, Regexp] IP address, CIDR range, or regex pattern
262
- # @example
263
- # otto.add_trusted_proxy('10.0.0.0/8')
264
- # otto.add_trusted_proxy(/^172\.16\./)
265
- def add_trusted_proxy(proxy)
266
- ensure_not_frozen!
267
- @security_config.add_trusted_proxy(proxy)
268
- end
269
-
270
- # Set custom security headers that will be added to all responses.
271
- # These merge with the default security headers.
272
- #
273
- # @param headers [Hash] Hash of header name => value pairs
274
- # @example
275
- # otto.set_security_headers({
276
- # 'content-security-policy' => "default-src 'self'",
277
- # 'strict-transport-security' => 'max-age=31536000'
278
- # })
279
- def set_security_headers(headers)
280
- ensure_not_frozen!
281
- @security_config.security_headers.merge!(headers)
282
- end
283
-
284
- # Enable HTTP Strict Transport Security (HSTS) header.
285
- # WARNING: This can make your domain inaccessible if HTTPS is not properly
286
- # configured. Only enable this when you're certain HTTPS is working correctly.
287
- #
288
- # @param max_age [Integer] Maximum age in seconds (default: 1 year)
289
- # @param include_subdomains [Boolean] Apply to all subdomains (default: true)
290
- # @example
291
- # otto.enable_hsts!(max_age: 86400, include_subdomains: false)
292
- def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
293
- ensure_not_frozen!
294
- @security_config.enable_hsts!(max_age: max_age, include_subdomains: include_subdomains)
295
- end
296
-
297
- # Enable Content Security Policy (CSP) header to prevent XSS attacks.
298
- # The default policy only allows resources from the same origin.
299
- #
300
- # @param policy [String] CSP policy string (default: "default-src 'self'")
301
- # @example
302
- # otto.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")
303
- def enable_csp!(policy = "default-src 'self'")
304
- ensure_not_frozen!
305
- @security_config.enable_csp!(policy)
306
- end
307
-
308
- # Enable X-Frame-Options header to prevent clickjacking attacks.
309
- #
310
- # @param option [String] Frame options: 'DENY', 'SAMEORIGIN', or 'ALLOW-FROM uri'
311
- # @example
312
- # otto.enable_frame_protection!('DENY')
313
- def enable_frame_protection!(option = 'SAMEORIGIN')
314
- ensure_not_frozen!
315
- @security_config.enable_frame_protection!(option)
316
- end
317
-
318
- # Enable Content Security Policy (CSP) with nonce support for dynamic header generation.
319
- # This enables the res.send_csp_headers response helper method.
320
- #
321
- # @param debug [Boolean] Enable debug logging for CSP headers (default: false)
322
- # @example
323
- # otto.enable_csp_with_nonce!(debug: true)
324
- def enable_csp_with_nonce!(debug: false)
325
- ensure_not_frozen!
326
- @security_config.enable_csp_with_nonce!(debug: debug)
327
- end
328
-
329
- # Add a single authentication strategy
330
- #
331
- # @param name [String] Strategy name
332
- # @param strategy [Otto::Security::Authentication::AuthStrategy] Strategy instance
333
- # @example
334
- # otto.add_auth_strategy('custom', MyCustomStrategy.new)
335
- # Add an authentication strategy with a registered name
336
- #
337
- # This is the primary public API for registering authentication strategies.
338
- # The name you provide here will be available as `strategy_result.strategy_name`
339
- # in your application code, making it easy to identify which strategy authenticated
340
- # the current request.
341
- #
342
- # Also available via Otto::Security::Configurator for consolidated security config.
343
- #
344
- # @param name [String, Symbol] Strategy name (e.g., 'session', 'api_key', 'jwt')
345
- # @param strategy [AuthStrategy] Strategy instance
346
- # @example
347
- # otto.add_auth_strategy('session', SessionStrategy.new(session_key: 'user_id'))
348
- # otto.add_auth_strategy('api_key', APIKeyStrategy.new)
349
- # @raise [ArgumentError] if strategy name already registered
350
- def add_auth_strategy(name, strategy)
351
- ensure_not_frozen!
352
- # Ensure auth_config is initialized (handles edge case where it might be nil)
353
- @auth_config = { auth_strategies: {}, default_auth_strategy: 'noauth' } if @auth_config.nil?
354
-
355
- # Strict mode: Detect strategy name collisions
356
- if @auth_config[:auth_strategies].key?(name)
357
- raise ArgumentError, "Authentication strategy '#{name}' is already registered"
358
- end
359
-
360
- @auth_config[:auth_strategies][name] = strategy
361
- end
362
-
363
- # Register an error handler for expected business logic errors
364
- #
365
- # This allows you to handle known error conditions (like missing resources,
366
- # expired data, rate limits) without logging them as unhandled 500 errors.
367
- #
368
- # @param error_class [Class, String] The exception class or class name to handle
369
- # @param status [Integer] HTTP status code to return (default: 500)
370
- # @param log_level [Symbol] Log level for expected errors (:info, :warn, :error)
371
- # @param handler [Proc] Optional block to customize error response
372
- #
373
- # @example Basic usage with status code
374
- # otto.register_error_handler(Onetime::MissingSecret, status: 404, log_level: :info)
375
- # otto.register_error_handler(Onetime::SecretExpired, status: 410, log_level: :info)
376
- #
377
- # @example With custom response handler
378
- # otto.register_error_handler(Onetime::RateLimited, status: 429, log_level: :warn) do |error, req|
379
- # {
380
- # error: 'Rate limit exceeded',
381
- # retry_after: error.retry_after,
382
- # message: error.message
383
- # }
384
- # end
385
- #
386
- # @example Using string class names (for lazy loading)
387
- # otto.register_error_handler('Onetime::MissingSecret', status: 404, log_level: :info)
388
- #
389
- def register_error_handler(error_class, status: 500, log_level: :info, &handler)
390
- ensure_not_frozen!
391
-
392
- # Normalize error class to string for consistent lookup
393
- error_class_name = error_class.is_a?(String) ? error_class : error_class.name
394
-
395
- @error_handlers[error_class_name] = {
396
- status: status,
397
- log_level: log_level,
398
- handler: handler
399
- }
400
- end
401
-
402
- # Disable IP privacy to access original IP addresses
403
- #
404
- # IMPORTANT: By default, Otto masks public IP addresses for privacy.
405
- # Private/localhost IPs (127.0.0.0/8, 10.0.0.0/8, etc.) are never masked.
406
- # Only disable this if you need access to original public IPs.
407
- #
408
- # When disabled:
409
- # - env['REMOTE_ADDR'] contains the real IP address
410
- # - env['otto.original_ip'] also contains the real IP
411
- # - No PrivateFingerprint is created
412
- #
413
- # @example
414
- # otto.disable_ip_privacy!
415
- def disable_ip_privacy!
416
- ensure_not_frozen!
417
- @security_config.ip_privacy_config.disable!
418
- end
419
-
420
- # Enable full IP privacy (mask ALL IPs including private/localhost)
421
- #
422
- # By default, Otto exempts private and localhost IPs from masking for
423
- # better development experience. Call this method to mask ALL IPs
424
- # regardless of type.
425
- #
426
- # @example Enable full privacy (mask all IPs)
427
- # otto = Otto.new(routes_file)
428
- # otto.enable_full_ip_privacy!
429
- # # Now 127.0.0.1 → 127.0.0.0, 192.168.1.100 → 192.168.1.0
430
- #
431
- # @return [void]
432
- # @raise [FrozenError] if called after configuration is frozen
433
- def enable_full_ip_privacy!
434
- ensure_not_frozen!
435
- @security_config.ip_privacy_config.mask_private_ips = true
436
- end
437
-
438
- # Register a callback to be executed after each request completes
439
- #
440
- # Instance-level request completion callbacks allow each Otto instance
441
- # to have its own isolated set of callbacks, preventing duplicate
442
- # invocations in multi-app architectures (e.g., Rack::URLMap).
443
- #
444
- # The callback receives three arguments:
445
- # - request: Rack::Request object
446
- # - response: Rack::Response object (wrapping the response tuple)
447
- # - duration: Request processing duration in microseconds
448
- #
449
- # @example Basic usage
450
- # otto = Otto.new(routes_file)
451
- # otto.on_request_complete do |req, res, duration|
452
- # logger.info "Request completed", path: req.path, duration: duration
453
- # end
454
- #
455
- # @example Multi-app architecture
456
- # # App 1: Core Web Application
457
- # core_router = Otto.new
458
- # core_router.on_request_complete do |req, res, duration|
459
- # logger.info "Core app request", path: req.path
460
- # end
461
- #
462
- # # App 2: API Application
463
- # api_router = Otto.new
464
- # api_router.on_request_complete do |req, res, duration|
465
- # logger.info "API request", path: req.path
466
- # end
467
- #
468
- # # Each callback only fires for its respective Otto instance
469
- #
470
- # @yield [request, response, duration] Block to execute after each request
471
- # @yieldparam request [Rack::Request] The request object
472
- # @yieldparam response [Rack::Response] The response object
473
- # @yieldparam duration [Integer] Duration in microseconds
474
- # @return [self] Returns self for method chaining
475
- # @raise [FrozenError] if called after configuration is frozen
476
- def on_request_complete(&block)
477
- ensure_not_frozen!
478
- @request_complete_callbacks << block if block_given?
479
- self
480
- end
481
-
482
- # Get registered request completion callbacks (for internal use)
483
- #
484
- # @api private
485
- # @return [Array<Proc>] Array of registered callback blocks
486
- attr_reader :request_complete_callbacks
487
-
488
- # Configure IP privacy settings
489
- #
490
- # Privacy is enabled by default. Use this method to customize privacy
491
- # behavior without disabling it entirely.
492
- #
493
- # @param octet_precision [Integer] Number of octets to mask (1 or 2, default: 1)
494
- # @param hash_rotation [Integer] Seconds between key rotation (default: 86400)
495
- # @param geo [Boolean] Enable geo-location resolution (default: true)
496
- # @param redis [Redis] Redis connection for multi-server atomic key generation
497
- #
498
- # @example Mask 2 octets instead of 1
499
- # otto.configure_ip_privacy(octet_precision: 2)
500
- #
501
- # @example Disable geo-location
502
- # otto.configure_ip_privacy(geo: false)
503
- #
504
- # @example Custom hash rotation
505
- # otto.configure_ip_privacy(hash_rotation: 24.hours)
506
- #
507
- # @example Multi-server with Redis
508
- # redis = Redis.new(url: ENV['REDIS_URL'])
509
- # otto.configure_ip_privacy(redis: redis)
510
- def configure_ip_privacy(octet_precision: nil, hash_rotation: nil, geo: nil, redis: nil)
511
- ensure_not_frozen!
512
- config = @security_config.ip_privacy_config
513
-
514
- config.octet_precision = octet_precision if octet_precision
515
- config.hash_rotation_period = hash_rotation if hash_rotation
516
- config.geo_enabled = geo unless geo.nil?
517
- config.instance_variable_set(:@redis, redis) if redis
518
-
519
- # Validate configuration
520
- config.validate!
521
- end
522
-
523
- # Enable MCP (Model Context Protocol) server support
524
- #
525
- # @param options [Hash] MCP configuration options
526
- # @option options [Boolean] :http Enable HTTP endpoint (default: true)
527
- # @option options [Boolean] :stdio Enable STDIO communication (default: false)
528
- # @option options [String] :endpoint HTTP endpoint path (default: '/_mcp')
529
- # @example
530
- # otto.enable_mcp!(http: true, endpoint: '/api/mcp')
531
- def enable_mcp!(options = {})
532
- ensure_not_frozen!
533
- @mcp_server ||= Otto::MCP::Server.new(self)
534
-
535
- @mcp_server.enable!(options)
536
- Otto.logger.info '[MCP] Enabled MCP server' if Otto.debug
537
- end
538
-
539
- # Check if MCP is enabled
540
- # @return [Boolean]
541
- def mcp_enabled?
542
- @mcp_server&.enabled?
543
- end
156
+ # Security, Privacy, MCP, Middleware, HelperRegistry, and LifecycleHooks
157
+ # methods are provided by their respective Core modules (see includes above)
544
158
 
545
159
  private
546
160
 
@@ -558,6 +172,15 @@ class Otto
558
172
  @request_complete_callbacks = [] # Instance-level request completion callbacks
559
173
  @error_handlers = {} # Registered error handlers for expected errors
560
174
 
175
+ # Initialize helper module registries
176
+ @request_helper_modules = []
177
+ @response_helper_modules = []
178
+
179
+ # Finalize request/response classes with built-in helpers
180
+ # Custom helpers can be registered via register_request_helpers/register_response_helpers
181
+ # before first request (before configuration freezing)
182
+ finalize_request_response_classes
183
+
561
184
  # Add IP Privacy middleware first in stack (privacy by default for public IPs)
562
185
  # Private/localhost IPs are automatically exempted from masking
563
186
  @middleware.add_with_position(
@@ -588,48 +211,6 @@ class Otto
588
211
  configure_mcp(opts)
589
212
  end
590
213
 
591
- # Register all Otto framework error classes with appropriate status codes
592
- #
593
- # This method auto-registers base HTTP error classes and all framework-specific
594
- # error classes (Security, MCP) so that raising them automatically returns the
595
- # correct HTTP status code instead of 500.
596
- #
597
- # Users can override these registrations by calling register_error_handler
598
- # after Otto.new with custom status codes or log levels.
599
- #
600
- # @return [void]
601
- # @api private
602
- def register_framework_errors
603
- # Base HTTP errors (for direct use or subclassing by implementing projects)
604
- register_error_from_class(Otto::NotFoundError)
605
- register_error_from_class(Otto::BadRequestError)
606
- register_error_from_class(Otto::UnauthorizedError)
607
- register_error_from_class(Otto::ForbiddenError)
608
- register_error_from_class(Otto::PayloadTooLargeError)
609
-
610
- # Security module errors
611
- register_error_from_class(Otto::Security::AuthorizationError)
612
- register_error_from_class(Otto::Security::CSRFError)
613
- register_error_from_class(Otto::Security::RequestTooLargeError)
614
- register_error_from_class(Otto::Security::ValidationError)
615
-
616
- # MCP module errors
617
- register_error_from_class(Otto::MCP::ValidationError)
618
- end
619
-
620
- # Register an error handler using the error class as the single source of truth
621
- #
622
- # @param error_class [Class] Error class that responds to default_status and default_log_level
623
- # @return [void]
624
- # @api private
625
- def register_error_from_class(error_class)
626
- register_error_handler(
627
- error_class,
628
- status: error_class.default_status,
629
- log_level: error_class.default_log_level
630
- )
631
- end
632
-
633
214
  class << self
634
215
  attr_accessor :debug, :logger # rubocop:disable ThreadSafety/ClassAndModuleAttributes
635
216
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: otto
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0.pre9
4
+ version: 2.0.0.pre10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
@@ -240,6 +240,9 @@ files:
240
240
  - lib/otto/core/error_handler.rb
241
241
  - lib/otto/core/file_safety.rb
242
242
  - lib/otto/core/freezable.rb
243
+ - lib/otto/core/helper_registry.rb
244
+ - lib/otto/core/lifecycle_hooks.rb
245
+ - lib/otto/core/middleware_management.rb
243
246
  - lib/otto/core/middleware_stack.rb
244
247
  - lib/otto/core/router.rb
245
248
  - lib/otto/core/uri_generator.rb
@@ -248,8 +251,6 @@ files:
248
251
  - lib/otto/errors.rb
249
252
  - lib/otto/helpers.rb
250
253
  - lib/otto/helpers/base.rb
251
- - lib/otto/helpers/request.rb
252
- - lib/otto/helpers/response.rb
253
254
  - lib/otto/helpers/validation.rb
254
255
  - lib/otto/locale.rb
255
256
  - lib/otto/locale/config.rb
@@ -257,6 +258,7 @@ files:
257
258
  - lib/otto/logging_helpers.rb
258
259
  - lib/otto/mcp.rb
259
260
  - lib/otto/mcp/auth/token.rb
261
+ - lib/otto/mcp/core.rb
260
262
  - lib/otto/mcp/protocol.rb
261
263
  - lib/otto/mcp/rate_limiting.rb
262
264
  - lib/otto/mcp/registry.rb
@@ -265,9 +267,12 @@ files:
265
267
  - lib/otto/mcp/server.rb
266
268
  - lib/otto/privacy.rb
267
269
  - lib/otto/privacy/config.rb
270
+ - lib/otto/privacy/core.rb
268
271
  - lib/otto/privacy/geo_resolver.rb
269
272
  - lib/otto/privacy/ip_privacy.rb
270
273
  - lib/otto/privacy/redacted_fingerprint.rb
274
+ - lib/otto/request.rb
275
+ - lib/otto/response.rb
271
276
  - lib/otto/response_handlers.rb
272
277
  - lib/otto/response_handlers/auto.rb
273
278
  - lib/otto/response_handlers/base.rb
@@ -302,6 +307,7 @@ files:
302
307
  - lib/otto/security/authorization_error.rb
303
308
  - lib/otto/security/config.rb
304
309
  - lib/otto/security/configurator.rb
310
+ - lib/otto/security/core.rb
305
311
  - lib/otto/security/csrf.rb
306
312
  - lib/otto/security/middleware/csrf_middleware.rb
307
313
  - lib/otto/security/middleware/ip_privacy_middleware.rb