end_point_blank 0.2.2 → 0.6.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: 25e18f304c5b420719930533c91b34cc2df76504e5a6d28504491b42c267e2e9
4
- data.tar.gz: 99b6acaf7c8dc69e730ebe81a4de17eea39f72ff32e681da08130cdafd2181f2
3
+ metadata.gz: edb9e7eb50879a3caf5cec7ae59210d29a3096e82d68c997dd4ae545cac00e2d
4
+ data.tar.gz: 4bd84f05bf328b387fdb6213194c4920c0282cb6eaaee0ad1eb20610785d53bb
5
5
  SHA512:
6
- metadata.gz: 79f945a9dacd76714a5d2a229931858d0346428b9459a6dfa98b0a5a363a1b043ff12c29643cc3c3f0d1679930d9d7056c29d02813a18863594e796ce667ad7d
7
- data.tar.gz: 8b61b6a9f38a5ff2e52d5a37d7979ed18fd0d49f06f8ae3ecf4a07bba8683cc7cefc76b17ec68a3e4a32654644a313e2d6e70c24666de9d1f93654720233ecdd
6
+ metadata.gz: b958fd1b769033e6fe4420e371b568ec449fb55accac06c7ebb82a2a1ac0b2415770117c4d61b9746af87ef8d27e97c7e0888dbb3bfc55cba605f10c5d57d91d
7
+ data.tar.gz: 3ef96dd88833a733d9f4dd30592618df157ef6af60483c50aad82e2306c60ffb78348c9a7256a52102d34647b148ab8d58109b25088cf6480cd075b054d46d72
data/CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ ## 0.6.0
4
+
5
+ ### Breaking
6
+
7
+ - **`Authorization.header` and `AccessTokens.token` now take a URL, not a
8
+ hostname.** Pass the URL you are about to call —
9
+ `https://api.example.com/orders`, not `api.example.com`. Strip any query
10
+ string or fragment first; they are rejected. Earlier READMEs showed the
11
+ hostname form; those examples no longer work.
12
+ - **`AccessTokens#exists?` now requires the same URL argument.** It answers
13
+ for the entry covering that URL; there is no longer a single process-wide
14
+ token for it to answer about.
15
+ - **Requires an intake that accepts `base_url`.** An older intake returns
16
+ `400 {"error":"Missing required parameter: base_url"}`.
17
+
18
+ ### Changed
19
+
20
+ - `endpoint_authorize` authenticates to intake with Basic instead of minting
21
+ an access token for itself. The inbound request path no longer touches the
22
+ token cache at all.
23
+ - A 401 from the authorize endpoint is returned to the caller rather than
24
+ retried once. With Basic, a 401 means the credential is wrong.
25
+ - Tokens are cached per application environment, keyed on the canonical base
26
+ URL intake resolves the request to, rather than one per process.
27
+
28
+ ### Security
29
+
30
+ - The minted bearer token was previously written to the host application's
31
+ logs at info level: every successful token exchange logged the full intake
32
+ response body, which contains the live token, whenever your app's Rails
33
+ logger was set to info or more verbose. This release logs only the
34
+ response status code. If your logs go back further than this upgrade,
35
+ treat them as potentially containing live bearer tokens and handle them
36
+ per your own retention/rotation policy.
data/README.md CHANGED
@@ -79,6 +79,7 @@ Every setting listed below can be set explicitly in that block, and most also fa
79
79
  | `worker_count` | — | `4` | Currently unused by the delayed writer (which always spins up 2 threads); reserved. |
80
80
  | `token_ttl` | — | `nil` | Optional TTL (seconds) requested when generating a `Bearer` access token. |
81
81
  | `cache_ttl` | — | `300` | TTL (seconds) for the authorization decision cache. |
82
+ | `trust_proxy_headers` | — | `true` | Whether the per-request `scheme`/`host`/`port` report honors `X-Forwarded-Proto`/`-Host`/`-Port`. See [Reported base URL](#reported-base-url). |
82
83
  | `masking_rules` | — | `[]` | Ordered list of masking rule hashes — see [Data masking](#data-masking). |
83
84
  | `mask_hook` | — | `nil` | Optional `->(payload, record_type_string) { payload }` run after `masking_rules`. |
84
85
  | `version_finder` | — | `nil` | Optional `->(request) { "1" }` overriding `EndPointBlank::Commands::VersionFinder`'s default header/param/path detection. |
@@ -88,6 +89,35 @@ Note: there is also a bare `environment` accessor on `Configuration`, but it is
88
89
  code path in this gem (the real per-request environment name is `env_name`, described above) — do
89
90
  not rely on it.
90
91
 
92
+ ### Reported base URL
93
+
94
+ Every request payload carries the base URL the *caller* used, as three separate fields —
95
+ `scheme`, `host` and `port`. A field that cannot be resolved is omitted rather than sent as
96
+ null. EndPointBlank uses these to fill in an application environment's base URL for you,
97
+ instead of asking someone to type it.
98
+
99
+ By default the gem honors `X-Forwarded-Proto`, `X-Forwarded-Host` and `X-Forwarded-Port`,
100
+ reading the **last** comma-separated hop. It does this on its own, without consulting Rails'
101
+ or Rack's trusted-proxy configuration, so that all five EndPointBlank clients answer
102
+ identically for the same request.
103
+
104
+ **Turn this off if your application is reachable directly, with no proxy in front of it** —
105
+ or if you would simply rather report nothing than report something a caller could influence:
106
+
107
+ ```ruby
108
+ EndPointBlank.configure { |c| c.trust_proxy_headers = false }
109
+ ```
110
+
111
+ With it off, the `X-Forwarded-*` headers are ignored entirely and `scheme`, `host` and `port`
112
+ come from the connection and the `Host` header only.
113
+
114
+ It defaults to `true` because the alternative is worse for almost everyone. Most production
115
+ deployments sit behind an ALB, nginx, Caddy or an Ingress, and a client that ignored the
116
+ forwarded headers there would not report *nothing* — it would confidently report an internal
117
+ hostname on an internal port. `host` is caller-controlled either way (it has always come from
118
+ the `Host` header), and none of these three values is ever used as an identity or
119
+ authorization key, so the worst case is a wrong *suggestion* that an admin has to approve.
120
+
91
121
  ### `configure` block example
92
122
 
93
123
  ```ruby
@@ -120,16 +150,28 @@ export ENDPOINTBLANK_ENV=staging
120
150
 
121
151
  ### Authorization
122
152
 
123
- `EndPointBlank::Authorization.header(hostname = nil)` builds the outbound `Authorization` header
124
- used by the gem's own HTTP calls: a cached `Bearer` token for `hostname` when one is available
125
- (via `EndPointBlank::AccessTokens`), otherwise `Basic` credentials built from `client_id` /
126
- `client_secret`.
153
+ `EndPointBlank::Authorization.header(base_url = nil)` builds the outbound `Authorization` header
154
+ used by the gem's own HTTP calls: a cached `Bearer` token covering `base_url` when one is
155
+ available (via `EndPointBlank::AccessTokens`), otherwise `Basic` credentials built from
156
+ `client_id` / `client_secret` -- which covers both giving no target and a token that could not
157
+ be obtained.
127
158
 
128
159
  ```ruby
129
- EndPointBlank::Authorization.header # => "Basic ..."
130
- EndPointBlank::Authorization.header("api.example.com") # => "Bearer ..." if a token is cached
160
+ EndPointBlank::Authorization.header # => "Basic ..."
161
+
162
+ # Pass the URL you are about to call, NOT a hostname.
163
+ # Strip any query string or fragment first -- intake rejects both.
164
+ EndPointBlank::Authorization.header("https://api.example.com/orders") # => "Bearer ..." if a token is cached
131
165
  ```
132
166
 
167
+ The argument is the URL you are about to call. intake matches it against registered base URLs by
168
+ longest path prefix, so you need not know how the target registered itself -- `header` for
169
+ `https://api.example.com/orders/42` reuses a token already cached for
170
+ `https://api.example.com/orders`. `EndPointBlank::AccessTokens` caches one token per base URL
171
+ intake resolves to, not one per process, so a service that calls several targets holds a token
172
+ for each. A URL that does not match character-for-character (a different case, a query string,
173
+ an unregistered path) simply misses and mints a new token -- it never guesses.
174
+
133
175
  Under Rails, protect an inbound endpoint by including the `Authorized` concern in a controller —
134
176
  it calls `EndPointBlank::Commands::EndpointAuthorize.authorize(request)` before the action, and
135
177
  raises `EndPointBlank::UnauthorizedError` (which you can rescue with
@@ -143,8 +185,17 @@ end
143
185
 
144
186
  `EndPointBlank::Commands::EndpointAuthorize.authorize` sends the request's path, HTTP method,
145
187
  inbound `Authorization` header, app name, resolved endpoint version, and remote IP to
146
- `#{base_url}/api/authorize`, and caches a positive (201) result for `cache_ttl` seconds via
147
- `EndPointBlank::Commands::AuthenticationCache`.
188
+ `#{base_url}/api/authorize`, authenticating itself to intake with `Basic`, and caches a positive
189
+ (201) result for `cache_ttl` seconds via `EndPointBlank::Commands::AuthenticationCache`. It never
190
+ mints or presents a Bearer token for this call: intake already holds this service's own
191
+ credential, so exchanging one to present it back would buy nothing.
192
+
193
+ **Behavior change:** `target_hostname` on the authorize call now comes from the `Host` header
194
+ only. It previously came from `request.host`, which reads the last `X-Forwarded-Host` hop. If
195
+ your app sits behind a proxy that **rewrites** `Host` (nginx's default; Caddy and most ALBs
196
+ preserve it) and you registered the external hostname in the portal, either update the
197
+ registered hostname to the internal one the app now reports, or configure the proxy to preserve
198
+ `Host`. Deployments where `Host` and `X-Forwarded-Host` agree are unaffected.
148
199
 
149
200
  ### Error reporting
150
201
 
data/deploy ADDED
@@ -0,0 +1,5 @@
1
+ #!/bin/sh
2
+
3
+ gem build end_point_blank.gemspec
4
+
5
+ gem push end_point_blank-*.gem
@@ -34,8 +34,13 @@ Gem::Specification.new do |spec|
34
34
 
35
35
  # Uncomment to register a new dependency of your gem
36
36
  spec.add_dependency "excon", "~> 1.0"
37
- spec.add_dependency "rack"
38
- spec.add_dependency "rexml"
37
+ # rack and rexml carried no floor at all, which let a host application resolve
38
+ # them to versions with known advisories. These are the lowest releases clear
39
+ # of every advisory published against each gem, not the newest available — a
40
+ # library should state the oldest version it will vouch for, not force the
41
+ # host to the bleeding edge.
42
+ spec.add_dependency "rack", ">= 3.2.6"
43
+ spec.add_dependency "rexml", ">= 3.3.9"
39
44
  # Base64 was removed from Ruby's default gems as of 3.4; Authorization
40
45
  # uses it for the Basic-auth header, so declare it explicitly.
41
46
  spec.add_dependency "base64"
@@ -4,69 +4,217 @@ require 'singleton'
4
4
  require "time"
5
5
 
6
6
  module EndPointBlank
7
- # Thread-safe singleton cache for storing access tokens per hostname
7
+ # Thread-safe singleton holding this process's access tokens, one per
8
+ # application environment.
9
+ #
10
+ # A token is cached under the canonical base URL intake resolved the
11
+ # request to -- not under the URL the caller supplied. A caller asks for the
12
+ # URL it is about to call; intake answers with the base URL of the
13
+ # environment that URL belongs to, and subsequent calls anywhere under that
14
+ # base URL reuse the entry.
15
+ #
16
+ # Lookup is a plain exact-or-path-prefix comparison, with the longest match
17
+ # winning. The SDK deliberately does not normalize: intake owns that rule,
18
+ # and a miss costs one extra request rather than a wrong answer.
19
+ #
20
+ # A lookup has to scan the keys, and the fast path deliberately does not
21
+ # take the mutex, so every write **replaces** the entries Hash instead of
22
+ # mutating it. A reader then takes one atomic read of @entries and iterates
23
+ # something nobody can change underneath it. Mutating in place would risk
24
+ # "can't add a new key into hash during iteration" as soon as one thread
25
+ # minted a token for a second target while another was doing a lookup.
8
26
  class AccessTokens
9
27
  include Singleton
10
28
 
11
- def initialize()
12
- @tokens = {}
13
- @mutexes = {}
29
+ # Replace a token this far ahead of its expiry. An expired token can never
30
+ # be revived, only replaced, so going early is what keeps an in-flight
31
+ # request from carrying one that dies before it lands.
32
+ REFRESH_WINDOW = 120
33
+
34
+ # exists? is used to decide whether a caller can proceed without a round
35
+ # trip, so it answers no while there is barely any life left.
36
+ PRESENCE_WINDOW = 30
37
+
38
+ # How long to hold a token whose expiry the intake sent unreadably.
39
+ DEFAULT_LIFETIME = 3600
40
+
41
+ def initialize
42
+ @mutex = Mutex.new
43
+ @entries = {}
14
44
  end
15
45
 
16
- def self.token(arg)
17
- instance.token(arg)
46
+ def self.token(base_url)
47
+ instance.token(base_url)
18
48
  end
19
49
 
20
- # Retrieve or generate an access token for the given hostname
21
- # @param hostname [String] The hostname for which to retrieve the token
22
- # @return [String, nil] The access token or nil if generation fails
23
- def token(arg)
24
- hostname = arg.downcase
25
- @mutexes[hostname] ||= Mutex.new
26
- @mutexes[hostname].synchronize do
27
- # Return cached token if it exists and is not expired
28
- return @tokens[hostname][:token] if @tokens.key?(hostname) && @tokens[hostname][:expired_at] > Time.now + 120
29
-
30
- # Fetch new token
31
- payload = Commands::GenerateAccessToken.token(hostname)
32
-
33
- if payload && payload[:token]
34
- payload[:expired_at] = Time.parse(payload[:expired_at])
35
- @tokens[hostname] = payload
50
+ # Retrieve a token covering base_url, generating one if no usable entry
51
+ # covers it.
52
+ # @param base_url [String] the URL you are about to call, with any query
53
+ # string and fragment removed. It is sent verbatim; intake normalizes it
54
+ # and matches it against registered base URLs by longest path prefix.
55
+ # @return [String, nil] The access token string, or nil if generation
56
+ # failed -- which includes a response that carried a token but no
57
+ # base_url.
58
+ def token(base_url)
59
+ entry = match(base_url)
60
+ return entry[:token] if usable?(entry)
61
+
62
+ @mutex.synchronize do
63
+ # Another caller may have filled it while this one waited.
64
+ entry = match(base_url)
65
+ return entry[:token] if usable?(entry)
66
+
67
+ payload = Commands::GenerateAccessToken.token(base_url)
68
+
69
+ # The key is what intake resolved to, and only that. There is no
70
+ # fallback to the requested URL: that would key on the resource the
71
+ # caller happened to ask about, so a service walking /orders/1,
72
+ # /orders/2, /orders/3 would mint and store a token per resource, and
73
+ # nothing here evicts. Without a base URL the right application
74
+ # cannot be found, so no token is handed back either.
75
+ key = payload && payload[:base_url]
76
+
77
+ if payload && payload[:token] && key
78
+ # The match that led here may have resolved under a different key
79
+ # than the one intake just returned -- an environment's base URL
80
+ # can change to a shorter path in the portal. Drop that stale key
81
+ # when it differs from the fresh one, or it goes on shadowing it:
82
+ # being the longer of the two, it keeps winning "longest match
83
+ # wins", keeps failing usable?, and keeps forcing a mint on every
84
+ # call until the process restarts. The failure branch below already
85
+ # does the equivalent for a match that turned out unusable; this is
86
+ # the same cleanup for a match that turned out to have moved.
87
+ stale = match_key(base_url, @entries)
88
+ new_entries = @entries.merge(
89
+ key => { token: payload[:token], expired_at: parse_expiry(payload[:expired_at]) }.freeze
90
+ )
91
+ new_entries = new_entries.reject { |k, _| k == stale } if stale && stale != key
92
+ @entries = new_entries.freeze
36
93
  payload[:token]
37
94
  else
38
- EndPointBlank.logger.error "Failed to generate access token for #{hostname}: #{payload&.fetch('error')}"
95
+ # A failed refresh must not leave an expiring token behind claiming
96
+ # to be usable -- callers would keep presenting it right up to the
97
+ # 401. Only the entry that covers this URL goes: the longest match
98
+ # is the one that was just found unusable, so a shorter, still-good
99
+ # entry survives.
100
+ stale = match_key(base_url, @entries)
101
+ @entries = @entries.reject { |k, _| k == stale }.freeze if stale
102
+
103
+ EndPointBlank.logger.error "Failed to generate access token for #{base_url}: #{failure_reason(payload)}"
39
104
  nil
40
105
  end
41
106
  end
42
107
  end
43
108
 
44
- # Clear all tokens from the cache
45
- # @return [Hash] Empty hash
46
- def clear(arg)
47
- @mutexes.keys.each do |hostname|
48
- @mutexes[hostname].synchronize do
49
- @tokens.delete(hostname)
50
- end
109
+ # Discard every held token
110
+ # @return [nil]
111
+ def clear
112
+ @mutex.synchronize { @entries = {}.freeze }
113
+ end
114
+
115
+ # Discard the held token, but only if it is still the one the caller had
116
+ #
117
+ # Every request in flight when a token is rejected reports the same stale
118
+ # value. Only the first of them should cause an exchange -- the rest are
119
+ # holding a token that has already been replaced, and clearing on their
120
+ # behalf would discard a good token and stampede intake.
121
+ #
122
+ # The lookup is by token value because a rejected caller has a token, not
123
+ # a URL.
124
+ #
125
+ # @param stale_token [String, nil] the token the caller was rejected for;
126
+ # ignored when it is no longer the one held for its base URL.
127
+ # @return [nil]
128
+ def invalidate(stale_token)
129
+ return if stale_token.nil?
130
+
131
+ @mutex.synchronize do
132
+ @entries = @entries.reject { |_, entry| entry[:token] == stale_token }.freeze
133
+ end
134
+ end
135
+
136
+ # Check whether a token covering base_url is held and is not about to
137
+ # expire
138
+ # @param base_url [String] the URL to check coverage for
139
+ # @return [Boolean]
140
+ def exists?(base_url)
141
+ entry = match(base_url)
142
+ !entry.nil? && entry[:expired_at] > Time.now + PRESENCE_WINDOW
143
+ end
144
+
145
+ private
146
+
147
+ # Returns the longest key in entries covering base_url, or nil.
148
+ #
149
+ # A nil or empty base_url never matches. An empty cache can't raise on
150
+ # one -- the loop body never runs -- so a non-empty cache must not either,
151
+ # or the same call succeeds or raises NoMethodError (nil has no
152
+ # start_with?) depending on unrelated traffic that happened to warm the
153
+ # cache first. Checking once, here, keeps every caller consistent for
154
+ # free: the lookup, the stale-entry cleanup on a failed refresh, and the
155
+ # stale-entry cleanup on a successful one.
156
+ #
157
+ # Deliberately not a port of intake's matcher: no normalization on either
158
+ # side. A caller that passes a non-canonical URL simply misses and mints
159
+ # again, which costs one HTTP call and is never a wrong answer.
160
+ #
161
+ # Takes entries as an explicit argument, rather than reading @entries
162
+ # itself, so the snapshot discipline is structural: every caller decides
163
+ # which snapshot is being scanned instead of this method reaching for
164
+ # whatever @entries happens to be at the moment it runs.
165
+ def match_key(base_url, entries)
166
+ return nil if base_url.nil? || base_url.empty?
167
+
168
+ best = nil
169
+ entries.each_key do |key|
170
+ next unless base_url == key || base_url.start_with?("#{key}/")
171
+
172
+ best = key if best.nil? || key.length > best.length
51
173
  end
174
+ best
52
175
  end
53
176
 
54
- # Remove token for a specific hostname
55
- # @param hostname [String] The hostname for which to remove the token
56
- # @return [Object, nil] The removed token data or nil if not found
57
- def remove(arg)
58
- hostname = arg.downcase
59
- @mutexes[hostname].synchronize do
60
- @tokens.delete(hostname)
177
+ def match(base_url)
178
+ entries = @entries # One atomic read; writes replace, never mutate.
179
+ key = match_key(base_url, entries)
180
+ key && entries[key]
181
+ end
182
+
183
+ def usable?(entry)
184
+ !entry.nil? && entry[:expired_at] > Time.now + REFRESH_WINDOW
185
+ end
186
+
187
+ # Why a mint produced no usable token, for the log.
188
+ def failure_reason(payload)
189
+ return "no response" unless payload.is_a?(Hash)
190
+ return payload[:error] if payload[:error]
191
+
192
+ if payload[:token]
193
+ # Distinct from a rejected request: intake's base_url is NOT NULL, and
194
+ # it answers 422 rather than minting when the caller's URL resolves to
195
+ # no environment. A token with no base_url is a broken server.
196
+ return "response carried a token but no base_url"
61
197
  end
198
+
199
+ "no token in response"
62
200
  end
63
201
 
64
- # Check if a valid token exists for a given hostname
65
- # @param hostname [String] The hostname to check
66
- # @return [Boolean] True if a valid token exists, false otherwise
67
- def exists?(arg)
68
- hostname = arg.downcase
69
- @tokens.key?(hostname) && @tokens[hostname][:expired_at] > Time.now + 30
202
+ # Time.parse raises on anything it cannot read — an ArgumentError for a
203
+ # string it fails to understand, a TypeError for a value that is not a
204
+ # string at all, including the nil left by a missing key. This runs inside
205
+ # the mutex on the path a caller's request goes through, so a malformed
206
+ # timestamp from the intake came out of Authorization.header and into the
207
+ # host application's request.
208
+ #
209
+ # An hour is a guess, but a working one. Treating the token as unusable
210
+ # instead would mean an exchange on every inbound request for as long as
211
+ # the far end misbehaves. There is no retry here if the token dies sooner
212
+ # than the guess -- invalidate has no caller on this path -- so a bad
213
+ # guess means 401s until the cache's own expiry-based refresh catches up.
214
+ def parse_expiry(value)
215
+ Time.parse(value.to_s)
216
+ rescue ArgumentError, TypeError
217
+ Time.now + DEFAULT_LIFETIME
70
218
  end
71
219
  end
72
220
  end
@@ -9,9 +9,17 @@ module EndPointBlank
9
9
  EndPointBlank::Configuration.instance
10
10
  end
11
11
 
12
- def header(hostname = nil)
12
+ # Builds an outbound Authorization header value.
13
+ # @param base_url [String, nil] the URL you are about to call, with any
14
+ # query string and fragment removed. If given, a token covering it is
15
+ # used (minting one if necessary) and returned as a Bearer header.
16
+ # Called with no argument this is the Basic form -- which is what the
17
+ # calls to intake itself use, since intake already holds this
18
+ # service's own credential.
19
+ # @return [String] "Bearer <token>" or "Basic <credentials>"
20
+ def header(base_url = nil)
13
21
  token = nil
14
- token = EndPointBlank::AccessTokens.token(hostname) if hostname
22
+ token = EndPointBlank::AccessTokens.token(base_url) if base_url
15
23
 
16
24
  if token
17
25
  "Bearer " + token
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EndPointBlank
4
+ # Resolves the base URL the caller used -- scheme, host and port -- from a
5
+ # Rack env.
6
+ #
7
+ # This deliberately does not go through Rack::Request#host / #scheme / #port.
8
+ # Rack, Express, the servlet spec and Plug each resolve "host" differently
9
+ # (Rack takes the last X-Forwarded-Host hop, Express the first, WSGI and Plug
10
+ # neither), which is precisely why the same request produced five different
11
+ # answers across the five clients. The env is read directly so that this
12
+ # algorithm is the same one implemented in the JS, Python, Java and Elixir
13
+ # libraries.
14
+ #
15
+ # Forwarded headers are honored when `trust_proxy_headers` is on, which it is
16
+ # by default: `host` was already caller-controlled in every client (all five
17
+ # read the Host header), so this opens no new hole for the field that matters
18
+ # most, and taking the LAST hop means that behind a proxy that appends, the
19
+ # value is the proxy's own observation rather than anything the caller
20
+ # planted. A directly-exposed deployment -- where nothing sits in front to
21
+ # overwrite the headers -- can set the flag to false, and then only the
22
+ # connection and the Host header are consulted.
23
+ #
24
+ # The flag arrives as an argument rather than being read from Configuration
25
+ # here, so that this module stays framework- and configuration-free and both
26
+ # states are directly testable.
27
+ module BaseUrl
28
+ HOSTNAME = /\A[a-z0-9._-]+\z/
29
+ IPV6 = /\A\[[0-9a-f:.]+\]\z/
30
+ SCHEME = /\A[a-z][a-z0-9+.-]{0,31}\z/
31
+ DEFAULT_PORTS = { "http" => 80, "https" => 443 }.freeze
32
+
33
+ module_function
34
+
35
+ # Returns a Hash carrying only the fields that resolved to a usable value.
36
+ # A field that could not be resolved is absent, never nil: the receiver has
37
+ # to be able to tell "this SDK did not report a port" from "the port is
38
+ # null".
39
+ #
40
+ # With trust_proxy_headers: false the three X-Forwarded-* headers are not
41
+ # read at all, so the request is never treated as proxied and the
42
+ # connection's scheme and port stay evidence.
43
+ def from_rack_env(env, trust_proxy_headers: true)
44
+ return {} unless env.is_a?(Hash)
45
+
46
+ forwarded_scheme = trust_proxy_headers ? clean_scheme(last_hop(env["HTTP_X_FORWARDED_PROTO"])) : nil
47
+ forwarded_host_part, forwarded_host_authority_port =
48
+ trust_proxy_headers ? split_authority(last_hop(env["HTTP_X_FORWARDED_HOST"])) : [nil, nil]
49
+ forwarded_host = clean_host(forwarded_host_part)
50
+ forwarded_port = trust_proxy_headers ? parse_port(last_hop(env["HTTP_X_FORWARDED_PORT"])) : nil
51
+
52
+ # Evidence is judged AFTER validation, not on raw header presence: a
53
+ # malformed header (e.g. "X-Forwarded-Port: not-a-port") parses to
54
+ # nothing and so must never count as proxy evidence, or an
55
+ # unauthenticated caller could blank an otherwise-valid field just by
56
+ # sending garbage.
57
+ #
58
+ # Only a forwarded scheme or port counts as evidence strong enough to
59
+ # distrust the raw connection. A forwarded Host alone does not: some
60
+ # proxies rewrite only the Host header and pass scheme/port through
61
+ # unchanged, so a valid X-Forwarded-Host by itself says nothing about
62
+ # whether the connection's own scheme/port belong to the proxy or the
63
+ # caller.
64
+ proxied = !forwarded_scheme.nil? || !forwarded_port.nil?
65
+
66
+ # A malformed X-Forwarded-Host (wrong shape, or too long -- see
67
+ # clean_host) gets the same treatment as a malformed proto or port: it
68
+ # is ignored entirely and falls back to the direct Host header, exactly
69
+ # as if the header were absent, rather than leaving host unresolved.
70
+ host_part, authority_port =
71
+ if forwarded_host
72
+ [forwarded_host_part, forwarded_host_authority_port]
73
+ else
74
+ split_authority(host_authority(env))
75
+ end
76
+
77
+ scheme = forwarded_scheme || (proxied ? nil : clean_scheme(env["rack.url_scheme"]))
78
+ host = forwarded_host || clean_host(host_part)
79
+ port_candidate = forwarded_port ||
80
+ parse_port(authority_port) ||
81
+ (proxied ? nil : parse_port(env["SERVER_PORT"]))
82
+ port = usable_port(port_candidate, scheme)
83
+
84
+ resolved = {}
85
+ resolved[:scheme] = scheme if scheme
86
+ resolved[:host] = host if host
87
+ resolved[:port] = port if port
88
+ resolved
89
+ end
90
+
91
+ # The hostname alone, for the authorize path.
92
+ #
93
+ # Deliberately NOT from_rack_env(env)[:host]: reads the Host header only,
94
+ # never the forwarded chain, however trust_proxy_headers is set. The
95
+ # value feeds `target_hostname` and the access-token cache key, and the
96
+ # portal resolves an application environment from it -- a value matching
97
+ # no registered row is a hard 422 with no fallback, not a cache miss.
98
+ #
99
+ # Composed from the same split_authority/clean_host pair from_rack_env
100
+ # uses, so IPv6 bracketing, lowercasing, and shape and length validation
101
+ # are identical between the two; only the authority's source differs.
102
+ def hostname_from_rack_env(env)
103
+ return nil unless env.is_a?(Hash)
104
+
105
+ host_part, _authority_port = split_authority(host_authority(env))
106
+ clean_host(host_part)
107
+ end
108
+
109
+ # A proxy that appends writes its own observation last. A proxy that
110
+ # overwrites (nginx, Caddy, ALB) emits one value, where first and last are
111
+ # the same thing.
112
+ def last_hop(value)
113
+ return nil unless value.is_a?(String)
114
+
115
+ hops = value.split(",").map(&:strip).reject(&:empty?)
116
+ hops.last
117
+ end
118
+
119
+ # The authority the caller named, for both resolution paths.
120
+ #
121
+ # An empty Host header is treated as ABSENT, not as a present-but-unusable
122
+ # value. A caller that sends `Host:` with nothing after it has said nothing
123
+ # about which host it meant, so there is nothing there to prefer over
124
+ # SERVER_NAME. Falling through concedes no control the caller did not
125
+ # already have either: SERVER_NAME is a server-side value, not one a
126
+ # request can steer.
127
+ #
128
+ # On the authorize path the alternative is worse than cosmetic. Resolving
129
+ # the host to nil there drops the request to Basic auth and skips the token
130
+ # mint entirely, where falling through yields a usable
131
+ # application-environment lookup key.
132
+ #
133
+ # This CHANGES Ruby's behavior: an empty Host header used to resolve the
134
+ # host to nil. `env["HTTP_HOST"] || env["SERVER_NAME"]` stops at "" because
135
+ # "" is truthy in Ruby. Python, Java and JS already fell through, because
136
+ # "" is falsy there; Ruby and Elixir stopped, because "" is truthy in both.
137
+ # One expression written five times, diverging only on the empty case. It
138
+ # now lives at one site per SDK, and this comment is why.
139
+ #
140
+ # `.to_s.empty?` so a nil Host header takes the same branch as an empty one.
141
+ def host_authority(env)
142
+ host = env["HTTP_HOST"]
143
+ host.to_s.empty? ? env["SERVER_NAME"] : host
144
+ end
145
+
146
+ # "api.example.com:8443" -> ["api.example.com", "8443"]
147
+ # "[2001:db8::1]:8443" -> ["[2001:db8::1]", "8443"]
148
+ def split_authority(value)
149
+ return [nil, nil] unless value.is_a?(String)
150
+
151
+ authority = value.strip
152
+ if authority.start_with?("[")
153
+ head, bracket, tail = authority.partition("]")
154
+ return [nil, nil] if bracket.empty?
155
+
156
+ ["#{head}]", tail.start_with?(":") ? tail[1..] : nil]
157
+ elsif authority.count(":") == 1
158
+ host, _, port = authority.partition(":")
159
+ [host, port]
160
+ else
161
+ [authority, nil]
162
+ end
163
+ end
164
+
165
+ # Normalize, then validate. "HTTPS" and "https:" both have to reach intake
166
+ # as "https": JS's location.protocol and Node's URL#protocol keep the
167
+ # colon, nothing pins the case, and intake never rewrites a stored row --
168
+ # two spellings of the same scheme would split the dominant-triple
169
+ # grouping forever. delete_suffix removes one colon, not all of them, so
170
+ # "https::" still fails the shape check rather than sneaking through.
171
+ def clean_scheme(value)
172
+ return nil unless value.is_a?(String)
173
+
174
+ scheme = value.strip.downcase.delete_suffix(":")
175
+ scheme.match?(SCHEME) ? scheme : nil
176
+ end
177
+
178
+ # DNS caps a hostname at 253 characters, and the receiving column is
179
+ # varchar(255). No web-server adapter validates the length of
180
+ # X-Forwarded-Host, so without this a caller could make the SDK report an
181
+ # arbitrarily long value; dropped, not truncated, because a truncated
182
+ # hostname is a plausible-looking WRONG one and the portal reads `host`
183
+ # verbatim to assemble a base URL.
184
+ def clean_host(value)
185
+ return nil unless value.is_a?(String)
186
+
187
+ host = value.strip.downcase
188
+ return nil if host.empty? || host.bytesize > 253
189
+
190
+ host.match?(HOSTNAME) || host.match?(IPV6) ? host : nil
191
+ end
192
+
193
+ # Numeric validation only -- 1..65535, nothing scheme-aware. Used both to
194
+ # decide whether a forwarded/authority port counts as a usable value and
195
+ # as a port candidate; default-port omission happens exactly once, in
196
+ # usable_port, against the FINAL resolved scheme, so it can never be
197
+ # skipped just because the scheme happened to resolve from a different
198
+ # source than the port did.
199
+ def parse_port(value)
200
+ port = Integer(value.to_s.strip, 10, exception: false)
201
+ return nil if port.nil? || port < 1 || port > 65_535
202
+
203
+ port
204
+ end
205
+
206
+ # A port is reported only when it can be classified against a resolved
207
+ # scheme. With no scheme, "default" is meaningless, so an unclassifiable
208
+ # port is withheld entirely rather than guessed at -- the same origin must
209
+ # never be reportable two ways depending on which headers happened to
210
+ # arrive.
211
+ def usable_port(candidate, scheme)
212
+ return nil if candidate.nil? || scheme.nil?
213
+ return nil if DEFAULT_PORTS[scheme] == candidate
214
+
215
+ candidate
216
+ end
217
+ end
218
+ end
@@ -1,5 +1,6 @@
1
1
  #!/bin/ruby
2
2
 
3
+ require 'json'
3
4
  require_relative 'http'
4
5
  require_relative 'authentication_cache'
5
6
 
@@ -19,35 +20,56 @@ module EndPointBlank
19
20
  method = request.request_method
20
21
  path = request.route_uri_pattern.to_s.gsub(/\([^)]*\)/, '')
21
22
  app_name = Configuration.instance.app_name
22
- cache_key = "epb_auth:#{client_auth}:#{path}:#{method}:#{app_name}"
23
+ # The version is part of the key because authorization is decided per
24
+ # endpoint version, and so is the deprecation carried back with it.
25
+ # Without it, two callers on different versions of the same route share
26
+ # one entry: whichever authorizes first decides both, so a client on a
27
+ # deprecated version can get no warning, or one on a current version
28
+ # can be told it is retiring.
29
+ version = VersionFinder.new.find(request)
30
+ cache_key = "epb_auth:#{client_auth}:#{path}:#{method}:#{app_name}:#{version}"
23
31
 
24
32
  cache = AuthenticationCache.instance
25
- return CachedResponse.new(201, '') if cache.exists?(cache_key)
33
+ # The cached value is the authorize response body, not a truthy
34
+ # marker.
35
+ #
36
+ # It has to be, for two reasons. Callers parse the body — a cache hit
37
+ # returning '' made JSON.parse raise, so a cached authorization became
38
+ # a 500 rather than a fast success. And the body is where the
39
+ # deprecation block lives; without it the Deprecation and Sunset
40
+ # headers would appear only on cache misses, which reads as a flaky
41
+ # feature rather than a missing one.
42
+ if (cached = cache.retrieve(cache_key))
43
+ return CachedResponse.new(201, cached)
44
+ end
26
45
 
27
- hostname = request.host
28
- auth = Authorization.header(hostname)
46
+ # Host header only, never the forwarded chain -- see
47
+ # BaseUrl.hostname_from_rack_env. request.host would read
48
+ # X-Forwarded-Host unconditionally.
49
+ hostname = EndPointBlank::BaseUrl.hostname_from_rack_env(request.env)
29
50
  body = {
30
51
  path: path,
31
52
  http_method: method,
32
53
  client_auth: client_auth,
33
54
  target_hostname: hostname,
34
55
  application: app_name,
35
- endpoint_version: VersionFinder.new.find(request),
56
+ endpoint_version: version,
36
57
  source_ip: request.remote_ip,
37
58
  uuid: request.uuid
38
59
  }
39
- response = Http.post(configuration.authorize_url, auth, body)
40
60
 
41
- if response&.status == 401 && auth.to_s.start_with?("Bearer ")
42
- EndPointBlank::AccessTokens.instance.remove(hostname)
43
- auth = Authorization.header(hostname)
44
- response = Http.post(configuration.authorize_url, auth, body)
45
- end
61
+ # Basic, not Bearer. This call is to intake, which already holds
62
+ # this service's credential -- minting a token to present it back
63
+ # was a hop that bought nothing. With no Bearer there is no stale
64
+ # token, so the 401 retry that used to live here is gone: a 401 now
65
+ # means the credential is wrong, which is worth surfacing rather
66
+ # than retrying.
67
+ response = Http.post(configuration.authorize_url, Authorization.header, body)
46
68
 
47
69
  return nil if response.nil?
48
70
  EndPointBlank.logger.info "Authentication response: #{response.status} - #{response.body}"
49
71
  if response.status == 201
50
- cache.store(cache_key, true)
72
+ cache.store(cache_key, response.body)
51
73
  elsif response.status > 299
52
74
  EndPointBlank.logger.error "Failed to authorize endpoint: #{response.status} - #{response.body}"
53
75
  end
@@ -110,7 +110,7 @@ module EndPointBlank
110
110
  nil
111
111
  else
112
112
  controller_class = "#{route.defaults[:controller].camelize}Controller".constantize
113
- versions = controller_class.respond_to?(:versions) ? controller_class.versions(route.defaults[:action].to_sym) : {}
113
+ versions = controller_class.respond_to?(:versions) ? controller_class.versions(route.defaults[:action].to_sym) : []
114
114
  {
115
115
  path: route.path.spec.to_s.gsub(/\([^)]*\)/, ''),
116
116
  http_method: route.verb,
@@ -12,8 +12,8 @@ module EndPointBlank
12
12
  EndPointBlank::Configuration.instance
13
13
  end
14
14
 
15
- def token(hostname)
16
- body = {hostname: hostname}
15
+ def token(base_url)
16
+ body = {base_url: base_url}
17
17
  if configuration.token_ttl
18
18
  body[:token_ttl] = configuration.token_ttl
19
19
  end
@@ -23,7 +23,7 @@ module EndPointBlank
23
23
  body: body.to_json,
24
24
  **EndPointBlank::Commands::Http::TIMEOUT_OPTIONS
25
25
  )
26
- EndPointBlank.logger.info "Authentication response: #{response.status} - #{response.body}"
26
+ EndPointBlank.logger.info "Authentication response: #{response.status}"
27
27
  parsed_body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
28
28
  parsed_body.transform_keys(&:to_sym)
29
29
  rescue => e
@@ -15,7 +15,7 @@ module EndPointBlank
15
15
 
16
16
  attr_accessor :worker_count, :log_mode,
17
17
  :version_finder, :application_version, :token_ttl, :cache_ttl,
18
- :masking_rules, :mask_hook, :logger
18
+ :masking_rules, :mask_hook, :logger, :trust_proxy_headers
19
19
 
20
20
  def initialize
21
21
  @worker_count = 4
@@ -23,6 +23,7 @@ module EndPointBlank
23
23
  @cache_ttl = 300
24
24
  @masking_rules = []
25
25
  @mask_hook = nil
26
+ @trust_proxy_headers = true
26
27
  end
27
28
 
28
29
  # Returns the configured client id, falling back to the
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module EndPointBlank
6
+ # Formats the deprecation facts returned by an authorize call into the
7
+ # standard response headers.
8
+ #
9
+ # Deprecation RFC 9745 — an Item Structured Header Date: "@1688169599"
10
+ # Sunset RFC 8594 — an HTTP-date: "Sat, 31 Dec 2018 23:59:59 GMT"
11
+ #
12
+ # RFC 9745 permits a past value ("was deprecated at that date"), which is what
13
+ # EndPointBlank emits: deprecation takes effect when it is declared.
14
+ #
15
+ # Pure and stateless on purpose. The SDK does not know what a lifecycle is —
16
+ # it relays two timestamps the portal already decided about, and this turns
17
+ # them into two strings. That keeps the vectors in
18
+ # docs/superpowers/specs/2026-08-01-header-vectors.md assertable without
19
+ # constructing a request.
20
+ module DeprecationHeaders
21
+ DEPRECATION = "Deprecation"
22
+ SUNSET = "Sunset"
23
+
24
+ # Fixed English abbreviations. Ruby's %a/%b are locale-independent, but
25
+ # spelling them out removes the question entirely — a server running under a
26
+ # different locale must still emit an HTTP-date.
27
+ DAYS = %w[Sun Mon Tue Wed Thu Fri Sat].freeze
28
+ MONTHS = %w[Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec].freeze
29
+
30
+ class << self
31
+ # @param deprecation [Hash, nil] the authorize response's "deprecation"
32
+ # block: {"deprecated_at" => iso8601, "sunset_at" => iso8601 | nil}
33
+ # @return [Hash] header name => value; empty when there is nothing to say
34
+ def build(deprecation)
35
+ return {} unless deprecation.is_a?(Hash)
36
+
37
+ headers = {}
38
+
39
+ if (at = parse(deprecation["deprecated_at"] || deprecation[:deprecated_at]))
40
+ headers[DEPRECATION] = deprecation_value(at)
41
+ end
42
+
43
+ if (at = parse(deprecation["sunset_at"] || deprecation[:sunset_at]))
44
+ headers[SUNSET] = sunset_value(at)
45
+ end
46
+
47
+ headers
48
+ end
49
+
50
+ # "@1688169599" — no quotes, no sub-second precision.
51
+ def deprecation_value(time)
52
+ "@#{time.to_i}"
53
+ end
54
+
55
+ # "Sat, 31 Dec 2018 23:59:59 GMT" — day-of-month zero padded, always GMT.
56
+ def sunset_value(time)
57
+ t = time.utc
58
+ format(
59
+ "%s, %02d %s %04d %02d:%02d:%02d GMT",
60
+ DAYS[t.wday], t.day, MONTHS[t.month - 1], t.year, t.hour, t.min, t.sec
61
+ )
62
+ end
63
+
64
+ private
65
+
66
+ # Never raise into the provider's response path. A malformed timestamp
67
+ # from the portal is a bug worth no header; it is not worth a 500 on a
68
+ # request that already succeeded.
69
+ #
70
+ # Types are matched rather than coerced. `Time.parse(12345)` does not
71
+ # raise — it happily returns a date in 2012 — so a `to_s` here would turn
72
+ # a nonsense value into a plausible-looking header, which is the one
73
+ # outcome worse than no header at all.
74
+ def parse(value)
75
+ case value
76
+ when Time then value.utc
77
+ when String then parse_string(value)
78
+ end
79
+ end
80
+
81
+ def parse_string(value)
82
+ return nil if value.empty?
83
+
84
+ Time.parse(value).utc
85
+ rescue ArgumentError, TypeError
86
+ nil
87
+ end
88
+ end
89
+ end
90
+ end
@@ -19,6 +19,14 @@ module EndPointBlank
19
19
 
20
20
  status, headers, body = @app.call(env)
21
21
 
22
+ # RFC 9745 / RFC 8594. The provider writes no code for this: the
23
+ # portal decided the version is deprecated, authorize relayed the
24
+ # dates, and this attaches them to the response their consumer
25
+ # already receives.
26
+ ::EndPointBlank::DeprecationHeaders
27
+ .build(::EndPointBlank::Rack::EnvStore.deprecation)
28
+ .each { |name, value| headers[name] = value }
29
+
22
30
  [status, headers, body]
23
31
  rescue ::EndPointBlank::UnauthorizedError => e
24
32
  # We don't want to log unauthorized errors as they are expected to happen
@@ -1,8 +1,28 @@
1
1
  module EndPointBlank
2
2
  module Rack
3
+ # Per-request state, keyed off the Rack env.
4
+ #
5
+ # Only the env itself is held in a thread-local; everything derived from a
6
+ # request lives *inside* that env. That distinction is the whole point.
7
+ #
8
+ # Threads are reused between requests, so a thread-local is correct only for
9
+ # as long as something reliably clears it — here, the middleware's `ensure`.
10
+ # Anything that set a value without that middleware in the stack would
11
+ # strand it on the thread, and the next request served by that thread could
12
+ # read the previous caller's data: their app-environment id on our audit
13
+ # row, their sunset date on our response.
14
+ #
15
+ # The env is per-request by construction. The server builds a fresh hash per
16
+ # request and `set/1` replaces it wholesale at the top of the middleware, so
17
+ # a stranded value is unreachable even if `clear/0` never runs. Correctness
18
+ # stops depending on cleanup happening.
19
+ #
20
+ # Outside a Rack request there is nowhere to put anything and no response to
21
+ # decorate, so the writers are no-ops and the readers return nil.
3
22
  class EnvStore
4
23
  KEY = 'end_point_blank.rack_env'.freeze
5
24
  SOURCE_ENV_ID_KEY = 'end_point_blank.source_application_environment_id'.freeze
25
+ DEPRECATION_KEY = 'end_point_blank.deprecation'.freeze
6
26
 
7
27
  def self.set(env)
8
28
  Thread.current[KEY] = env
@@ -17,18 +37,42 @@ module EndPointBlank
17
37
  env && ::Rack::Request.new(env)
18
38
  end
19
39
 
40
+ # The calling service's application environment, resolved by `authorize!`
41
+ # and stamped onto every audit row for this request.
20
42
  def self.set_source_application_environment_id(id)
21
- Thread.current[SOURCE_ENV_ID_KEY] = id
43
+ put(SOURCE_ENV_ID_KEY, id)
22
44
  end
23
45
 
24
46
  def self.source_application_environment_id
25
- Thread.current[SOURCE_ENV_ID_KEY]
47
+ fetch(SOURCE_ENV_ID_KEY)
48
+ end
49
+
50
+ # The authorize response's deprecation block, stashed on the way in so the
51
+ # response middleware can turn it into `Deprecation` and `Sunset` headers
52
+ # on the way out.
53
+ def self.set_deprecation(deprecation)
54
+ put(DEPRECATION_KEY, deprecation)
55
+ end
56
+
57
+ def self.deprecation
58
+ fetch(DEPRECATION_KEY)
26
59
  end
27
60
 
28
61
  def self.clear
29
62
  Thread.current[KEY] = nil
30
- Thread.current[SOURCE_ENV_ID_KEY] = nil
31
63
  end
64
+
65
+ def self.put(key, value)
66
+ env = get
67
+ env[key] = value if env
68
+ end
69
+ private_class_method :put
70
+
71
+ def self.fetch(key)
72
+ env = get
73
+ env && env[key]
74
+ end
75
+ private_class_method :fetch
32
76
  end
33
77
  end
34
- end
78
+ end
@@ -3,6 +3,8 @@ module EndPointBlank
3
3
  module Headers
4
4
  def self.extract
5
5
  env = ::EndPointBlank::Rack::EnvStore.get
6
+ return {} if env.nil?
7
+
6
8
  env.select { |k,v| k.start_with? 'HTTP_'}.
7
9
  transform_keys { |k| k.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-') }
8
10
  end
@@ -17,6 +17,9 @@ module EndPointBlank
17
17
  result_json = JSON.parse(result.body)
18
18
  app_env_id = result_json['data'][0]['source_application_environment_id']
19
19
  ::EndPointBlank::Rack::EnvStore.set_source_application_environment_id(app_env_id)
20
+ # Present only when the called version is deprecated; the middleware
21
+ # turns it into Deprecation / Sunset headers on the way out.
22
+ ::EndPointBlank::Rack::EnvStore.set_deprecation(result_json['deprecation'])
20
23
  end
21
24
 
22
25
  private
@@ -4,15 +4,20 @@ module EndPointBlank
4
4
  extend ActiveSupport::Concern
5
5
 
6
6
  class_methods do
7
- # Define versioning for specific actions
7
+ # Record which versions specific actions serve.
8
+ #
9
+ # Lifecycle state (Current, Deprecated, ...) is NOT declared here. It is
10
+ # managed in the EndPointBlank portal, where changing it does not require
11
+ # shipping code. This reports which versions exist, not what they mean.
12
+ #
8
13
  # @param versions [Array<String>] List of version strings (e.g., ["v1", "v2"])
9
14
  # @param options [Hash] Options hash with :only or :except keys
10
15
  # @option options [Array<Symbol>] :only Actions to include in versioning
11
16
  # @option options [Array<Symbol>] :except Actions to exclude from versioning
12
17
  #
13
18
  # Example:
14
- # version ["v1", "v2"], only: [:index], state: "Current"
15
- # version ["v3"], except: [:destroy], state: "Deprecated"
19
+ # version ["v1", "v2"], only: [:index]
20
+ # version ["v3"], except: [:destroy]
16
21
  def version(values, options = {})
17
22
  versions = Array(values)
18
23
  @versioning_config ||= {}
@@ -20,23 +25,16 @@ module EndPointBlank
20
25
  actions = determine_actions(options)
21
26
 
22
27
  actions.each do |action|
23
- @versioning_config[action] ||= {}
24
- state = options[:state] || "__default__"
25
- @versioning_config[action][state] ||= []
26
- @versioning_config[action][state] = (@versioning_config[action][state] + versions).uniq
28
+ # uniq preserves declaration order, so the manifest stays stable
29
+ # between deploys instead of churning.
30
+ @versioning_config[action] = ((@versioning_config[action] || []) + versions).uniq
27
31
  end
28
32
  end
29
33
 
30
- # Returns the versioning configuration hash
31
- # @return [Hash] Hash mapping action names to their version arrays
32
- #
33
- # Example return value:
34
- # {
35
- # index: ["v1", "v2"],
36
- # show: ["v1"]
37
- # }
34
+ # Versions the given action serves.
35
+ # @return [Array<String>] e.g. ["v1", "v2"]; empty when none declared.
38
36
  def versions(action)
39
- @versioning_config&.fetch(action, {}) || {}
37
+ @versioning_config&.fetch(action, []) || []
40
38
  end
41
39
 
42
40
  private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EndPointBlank
4
- VERSION = "0.2.2"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -26,11 +26,10 @@ module EndPointBlank
26
26
  version = Commands::VersionFinder.new.find(request)
27
27
  headers = ::EndPointBlank::Rack::Headers.extract
28
28
 
29
- payload = {
29
+ {
30
30
  app_name: EndPointBlank::Configuration.instance.app_name,
31
31
  env: SessionConfiguration.env_name,
32
32
  uuid: request_uuid(env),
33
- host: request.host,
34
33
  status: request_status(request),
35
34
  headers: headers,
36
35
  path: request.path,
@@ -38,7 +37,12 @@ module EndPointBlank
38
37
  endpoint_version: version,
39
38
  request: request_body(request),
40
39
  sent_at: Time.now.utc.iso8601(3)
41
- }
40
+ }.merge(
41
+ ::EndPointBlank::BaseUrl.from_rack_env(
42
+ env,
43
+ trust_proxy_headers: EndPointBlank::Configuration.instance.trust_proxy_headers
44
+ )
45
+ )
42
46
  end
43
47
 
44
48
  def write()
@@ -7,12 +7,14 @@ require_relative "end_point_blank/access_tokens"
7
7
  require_relative "end_point_blank/authorization"
8
8
  require_relative "end_point_blank/version"
9
9
  require_relative "end_point_blank/configuration"
10
+ require_relative "end_point_blank/base_url"
10
11
  require_relative "end_point_blank/session_configuration"
11
12
  require_relative "end_point_blank/log_entry"
12
13
  require_relative "end_point_blank/string_truncator"
13
14
  require_relative "end_point_blank/fast_json_truncator"
14
15
  require_relative "end_point_blank/xml_truncator"
15
16
  require_relative "end_point_blank/masking"
17
+ require_relative "end_point_blank/deprecation_headers"
16
18
  require_relative "end_point_blank/writers/shared"
17
19
  require_relative "end_point_blank/writers/delayed_writer"
18
20
  require_relative "end_point_blank/writers/direct_writer"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: end_point_blank
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert A. Lasch
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-07-10 00:00:00.000000000 Z
10
+ date: 2026-08-14 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: excon
@@ -29,28 +29,28 @@ dependencies:
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '0'
32
+ version: 3.2.6
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: '0'
39
+ version: 3.2.6
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: rexml
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: '0'
46
+ version: 3.3.9
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
- version: '0'
53
+ version: 3.3.9
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: base64
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -91,17 +91,20 @@ files:
91
91
  - ".rspec"
92
92
  - ".rubocop.yml"
93
93
  - ".ruby-version"
94
+ - CHANGELOG.md
94
95
  - HARDENING_REPORT.md
95
96
  - LICENSE
96
97
  - README.md
97
98
  - Rakefile
98
99
  - build.sh
100
+ - deploy
99
101
  - docs/superpowers/plans/2026-07-08-framework-agnostic-core.md
100
102
  - docs/superpowers/specs/2026-07-08-framework-agnostic-core-design.md
101
103
  - end_point_blank.gemspec
102
104
  - lib/end_point_blank.rb
103
105
  - lib/end_point_blank/access_tokens.rb
104
106
  - lib/end_point_blank/authorization.rb
107
+ - lib/end_point_blank/base_url.rb
105
108
  - lib/end_point_blank/commands/authentication_cache.rb
106
109
  - lib/end_point_blank/commands/basic_authenticate.rb
107
110
  - lib/end_point_blank/commands/bearer_generate.rb
@@ -112,6 +115,7 @@ files:
112
115
  - lib/end_point_blank/commands/route_pattern_finder.rb
113
116
  - lib/end_point_blank/commands/version_finder.rb
114
117
  - lib/end_point_blank/configuration.rb
118
+ - lib/end_point_blank/deprecation_headers.rb
115
119
  - lib/end_point_blank/fast_json_truncator.rb
116
120
  - lib/end_point_blank/log_entry.rb
117
121
  - lib/end_point_blank/loggers/logger.rb