otto 2.0.2 → 2.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fabc1e8d6f7eef1d982b1f298fd0fe68382417aedd4b250ded28e5c7ba39a588
4
- data.tar.gz: 02f4509daad92151895100c75339d82e3f8b76106dbcaa04b2db34f7b4d39976
3
+ metadata.gz: 0616e034fc5859c42ed2eb48e1d123abdd1b36bca93422cf858d51331843c2c9
4
+ data.tar.gz: 667c3067cdd71ed41ef733c479798836522c32d37044edeb5f78d33c04b14818
5
5
  SHA512:
6
- metadata.gz: 2c5f1958418f70a429bfc2379f7407387ca4a1a6cdd129160ff6bf3e416f33a637c0d14049bd97ab0c7abce02ab5b11eb943aff330b5c1c0425d55c387ff3351
7
- data.tar.gz: '08e5ec5c3278adeced0dd3c1cffb7452129a12ad742c381b29a29bd569be4d013a67a660ad111bfe51631672e21d96cf2c0f39c69ab595758aa0e7e1d562ed32'
6
+ metadata.gz: c219d4864ffc3090983ee50828279914dc052d0d65f5fd75dbaff812a1fee5d32880c325956d778c9a68e39e396e0f855f271eb3355ddee9d846e186586c57a2
7
+ data.tar.gz: d32b8d2235fe0d2c95510c4ec5f1623f30a6039bac1a309878af03907458fd9a51ea18f6e5584dfe88754a45cef25469f52ba72bee7847e97b87f2e481672e9d
@@ -54,7 +54,7 @@ jobs:
54
54
  - name: Remove claude-review label
55
55
  # Remove label whether success or failure - prevents getting stuck
56
56
  if: always() && github.event.action != 'opened'
57
- uses: actions/github-script@v8
57
+ uses: actions/github-script@v9
58
58
  with:
59
59
  script: |
60
60
  try {
@@ -71,7 +71,7 @@ jobs:
71
71
  continue-on-error: true
72
72
 
73
73
  - name: Upload Reek report as artifact
74
- uses: actions/upload-artifact@v6
74
+ uses: actions/upload-artifact@v7
75
75
  if: always()
76
76
  with:
77
77
  name: reek-report
@@ -0,0 +1,162 @@
1
+ # Release Gem Workflow
2
+ #
3
+ # Automatically builds and publishes the otto gem to RubyGems.org when a
4
+ # GitHub release is published with a semantic version tag (vMAJOR.MINOR.PATCH,
5
+ # e.g. v0.6.1, v1.2.3).
6
+ #
7
+ # Follows the canonical RubyGems Trusted Publishing workflow:
8
+ # https://guides.rubygems.org/trusted-publishing/
9
+ #
10
+ # ============================================================================
11
+ # SETUP INSTRUCTIONS (one-time)
12
+ # ============================================================================
13
+ #
14
+ # This workflow uses RubyGems Trusted Publishing (OIDC). No long-lived API
15
+ # key needs to be stored in GitHub.
16
+ #
17
+ # ----------------------------------------------------------------------------
18
+ # 1. Configure the trusted publisher on RubyGems.org
19
+ # ----------------------------------------------------------------------------
20
+ #
21
+ # a. Sign in to https://rubygems.org and enable MFA on your account
22
+ # (required for trusted publishing).
23
+ #
24
+ # b. Open the gem's trusted publisher page (works for both existing gems
25
+ # and the first-ever publish):
26
+ # Existing gem: https://rubygems.org/gems/otto/trusted_publishers
27
+ # First publish: https://rubygems.org/profile/oidc/pending_trusted_publishers/new
28
+ #
29
+ # c. Click "Create" and fill in:
30
+ # Publisher type: GitHub Actions
31
+ # Repository owner: delano
32
+ # Repository name: otto
33
+ # Workflow filename: release-gem.yml
34
+ # Environment: rubygems.org (must match `environment.name` below)
35
+ #
36
+ # d. Save. RubyGems will now accept short-lived OIDC tokens issued by this
37
+ # workflow running in this repository.
38
+ #
39
+ # ----------------------------------------------------------------------------
40
+ # 2. Configure the GitHub environment
41
+ # ----------------------------------------------------------------------------
42
+ #
43
+ # a. In GitHub: Settings -> Environments -> New environment
44
+ # Name: `rubygems.org` (must match `environment.name` below)
45
+ #
46
+ # b. Under "Deployment branches and tags", restrict to tags matching:
47
+ # v*.*.*
48
+ # This prevents the environment (and its OIDC token) from being used
49
+ # from any branch or non-release tag.
50
+ #
51
+ # c. Optional but recommended: add required reviewers to gate publishes
52
+ # behind manual approval.
53
+ #
54
+ # No GitHub secrets are required - OIDC handles authentication.
55
+ #
56
+ # ----------------------------------------------------------------------------
57
+ # 3. Cutting a release
58
+ # ----------------------------------------------------------------------------
59
+ #
60
+ # a. Bump Otto::VERSION in lib/otto/version.rb (semver: MAJOR.MINOR.PATCH).
61
+ # b. Update CHANGELOG.md and commit on main.
62
+ # c. On GitHub, draft a Release with tag `vX.Y.Z` (matching the constant)
63
+ # and publish it. This workflow runs automatically: it verifies the tag
64
+ # matches the gemspec version, builds the gem, and pushes it.
65
+ #
66
+ # ----------------------------------------------------------------------------
67
+ # Fallback: API key (only if Trusted Publishing isn't an option)
68
+ # ----------------------------------------------------------------------------
69
+ #
70
+ # 1. Create an API key at https://rubygems.org/profile/api_keys with scope
71
+ # "Push rubygem" restricted to the `otto` gem.
72
+ # 2. Store it as a repo secret named `RUBYGEMS_API_KEY`.
73
+ # 3. Drop the `id-token: write` permission and `environment:` block, and
74
+ # replace the `rubygems/release-gem` step with:
75
+ #
76
+ # - name: Publish to RubyGems
77
+ # env:
78
+ # GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
79
+ # run: gem push otto-*.gem
80
+ #
81
+ # ----------------------------------------------------------------------------
82
+ # Action provenance (SHA pins)
83
+ # ----------------------------------------------------------------------------
84
+ #
85
+ # All third-party actions below are pinned to a full commit SHA, per GitHub's
86
+ # secure-use guidance for release-critical workflows. The accompanying
87
+ # comment records the tag the SHA was resolved from so renovate/dependabot
88
+ # can keep them current.
89
+ #
90
+ # actions/checkout - official GitHub action
91
+ # ruby/setup-ruby - official Ruby org action (GitHub-verified creator)
92
+ # rubygems/release-gem - official RubyGems action for trusted publishing
93
+ #
94
+ # ============================================================================
95
+
96
+ name: Release Gem
97
+
98
+ on:
99
+ release:
100
+ types: [published]
101
+
102
+ # Coarse default. Each job re-declares the narrowest permissions it needs.
103
+ permissions:
104
+ contents: read
105
+
106
+ # Don't allow two release runs to race - one published tag, one publish.
107
+ concurrency:
108
+ group: release-gem-${{ github.event.release.tag_name }}
109
+ cancel-in-progress: false
110
+
111
+ jobs:
112
+ release:
113
+ name: Build and push gem
114
+ runs-on: ubuntu-latest
115
+ timeout-minutes: 10
116
+
117
+ environment:
118
+ name: rubygems.org
119
+ url: https://rubygems.org/gems/otto
120
+
121
+ permissions:
122
+ contents: write # rubygems/release-gem attaches built .gem to the GitHub release
123
+ id-token: write # OIDC token for RubyGems Trusted Publishing
124
+
125
+ steps:
126
+ - name: Checkout
127
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
128
+ with:
129
+ persist-credentials: false
130
+
131
+ - name: Set up Ruby
132
+ uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
133
+ with:
134
+ bundler-cache: true
135
+ # Pinned to 3.3 (oldest non-experimental Ruby in the CI matrix).
136
+ # Latest stable (4.0) ships ipaddr 1.2.8 as a default gem, which
137
+ # bundler 2.7.x cannot override with the lockfile's 1.2.9 - the
138
+ # same reason Ruby 4.0 is `experimental: true` in ci.yml.
139
+ ruby-version: "3.3"
140
+
141
+ - name: Verify release tag matches Otto::VERSION
142
+ env:
143
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
144
+ run: |
145
+ set -euo pipefail
146
+ case "${RELEASE_TAG}" in
147
+ v[0-9]*.[0-9]*.[0-9]*) ;;
148
+ *)
149
+ echo "Release tag '${RELEASE_TAG}' is not a vMAJOR.MINOR.PATCH semver tag." >&2
150
+ exit 1
151
+ ;;
152
+ esac
153
+ tag_version="${RELEASE_TAG#v}"
154
+ gem_version="$(ruby -r ./lib/otto/version.rb -e 'print Otto::VERSION')"
155
+ if [ "${tag_version}" != "${gem_version}" ]; then
156
+ echo "Release tag ${RELEASE_TAG} (${tag_version}) != Otto::VERSION (${gem_version})." >&2
157
+ exit 1
158
+ fi
159
+ echo "Releasing otto ${gem_version} from tag ${RELEASE_TAG}"
160
+
161
+ - name: Build and push gem to RubyGems
162
+ uses: rubygems/release-gem@6317d8d1f7e28c24d28f6eff169ea854948bd9f7 # v1.2.0
@@ -1,6 +1,6 @@
1
- # CLAUDE.md
1
+ # AGENTS.md
2
2
 
3
- This file provides essential guidance to Claude Code when working with Otto.
3
+ This file provides essential guidance to AI agents when working with Otto.
4
4
 
5
5
  ## Error Handler Registration
6
6
 
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,43 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
7
7
 
8
8
  <!--scriv-insert-here-->
9
9
 
10
+ .. _changelog-2.2.0:
11
+
12
+ 2.2.0 — 2026-06-09
13
+ ==================
14
+
15
+ Added
16
+ -----
17
+
18
+ - Added ``AuthorizationFailure`` result type for auth strategies to signal 403 Forbidden distinct from 401 Unauthorized. Strategies that perform combined authentication and authorization in one pass can now return ``authorization_failure(reason)`` when a valid credential is denied a permission, allowing ``RouteAuthWrapper`` to map the result to a proper 403 response rather than collapsing it to 401.
19
+ - Added ``#authorization_failure`` helper to ``AuthStrategy`` base class for consistent error signaling across strategy implementations.
20
+ - Extracted ``#strategy_auth_method`` private helper to handle anonymous strategy classes (common in tests) that have a nil ``#name``.
21
+
22
+ .. _changelog-2.1.0:
23
+
24
+ 2.1.0 — 2026-05-27
25
+ ==================
26
+
27
+ - Add ``Otto#on_route_matched`` lifecycle hook. Callbacks fire after a
28
+ route matches but before the handler dispatches, with signature
29
+ ``(env, route_definition)``. Mirrors ``on_request_complete`` for
30
+ registration and freezing, but exceptions raised from a callback
31
+ propagate through ``handle_error`` rather than being swallowed, so
32
+ consumers can route custom error classes through
33
+ ``register_error_handler`` for short-circuit gating. Skipped for
34
+ static file routes and the 404 fallback; fires on both literal and
35
+ dynamic matches. Per-instance state, zero overhead when no callbacks
36
+ are registered. (#129)
37
+
38
+ - Add ``Otto#register_handler_wrapper`` API for per-request handler
39
+ composition. Registers factory blocks composed around each route
40
+ handler at request time; wrappers nest outermost-first in
41
+ registration order, with ``RouteAuthWrapper`` preserved as the
42
+ innermost wrapper so consumers see ``env['otto.strategy_result']``.
43
+ ``freeze_configuration!`` now exercises every registered wrapper
44
+ against every loaded route, surfacing ``TypeError`` and factory bugs
45
+ at boot rather than on the first matching request. (#130)
46
+
10
47
  .. _changelog-2.0.2:
11
48
 
12
49
  2.0.2 — 2026-04-15
data/Gemfile CHANGED
@@ -27,7 +27,7 @@ group :development do
27
27
  gem 'benchmark'
28
28
  gem 'debug'
29
29
  gem 'rackup' # Used to boot examples/ apps; not needed by specs
30
- gem 'rubocop', '~> 1.81.7', require: false
30
+ gem 'rubocop', '~> 1.86.2', require: false
31
31
  gem 'rubocop-performance', require: false
32
32
  gem 'rubocop-rspec', require: false
33
33
  gem 'rubocop-thread_safety', require: false
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- otto (2.0.2)
4
+ otto (2.2.0)
5
5
  concurrent-ruby (~> 1.3, < 2.0)
6
6
  ipaddr (~> 1, < 2.0)
7
7
  logger (~> 1, < 2.0)
@@ -18,8 +18,8 @@ GEM
18
18
  bigdecimal (4.1.1)
19
19
  concurrent-ruby (1.3.6)
20
20
  crass (1.0.6)
21
- date (3.4.1)
22
- debug (1.11.0)
21
+ date (3.5.1)
22
+ debug (1.11.1)
23
23
  irb (~> 1.10)
24
24
  reline (>= 0.3.8)
25
25
  diff-lcs (1.6.2)
@@ -52,15 +52,16 @@ GEM
52
52
  dry-inflector (~> 1.0)
53
53
  dry-logic (~> 1.4)
54
54
  zeitwerk (~> 2.6)
55
- erb (5.1.1)
55
+ erb (6.0.4)
56
56
  hana (1.3.7)
57
- io-console (0.8.1)
58
- ipaddr (1.2.8)
59
- irb (1.15.2)
57
+ io-console (0.8.2)
58
+ ipaddr (1.2.9)
59
+ irb (1.18.0)
60
60
  pp (>= 0.6.0)
61
+ prism (>= 1.3.0)
61
62
  rdoc (>= 4.0.0)
62
63
  reline (>= 0.4.2)
63
- json (2.19.3)
64
+ json (2.19.5)
64
65
  json_schemer (2.5.0)
65
66
  bigdecimal
66
67
  hana (~> 1.3)
@@ -100,7 +101,7 @@ GEM
100
101
  prettier_print (1.2.1)
101
102
  prettyprint (0.2.0)
102
103
  prism (1.9.0)
103
- psych (5.2.6)
104
+ psych (5.3.1)
104
105
  date
105
106
  stringio
106
107
  racc (1.8.1)
@@ -118,7 +119,7 @@ GEM
118
119
  logger
119
120
  prism (>= 1.6.0)
120
121
  tsort
121
- rdoc (6.15.0)
122
+ rdoc (7.2.0)
122
123
  erb
123
124
  psych (>= 4.0.0)
124
125
  tsort
@@ -129,7 +130,7 @@ GEM
129
130
  rainbow (>= 2.0, < 4.0)
130
131
  rexml (~> 3.1)
131
132
  regexp_parser (2.12.0)
132
- reline (0.6.2)
133
+ reline (0.6.3)
133
134
  io-console (~> 0.5)
134
135
  rexml (3.4.4)
135
136
  rspec (3.13.2)
@@ -145,15 +146,15 @@ GEM
145
146
  diff-lcs (>= 1.2.0, < 2.0)
146
147
  rspec-support (~> 3.13.0)
147
148
  rspec-support (3.13.7)
148
- rubocop (1.81.7)
149
+ rubocop (1.86.2)
149
150
  json (~> 2.3)
150
151
  language_server-protocol (~> 3.17.0.2)
151
152
  lint_roller (~> 1.1.0)
152
- parallel (~> 1.10)
153
+ parallel (>= 1.10)
153
154
  parser (>= 3.3.0.2)
154
155
  rainbow (>= 2.2.2, < 4.0)
155
156
  regexp_parser (>= 2.9.3, < 3.0)
156
- rubocop-ast (>= 1.47.1, < 2.0)
157
+ rubocop-ast (>= 1.49.0, < 2.0)
157
158
  ruby-progressbar (~> 1.7)
158
159
  unicode-display_width (>= 2.4.0, < 4.0)
159
160
  rubocop-ast (1.49.1)
@@ -176,8 +177,8 @@ GEM
176
177
  rbs (>= 3, < 5)
177
178
  ruby-progressbar (1.13.0)
178
179
  simpleidn (0.2.3)
179
- stackprof (0.2.27)
180
- stringio (3.1.7)
180
+ stackprof (0.2.28)
181
+ stringio (3.2.0)
181
182
  syntax_tree (6.3.0)
182
183
  prettier_print (>= 1.2.0)
183
184
  tryouts (3.7.1)
@@ -219,7 +220,7 @@ DEPENDENCIES
219
220
  rackup
220
221
  reek (~> 6.5)
221
222
  rspec (~> 3.13)
222
- rubocop (~> 1.81.7)
223
+ rubocop (~> 1.86.2)
223
224
  rubocop-performance
224
225
  rubocop-rspec
225
226
  rubocop-thread_safety
@@ -170,6 +170,11 @@ class Otto
170
170
  deep_freeze_value(@auth_config) if @auth_config
171
171
  deep_freeze_value(@option) if @option
172
172
 
173
+ # Validate registered handler-wrapper factories against every loaded
174
+ # route before locking the config. Surfaces TypeError / factory bugs
175
+ # at boot instead of on the first request that happens to match.
176
+ validate_handler_wrappers!
177
+
173
178
  # Deep freeze route structures (prevent modification of nested hashes/arrays)
174
179
  deep_freeze_value(@routes) if @routes
175
180
  deep_freeze_value(@routes_literal) if @routes_literal
@@ -207,6 +212,35 @@ class Otto
207
212
  # Only check the new middleware stack as the single source of truth
208
213
  @middleware&.includes?(middleware_class)
209
214
  end
215
+
216
+ # Walk every loaded route and exercise the registered handler-wrapper
217
+ # factories against a sentinel inner handler. Each factory must return a
218
+ # callable; HandlerFactory.apply_handler_wrappers raises TypeError
219
+ # otherwise. The constructed chain is discarded — this is a fail-fast
220
+ # validation pass, not memoization.
221
+ #
222
+ # Iterates @routes (covers MCP routes added directly) uniquified by
223
+ # identity. No-op if no wrappers are registered or no routes are loaded.
224
+ #
225
+ # @api private
226
+ # @return [void]
227
+ def validate_handler_wrappers!
228
+ return unless @routes && @route_handler_factory
229
+ return if @handler_wrappers.nil? || @handler_wrappers.empty?
230
+
231
+ sentinel = ->(_env, _extra = {}) { [200, {}, []] }
232
+ seen = {}.compare_by_identity
233
+ @routes.each_value do |routes_for_verb|
234
+ routes_for_verb.each do |route|
235
+ next if seen[route]
236
+
237
+ seen[route] = true
238
+ Otto::RouteHandlers::HandlerFactory.apply_handler_wrappers(
239
+ sentinel, route.route_definition, self
240
+ )
241
+ end
242
+ end
243
+ end
210
244
  end
211
245
  end
212
246
  end
@@ -58,6 +58,86 @@ class Otto
58
58
  def request_complete_callbacks
59
59
  @request_complete_callbacks
60
60
  end
61
+
62
+ # Register a callback fired after a route matches but before the handler dispatches.
63
+ #
64
+ # The callback receives two arguments:
65
+ # - env: the Rack environment hash
66
+ # - route_definition: the matched Otto::RouteDefinition
67
+ #
68
+ # Unlike on_request_complete, exceptions raised inside on_route_matched callbacks
69
+ # are NOT swallowed: they propagate to Otto#handle_error so consumers can route
70
+ # custom error classes through register_error_handler.
71
+ #
72
+ # Does not fire for static-file routes or for the 404 fallback route.
73
+ #
74
+ # @example
75
+ # otto.on_route_matched do |env, route_definition|
76
+ # raise MyApp::Maintenance if maintenance? && route_definition.auth_requirement
77
+ # end
78
+ #
79
+ # @yield [env, route_definition] Block to execute after route match
80
+ # @yieldparam env [Hash] The Rack environment
81
+ # @yieldparam route_definition [Otto::RouteDefinition] The matched route definition
82
+ # @return [self] Returns self for method chaining
83
+ # @raise [FrozenError] if called after configuration is frozen
84
+ def on_route_matched(&block)
85
+ ensure_not_frozen!
86
+ @route_matched_callbacks << block if block_given?
87
+ self
88
+ end
89
+
90
+ # Get registered route matched callbacks (for internal use)
91
+ #
92
+ # @api private
93
+ # @return [Array<Proc>] Array of registered callback blocks
94
+ def route_matched_callbacks
95
+ @route_matched_callbacks
96
+ end
97
+
98
+ # Register a wrapper factory that composes around each route handler.
99
+ #
100
+ # The block is invoked once per request, identical to RouteAuthWrapper
101
+ # instantiation, with the route's definition and the inner (already
102
+ # wrapped) handler. It must return either the original inner_handler (to
103
+ # opt out for that route) or a new object responding to :call(env).
104
+ # Wrappers compose outermost-first in registration order:
105
+ #
106
+ # request -> wrapper_1 -> wrapper_2 -> ... -> RouteAuthWrapper -> base_handler
107
+ #
108
+ # RouteAuthWrapper always stays innermost so env['otto.strategy_result'] is set
109
+ # before any consumer wrapper sees the request.
110
+ #
111
+ # Factory blocks are exercised once against every loaded route during
112
+ # freeze_configuration! (see validate_handler_wrappers!) so factories that
113
+ # raise unconditionally or return non-callables surface at boot rather
114
+ # than on the first request that happens to match.
115
+ #
116
+ # @example Gate a route by an option set in the routes file
117
+ # otto.register_handler_wrapper do |route_definition, inner_handler|
118
+ # next inner_handler unless route_definition.option(:scope) == 'canonical'
119
+ # MyApp::CanonicalOnlyWrapper.new(inner_handler, route_definition)
120
+ # end
121
+ #
122
+ # @yield [route_definition, inner_handler] Factory invoked per request
123
+ # @yieldparam route_definition [Otto::RouteDefinition] The route being wrapped
124
+ # @yieldparam inner_handler [#call] The handler to wrap (RouteAuthWrapper or base)
125
+ # @yieldreturn [#call] A callable wrapper, or inner_handler unchanged to skip
126
+ # @return [self] Returns self for method chaining
127
+ # @raise [FrozenError] if called after configuration is frozen
128
+ def register_handler_wrapper(&block)
129
+ ensure_not_frozen!
130
+ @handler_wrappers << block if block_given?
131
+ self
132
+ end
133
+
134
+ # Get registered handler wrapper factories (for internal use)
135
+ #
136
+ # @api private
137
+ # @return [Array<Proc>] Array of registered wrapper factory blocks
138
+ def handler_wrappers
139
+ @handler_wrappers
140
+ end
61
141
  end
62
142
  end
63
143
  end
@@ -110,6 +110,10 @@ class Otto
110
110
  handler: route.route_definition.definition,
111
111
  auth_strategy: route.route_definition.auth_requirement || 'none'
112
112
  ))
113
+ # Fire route matched hooks before dispatch; raises propagate to handle_error
114
+ unless @route_matched_callbacks.empty?
115
+ @route_matched_callbacks.each { |cb| cb.call(env, route.route_definition) }
116
+ end
113
117
  route.call(env)
114
118
  elsif static_route && http_verb == :GET && safe_file?(path_info)
115
119
  Otto.structured_log(:debug, 'Route matched',
@@ -180,6 +184,12 @@ class Otto
180
184
  Otto::LoggingHelpers.request_context(env).merge(
181
185
  fallback_to: '404_route'
182
186
  ))
187
+ else
188
+ # Fire route matched hooks before dispatch; suppressed for 404 fallback.
189
+ # Raises propagate to handle_error so custom error classes can be registered.
190
+ unless @route_matched_callbacks.empty?
191
+ @route_matched_callbacks.each { |cb| cb.call(env, found_route.route_definition) }
192
+ end
183
193
  end
184
194
  found_route.call env, extra_params
185
195
  else
@@ -37,6 +37,23 @@ class Otto
37
37
  )
38
38
  end
39
39
 
40
+ apply_handler_wrappers(handler, route_definition, otto_instance)
41
+ end
42
+
43
+ # Compose registered wrappers outermost-first. Built innermost-out so
44
+ # reverse_each yields a final order of: w1(w2(...(RouteAuthWrapper(base)))).
45
+ def self.apply_handler_wrappers(handler, route_definition, otto_instance)
46
+ wrappers = otto_instance&.handler_wrappers
47
+ return handler if wrappers.nil? || wrappers.empty?
48
+
49
+ wrappers.reverse_each do |factory|
50
+ wrapped = factory.call(route_definition, handler)
51
+ unless wrapped.respond_to?(:call)
52
+ raise TypeError,
53
+ "handler wrapper must return an object responding to :call, got #{wrapped.class}"
54
+ end
55
+ handler = wrapped
56
+ end
40
57
  handler
41
58
  end
42
59
  end
@@ -31,20 +31,47 @@ class Otto
31
31
  Otto::Security::Authentication::StrategyResult.new(
32
32
  session: session,
33
33
  user: user,
34
- auth_method: auth_method || self.class.name.split('::').last,
34
+ auth_method: auth_method || strategy_auth_method,
35
35
  metadata: metadata,
36
36
  strategy_name: nil # Will be set by RouteAuthWrapper
37
37
  )
38
38
  end
39
39
 
40
40
  # Helper for authentication failure - return AuthFailure
41
+ #
42
+ # Use for a missing, invalid, or expired credential. RouteAuthWrapper maps
43
+ # this to 401 Unauthorized. For a VALID credential that is not permitted,
44
+ # use #authorization_failure instead (403 Forbidden).
41
45
  def failure(reason = nil)
42
46
  Otto.logger.debug "[#{self.class}] Authentication failed: #{reason}" if reason
43
47
  Otto::Security::Authentication::AuthFailure.new(
44
48
  failure_reason: reason || 'Authentication failed',
45
- auth_method: self.class.name.split('::').last
49
+ auth_method: strategy_auth_method
50
+ )
51
+ end
52
+
53
+ # Helper for authorization failure - return AuthorizationFailure
54
+ #
55
+ # Use when the credential is valid but the authenticated subject is not
56
+ # permitted (wrong role, missing permission). RouteAuthWrapper maps this to
57
+ # 403 Forbidden, letting clients distinguish "authenticate again" (401) from
58
+ # "you lack this permission" (403). See AuthorizationFailure.
59
+ def authorization_failure(reason = nil)
60
+ Otto.logger.debug "[#{self.class}] Authorization denied: #{reason}" if reason
61
+ Otto::Security::Authentication::AuthorizationFailure.new(
62
+ failure_reason: reason || 'Authorization denied',
63
+ auth_method: strategy_auth_method
46
64
  )
47
65
  end
66
+
67
+ private
68
+
69
+ # Short auth_method label from the strategy class name. Anonymous strategy
70
+ # classes (Class.new(...), common in tests) have a nil #name, so fall back
71
+ # to a generic label rather than raising on nil#split.
72
+ def strategy_auth_method
73
+ (self.class.name || 'AuthStrategy').split('::').last
74
+ end
48
75
  end
49
76
  end
50
77
  end
@@ -0,0 +1,56 @@
1
+ # lib/otto/security/authentication/authorization_failure.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ module Security
7
+ module Authentication
8
+ # Result for AUTHORIZATION failures (authenticated, but not permitted).
9
+ #
10
+ # This is distinct from AuthFailure, which represents an AUTHENTICATION
11
+ # failure (no/invalid/expired credential). A strategy that performs both
12
+ # authentication and authorization in one pass (e.g. a token strategy that
13
+ # also enforces a role/permission encoded in the route requirement) returns:
14
+ #
15
+ # * AuthFailure -> credential missing/invalid -> 401 Unauthorized
16
+ # * AuthorizationFailure -> credential valid, but denied -> 403 Forbidden
17
+ #
18
+ # Without this type a combined strategy could only return AuthFailure, and
19
+ # RouteAuthWrapper would collapse an authorization denial to 401 — leaving a
20
+ # client unable to distinguish "authenticate again" from "you lack this
21
+ # permission." The wrapper maps this type to ResponseBuilder#forbidden (403);
22
+ # see RouteAuthWrapper#handle_all_strategies_failed.
23
+ #
24
+ # NOTE: Otto's built-in Layer-1 role check (RoleAuthorization, driven by the
25
+ # `role=` route token) already yields 403 for role mismatches on a successful
26
+ # StrategyResult. This type covers the complementary case: a strategy that
27
+ # owns authorization itself (including permission tiers, which Layer-1 does
28
+ # not model) and needs to signal a 403 directly.
29
+ AuthorizationFailure = Data.define(:failure_reason, :auth_method) do
30
+ # Authorization failures are not an authenticated request state. The
31
+ # request never reaches the handler, so handler-facing predicates report
32
+ # the same "no user context" shape AuthFailure does.
33
+ #
34
+ # @return [Boolean] False
35
+ def authenticated?
36
+ false
37
+ end
38
+
39
+ # @return [Boolean] True (no user context attached to a denial)
40
+ def anonymous?
41
+ true
42
+ end
43
+
44
+ # @return [Hash] Empty hash
45
+ def user_context
46
+ {}
47
+ end
48
+
49
+ # @return [String] Debug representation
50
+ def inspect
51
+ "#<AuthorizationFailure reason=#{failure_reason.inspect} method=#{auth_method}>"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -106,11 +106,18 @@ class Otto
106
106
  duration, total_start_time, failed_strategies)
107
107
  end
108
108
 
109
- # Handle authentication failure - continue to next strategy
110
- next unless result.is_a?(AuthFailure)
109
+ # Handle a failure (authentication OR authorization) - record it and
110
+ # continue to the next strategy (OR logic; a later success still wins).
111
+ # AuthorizationFailure (valid credential, denied) is tagged so the
112
+ # final response is 403 instead of 401. See handle_all_strategies_failed.
113
+ next unless result.is_a?(AuthFailure) || result.is_a?(AuthorizationFailure)
111
114
 
112
115
  log_strategy_failure(env, strategy_name, result, duration, auth_requirements, requirement)
113
- failed_strategies << { strategy: strategy_name, reason: result.failure_reason }
116
+ failed_strategies << {
117
+ strategy: strategy_name,
118
+ reason: result.failure_reason,
119
+ authorization: result.is_a?(AuthorizationFailure),
120
+ }
114
121
  end
115
122
 
116
123
  # All strategies failed
@@ -156,6 +163,14 @@ class Otto
156
163
  strategy_name: failure_strategy_name
157
164
  )
158
165
 
166
+ # Authorization denial wins over authentication failure: if any strategy
167
+ # authenticated the subject but denied authorization (wrong role/missing
168
+ # permission), respond 403 Forbidden rather than 401 — the subject IS
169
+ # authenticated, they simply lack access. A bare 401 would (incorrectly)
170
+ # tell a logged-in client to re-authenticate.
171
+ authz_denial = failed_strategies.find { |f| f[:authorization] }
172
+ return @response_builder.forbidden(env, authz_denial[:reason]) if authz_denial
173
+
159
174
  last_failure = if failed_strategies.any?
160
175
  AuthFailure.new(
161
176
  failure_reason: failed_strategies.last[:reason],
@@ -8,6 +8,7 @@
8
8
  require_relative 'authentication/auth_strategy'
9
9
  require_relative 'authentication/strategy_result'
10
10
  require_relative 'authentication/auth_failure'
11
+ require_relative 'authentication/authorization_failure'
11
12
  require_relative 'authentication/route_auth_wrapper'
12
13
 
13
14
  # Load all strategies
@@ -31,4 +32,5 @@ class Otto
31
32
  # Top-level backward compatibility aliases
32
33
  StrategyResult = Security::Authentication::StrategyResult
33
34
  AuthFailure = Security::Authentication::AuthFailure
35
+ AuthorizationFailure = Security::Authentication::AuthorizationFailure
34
36
  end
data/lib/otto/version.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.0.2'
6
+ VERSION = '2.2.0'
7
7
  end
data/lib/otto.rb CHANGED
@@ -170,7 +170,9 @@ class Otto
170
170
  @security = Otto::Security::Configurator.new(@security_config, @middleware, @auth_config)
171
171
  @app = nil # Pre-built middleware app (built after initialization)
172
172
  @request_complete_callbacks = [] # Instance-level request completion callbacks
173
- @error_handlers = {} # Registered error handlers for expected errors
173
+ @route_matched_callbacks = [] # Instance-level route matched callbacks
174
+ @handler_wrappers = [] # Instance-level handler wrapper factories
175
+ @error_handlers = {} # Registered error handlers for expected errors
174
176
 
175
177
  # Initialize helper module registries
176
178
  @request_helper_modules = []
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.2
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
@@ -142,14 +142,15 @@ files:
142
142
  - ".github/workflows/claude-code-review.yml"
143
143
  - ".github/workflows/claude.yml"
144
144
  - ".github/workflows/code-smells.yml"
145
+ - ".github/workflows/release-gem.yml"
145
146
  - ".gitignore"
146
147
  - ".pre-commit-config.yaml"
147
148
  - ".pre-push-config.yaml"
148
149
  - ".reek.yml"
149
150
  - ".rspec"
150
151
  - ".rubocop.yml"
152
+ - AGENTS.md
151
153
  - CHANGELOG.rst
152
- - CLAUDE.md
153
154
  - Gemfile
154
155
  - Gemfile.lock
155
156
  - LICENSE.txt
@@ -280,6 +281,7 @@ files:
280
281
  - lib/otto/security/authentication.rb
281
282
  - lib/otto/security/authentication/auth_failure.rb
282
283
  - lib/otto/security/authentication/auth_strategy.rb
284
+ - lib/otto/security/authentication/authorization_failure.rb
283
285
  - lib/otto/security/authentication/route_auth_wrapper.rb
284
286
  - lib/otto/security/authentication/route_auth_wrapper/response_builder.rb
285
287
  - lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb