end_point_blank 0.6.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +621 -0
  3. data/README.md +236 -19
  4. data/end_point_blank.gemspec +5 -3
  5. data/lib/end_point_blank/access_tokens.rb +244 -25
  6. data/lib/end_point_blank/authorization.rb +105 -20
  7. data/lib/end_point_blank/commands/authentication_cache.rb +141 -19
  8. data/lib/end_point_blank/commands/basic_authenticate.rb +66 -2
  9. data/lib/end_point_blank/commands/bearer_generate.rb +36 -0
  10. data/lib/end_point_blank/commands/endpoint_authorize.rb +46 -1
  11. data/lib/end_point_blank/commands/endpoint_update.rb +2 -2
  12. data/lib/end_point_blank/commands/generate_access_token.rb +241 -8
  13. data/lib/end_point_blank/commands/http.rb +20 -1
  14. data/lib/end_point_blank/configuration.rb +111 -4
  15. data/lib/end_point_blank/configuration_error.rb +18 -0
  16. data/lib/end_point_blank/rails/authenticated.rb +62 -7
  17. data/lib/end_point_blank/rails/authorized.rb +9 -13
  18. data/lib/end_point_blank/target_url.rb +57 -0
  19. data/lib/end_point_blank/token_unavailable_error.rb +102 -0
  20. data/lib/end_point_blank/unauthorized_error.rb +81 -1
  21. data/lib/end_point_blank/version.rb +1 -1
  22. data/lib/end_point_blank/writers/delayed_writer.rb +131 -21
  23. data/lib/end_point_blank/writers/direct_writer.rb +1 -1
  24. data/lib/end_point_blank/writers/exception_writer.rb +11 -2
  25. data/lib/end_point_blank/writers/log_writer.rb +1 -1
  26. data/lib/end_point_blank/writers/request_writer.rb +1 -0
  27. data/lib/end_point_blank/writers/response_writer.rb +1 -0
  28. data/lib/end_point_blank/writers/shared.rb +35 -4
  29. data/lib/end_point_blank.rb +248 -3
  30. metadata +15 -10
  31. data/lib/end_point_blank/loggers/logger.rb +0 -30
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: edb9e7eb50879a3caf5cec7ae59210d29a3096e82d68c997dd4ae545cac00e2d
4
- data.tar.gz: 4bd84f05bf328b387fdb6213194c4920c0282cb6eaaee0ad1eb20610785d53bb
3
+ metadata.gz: 97bfbad03254a86968687a6f11f10c566b6357bf1294c82521fe8216261586eb
4
+ data.tar.gz: 28512438acaea4072288edd364cf246f307abf64cbb2e64eb6decd66f8295b0b
5
5
  SHA512:
6
- metadata.gz: b958fd1b769033e6fe4420e371b568ec449fb55accac06c7ebb82a2a1ac0b2415770117c4d61b9746af87ef8d27e97c7e0888dbb3bfc55cba605f10c5d57d91d
7
- data.tar.gz: 3ef96dd88833a733d9f4dd30592618df157ef6af60483c50aad82e2306c60ffb78348c9a7256a52102d34647b148ab8d58109b25088cf6480cd075b054d46d72
6
+ metadata.gz: cdb4c6de628daa762e7c17925d96b51dd4cc26379531de385b61cfcb44fbf8c87c6a87f14b3c104fe4c1f5bd5ffdc953ee16f8ff36a61c0d4a90dfe9c2701ba7
7
+ data.tar.gz: 9fd76a38f763fd134758d9b4c89792bd983f707821008dfc9cf4f341fd8a35f53d9455c93c965d20a9649667b076f77f0359d52bc96425966cc4c4770708250d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,626 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Breaking changes
6
+
7
+ - **Outbound calls never fall back to `Basic` any more (sc-1469).**
8
+ `EndPointBlank::Authorization.header(base_url)` used to answer
9
+ `"Basic base64(client_id:client_secret)"` whenever it could not obtain a
10
+ token -- a failed mint (401, 400/422, 5xx, a response with no `base_url`),
11
+ an intake timeout or outage -- which sent this service's own credential to
12
+ the provider it was calling. A client must never do that. It now answers
13
+ only `"Bearer <token>"`, and raises the new
14
+ `EndPointBlank::TokenUnavailableError` (a subclass of `EndPointBlank::Error`)
15
+ when no token can be had. The error carries `base_url` (stripped, see
16
+ below), `failure` (the `AccessTokens::Failure` for the mint), `outcome`
17
+ and `status`, and its message reads `Could not mint an EndPointBlank access
18
+ token for <url>: <reason>. EndPointBlank never sends this service's
19
+ client_id/client_secret to a provider, so there is no Basic-auth fallback
20
+ and the call must not be made without a token.` `<reason>` is one fixed
21
+ text per outcome, the same in every EndPointBlank SDK (for example
22
+ `intake refused the token request (HTTP 422); check the URL and that a
23
+ grant covers the target`); intake's response body and exception messages
24
+ never appear in it. The body is still on `failure.reason`. A mint that
25
+ raises anything other than `ConfigurationError` is reported as this error
26
+ too, with outcome `:transport_error`, `unexpected?` true, the text
27
+ `the token request failed unexpectedly`, and the exception as `cause`,
28
+ rather than escaping `header` as whatever it was.
29
+
30
+ Callers that relied on the fallback must now rescue
31
+ `TokenUnavailableError` and decide for themselves: retry, degrade, or fail
32
+ their own request.
33
+
34
+ - **The URL's userinfo, query and fragment are removed before the token
35
+ request (sc-1469).** They are never sent to intake, logged, or kept on the
36
+ error: `Authorization.header` and the public `AccessTokens` entry points
37
+ (`token`, `token_result`, `exists?`, `last_failure`) strip the URL to
38
+ scheme, host, port and path first, and the cache, the failure record and
39
+ every log line use that form. `TokenUnavailableError#base_url` is the
40
+ stripped URL, not the one passed in. intake refuses a `base_url` carrying
41
+ any of them, so a URL that used to fail its mint with a 422 now mints. A
42
+ URL that cannot be parsed into a scheme and host is refused without a
43
+ request: `header` raises `ArgumentError` (without repeating the URL), and
44
+ `AccessTokens.token_result` answers a `:request_rejected` `Failure` with no
45
+ status. So is a URL whose scheme is anything but `http` or `https` (matched
46
+ in any case, and sent lowercased): `ftp://h:21/x` used to come out as
47
+ `ftp://hx`, because Ruby's `URI::FTP` keeps its path without the leading
48
+ slash, and no provider is reached over such a scheme anyway. So, too, is a
49
+ URL whose port is outside 1..65535, which intake refuses: it used to cost a
50
+ request and a recorded failure. The host is lowercased, as intake's
51
+ `BaseUrl` does, so `API.example.test` and `api.example.test` now share one
52
+ cached token and one failure record instead of minting separately.
53
+
54
+ - **`Commands::GenerateAccessToken.token_result` reports only a request that
55
+ never completed as `:transport_error` (sc-1469).** That means an Excon,
56
+ socket, SSL or timeout error. Anything else raised while minting -- a bug,
57
+ such as a `NoMethodError` -- used to be filed under `:transport_error` too,
58
+ which read as "intake could not be reached" and dropped the exception. It
59
+ now propagates, and so `GenerateAccessToken.token`, `AccessTokens.token`
60
+ and `AccessTokens.token_result`, which used to answer `nil` or a
61
+ `:transport_error` `Failure` for it, now raise it; only
62
+ `Authorization.header` wraps it, and reports it as above.
63
+
64
+ - **`Authorization.header` with no argument is removed.** `base_url` is now
65
+ required, and `header(nil)` / `header("")` raise `ArgumentError`. The
66
+ no-argument form returned `Basic` credentials and was only ever right for
67
+ the SDK's own calls to its own intake; those now use the internal
68
+ `Authorization.intake_header`, which behaves exactly as the old no-argument
69
+ `header` did. Do not use it for outbound calls to a provider.
70
+
71
+ Unchanged: authenticate/authorize, token minting, endpoint updates and the
72
+ log/request/response writers still authenticate to intake with `Basic`.
73
+
74
+ - **A missing `client_id` or `client_secret` raises
75
+ `EndPointBlank::ConfigurationError`** (a subclass of `EndPointBlank::Error`)
76
+ when the SDK builds its own Basic header for intake, instead of sending
77
+ `Basic Og==`. This includes token minting, so `Authorization.header` raises
78
+ it too rather than reporting a transport error.
79
+
80
+ ### Added
81
+
82
+ - **`EndPointBlank::AccessTokens.token_result(base_url)`** answers the token
83
+ String or the `AccessTokens::Failure` recorded for that very call, inside
84
+ the cache's lock. `token` followed by `last_failure` could report another
85
+ thread's reason, or none, and `Authorization.header` now uses
86
+ `token_result` so `TokenUnavailableError#failure` always describes its own
87
+ mint. `token` and `last_failure` are unchanged.
88
+ - **The intake hostname can be derived from `client_id` (sc-1463), off by
89
+ default.** New credentials carry their organization's slug as a prefix
90
+ (`acima-x7k2mq.<random>`), and that organization's intake answers at
91
+ `https://<slug>.in.endpointblank.com`. With the new
92
+ `c.derive_base_url_from_client_id = true`, the SDK calls that hostname when
93
+ neither `base_url` nor `ENDPOINTBLANK_BASE_URL` is set. It derives only when
94
+ the part before the first `.` has the exact shape of an organization slug
95
+ and something follows the dot
96
+ (`EndPointBlank::Configuration.client_id_slug`, the same rule as
97
+ app_portal's `Credentials.client_id_slug/1`); any other `client_id`,
98
+ including a legacy `my.client`, calls `https://in.endpointblank.com` as
99
+ before. The option defaults to `false` because `*.in.endpointblank.com` has
100
+ no DNS or TLS in production yet; with it off, every `client_id` resolves
101
+ exactly as in 0.11.1. It will default to `true` in a later release, once DNS
102
+ and TLS are live. A value other than `true` or `false` raises
103
+ `ArgumentError` from the assignment itself, at configure time, and the
104
+ `configure` call applies nothing. The logs hostname (`log_base_url`) is
105
+ never derived.
106
+ - **Every call to intake sends `x-epb-sdk: ruby/<version>` (sc-1463)**, with
107
+ `EndPointBlank::VERSION` as loaded: authorize, token minting, endpoint
108
+ updates and the request/response/log/error writers. intake ignores it
109
+ today; it will record the oldest version seen per credential for the move
110
+ gate. **This release is not that gate's minimum Ruby version:** derivation
111
+ is off by default here, so a host on this version with the default config
112
+ keeps calling `in.endpointblank.com` after its organization moves. The
113
+ minimum is the release that turns `derive_base_url_from_client_id` on by
114
+ default.
115
+
116
+ ### Unchanged
117
+
118
+ - A 503 or a 429 from intake is never cached (sc-1463 conformance, now pinned
119
+ by specs): the authorization cache stores only a 201, and the token cache
120
+ stores only a token, so the next call asks intake again. The token cache is
121
+ still keyed on the `base_url` the mint response returns, and a 2xx without
122
+ one is still a failed mint (`:server_error`, "response carried a token but
123
+ no base_url"). intake sends `base_url` on every successful mint.
124
+
125
+ ### Deprecated
126
+
127
+ - **`EndPointBlank::Commands::BearerGenerate`** (`generate` / `auth_header`)
128
+ now emits a one-time runtime deprecation warning
129
+ (`Kernel#warn(..., category: :deprecated)`, shown when
130
+ `Warning[:deprecated]` is on). Its header carries this service's own client
131
+ secret; use `EndPointBlank::Authorization.header(base_url)` for outbound
132
+ calls. It will be removed in a future release.
133
+
134
+ ## 0.11.1
135
+
136
+ ### Fixed
137
+
138
+ - **`EndPointBlank.configure` is now all-or-nothing.** The sc-970 review
139
+ found that this SDK (along with Java) applied a `configure` block's
140
+ assignments to the live `Configuration` singleton as the block executed,
141
+ so a field set *before* a later one failed validation stayed applied even
142
+ though the whole call raised:
143
+
144
+ ```ruby
145
+ EndPointBlank.configure do |c|
146
+ c.app_name = "checkout" # applied immediately
147
+ c.cache_ttl = -1 # raises ArgumentError here
148
+ end
149
+ # app_name was left as "checkout" -- half-updated, with no error saying so
150
+ ```
151
+
152
+ `configure` now yields a detached copy of the configuration, and once the
153
+ block returns normally, writes back only the fields whose value on that
154
+ copy differs from an independent deep copy taken before the block ran --
155
+ so a rejected call leaves the live configuration exactly as it was,
156
+ including a field set for the very first time (e.g. `client_id` on a
157
+ fresh boot, before anything has ever assigned it) and an in-place edit
158
+ anywhere in a String, Array or Hash field (both `masking_rules` and the
159
+ rule Hashes in it, and a String field such as `app_name` edited with
160
+ `<<`, are deep-copied into the block's copy). This holds for *any*
161
+ exception the block raises, not only `StandardError`. Committing only
162
+ what the block changed, rather than every field, also means a write to a
163
+ field the block never touched -- made from inside the block itself (e.g.
164
+ `EndPointBlank.logger =`) or concurrently from another thread outside
165
+ `configure` -- is no longer silently reset back to what it was when the
166
+ copy was made.
167
+
168
+ `configure` calls are also now serialized with a Mutex held across the
169
+ block and the commit, so two calls can no longer overlap. Without that,
170
+ two calls that both succeed could still overlap -- the second starting
171
+ while the first is still running -- and both would build their copy from
172
+ the same starting snapshot. Whichever finished last would still decide
173
+ what to write by comparing its own copy against that same now-stale
174
+ snapshot rather than against whatever was live on `Configuration` by the
175
+ time it actually committed, so it would silently write its own change
176
+ over whatever the other call had already committed. Because Ruby's
177
+ `Mutex` is not reentrant, calling `configure` again from inside a
178
+ `configure` block, on the same thread, now raises `EndPointBlank::Error`
179
+ instead of running -- previously, with no atomicity in place at all,
180
+ nesting worked by accident, since both calls just mutated the live
181
+ singleton directly. A block that starts a different thread, has it call
182
+ `configure`, and then joins it will hang instead of raising, since that
183
+ thread is genuinely waiting on a lock this thread holds.
184
+
185
+ The copy `configure` yields (`c`, by convention) is only valid for the
186
+ duration of the block: once `configure` returns, whether the block
187
+ returned normally or raised, `c` is frozen, and every String, Array or
188
+ Hash value it holds is first replaced with its own frozen deep copy. A
189
+ write made through a reference to `c` retained past the block now always
190
+ raises `FrozenError` -- a reassignment (`saved.app_name = "x"`) because
191
+ `c` itself is frozen, and an in-place edit (`saved.masking_rules << rule`,
192
+ `saved.app_name << "x"`, editing a rule Hash in place) because the value
193
+ it points to is frozen too, not just `c`. A read that bypasses `c` --
194
+ `Configuration.instance.app_name`, or `EndPointBlank.logger` right after
195
+ `c.logger = ...` earlier in the same block -- still sees the value from
196
+ before the call started, not what the block has set on `c` so far, until
197
+ the block returns and the commit runs.
198
+
199
+ Assigning a String, Array or Hash through `c` now copies it, rather than
200
+ storing the object itself: `rules = [...]; EndPointBlank.configure { |c|
201
+ c.masking_rules = rules }; rules << extra` no longer affects the live
202
+ configuration, whereas on 0.11.0 it did, because `c` was the live
203
+ singleton and `masking_rules` held that very same array. Call `configure`
204
+ again to apply a further change. Objects the caller hands in by
205
+ reference that are not String/Array/Hash -- `logger`, `mask_hook`,
206
+ `version_finder` -- are unaffected by any of this: they are held by
207
+ reference, not copied, both during the block and after it returns, so
208
+ mutating one through a retained `c` (`saved.logger.level = ...`) still
209
+ reaches the live value, the same as mutating it through
210
+ `EndPointBlank.logger` or `Configuration.instance.logger` directly would.
211
+
212
+ This is generic over every `Configuration` instance variable, not a
213
+ hand-maintained field list, so a future validated field (e.g. sc-1265's
214
+ planned `cache_ttl` upper bound) is atomic under `configure`
215
+ automatically, with no changes needed here. Objects the caller hands in
216
+ by reference -- `logger`, `mask_hook`, `version_finder` -- are copied by
217
+ reference like any other field, not deep-copied, since they are not
218
+ String/Array/Hash; `configure` cannot roll back mutation the caller
219
+ performs on those objects themselves, and calling `configure` from more
220
+ than one thread only serializes calls to `configure` itself, not reads of
221
+ `Configuration` elsewhere.
222
+
223
+ This is sc-1266. Of the other four EndPointBlank SDKs, only Java (`java#39`)
224
+ had this bug and needed a code fix; JS, Python and Elixir were already
225
+ atomic and got test-only PRs pinning that behavior.
226
+
227
+ ## 0.11.0
228
+
229
+ ### Breaking
230
+
231
+ - **`cache_ttl` is validated when you set it, not when the cache first uses
232
+ it.** `c.cache_ttl =` now accepts only a non-negative `Integer` number of
233
+ seconds. Anything else raises `ArgumentError`, naming `cache_ttl` and the
234
+ value, from the assignment itself -- inside `EndPointBlank.configure`, so a
235
+ bad value stops the app at boot -- and leaves the previous value in place.
236
+ This is the rule sc-970 sets for every EndPointBlank SDK:
237
+
238
+ | `cache_ttl` | 0.10.0 | Now |
239
+ |---|---|---|
240
+ | never set | 300 seconds | 300 seconds (unchanged) |
241
+ | `0` | accepted: cache disabled | accepted: cache disabled (unchanged) |
242
+ | `nil` | accepted; `TypeError: can't convert NilClass into an exact number` on the first cache store | `ArgumentError` at configure time |
243
+ | negative, e.g. `-5` | accepted, silently behaving like `0` | `ArgumentError` at configure time |
244
+ | a `String`, e.g. `"abc"` | accepted; `TypeError: can't convert String into an exact number` on the first cache store | `ArgumentError` at configure time |
245
+ | a non-`Integer` number, e.g. `3.5` | accepted, silently used as a 3.5-second TTL | `ArgumentError` at configure time |
246
+
247
+ **If you disabled the cache with a negative value such as `-1`, change it
248
+ to `0`.** To get the default, omit the setting: no value assigned to
249
+ `cache_ttl` means "use the default".
250
+
251
+ ### Fixed
252
+
253
+ - **Runtime `cache_ttl` changes now apply to already-cached entries.**
254
+ `AuthenticationCache` used to fix an entry's expiry once, at the moment it
255
+ was stored; changing `cache_ttl` afterwards (typically to lower it during
256
+ an incident, hoping to make a revocation take effect sooner) did nothing to
257
+ entries already in the cache -- they kept answering until their *original*
258
+ expiry, on the *old* TTL. Setting `cache_ttl` to a disabling value (`<= 0`)
259
+ did nothing at all: it neither stopped new reads from being served nor
260
+ removed what was already cached.
261
+
262
+ Each entry now records its write time as well as its expiry, and every
263
+ read re-derives validity against the `cache_ttl` configured *at read time*,
264
+ anchored to that write time: an entry HITs only while it is within both its
265
+ original expiry (so raising `cache_ttl` never extends an entry already
266
+ cached) and the current `cache_ttl` measured from when it was written (so
267
+ lowering `cache_ttl` shortens an already-cached entry's life immediately,
268
+ on its next read).
269
+
270
+ A disabled `cache_ttl` now MISSes, and the moment a read *or a store*
271
+ observes the cache disabled, the **entire cache is cleared** -- not just
272
+ the entry being looked up or written. An earlier version of this fix
273
+ deleted only the single key involved, which let a *different*,
274
+ already-revoked key go on answering after `cache_ttl` was raised again.
275
+ This matches the Elixir SDK's existing `AuthCache.clear/0` behavior on its
276
+ disabled get/put path. A store made while the cache is disabled still
277
+ inserts nothing itself, on top of clearing what was already there.
278
+
279
+ **This cache is per Ruby process** -- a plain in-memory Hash, nothing
280
+ shared like `Rails.cache` or Redis -- so each Puma or Unicorn worker, and
281
+ every separate app instance, holds its own cache and its own view of
282
+ `cache_ttl`. "The entire cache is cleared" means *that process's* cache
283
+ only: disabling `cache_ttl` does not by itself reach any other worker or
284
+ instance, and each one clears its own cache only once it has itself
285
+ observed `cache_ttl` disabled and then handled an `Authorized` request
286
+ (or a direct cache call) while disabled. A worker sitting idle, or one
287
+ that hasn't yet picked up the new config, keeps answering from whatever
288
+ it already cached until it does. Known residual, left unaddressed here
289
+ rather than fixed: a disable followed by a re-enable with **no cache read
290
+ or store in between** flushes nothing, because nothing ever observed the
291
+ disabled state to trigger the clear; this release does not add
292
+ configure-time flushing.
293
+
294
+ A `nil` `cache_ttl` is never read as "disabled": it is refused when it is
295
+ assigned -- see Breaking, above.
296
+
297
+ ## 0.10.0
298
+
299
+ ### Fixed
300
+
301
+ - **The send queue no longer drops duplicate payloads.** The drain took a batch
302
+ off the front of the pending list and then removed it with `payloads -= list`.
303
+ `Array#-` removes every element *equal to* one in the batch, not the ones
304
+ actually sent: seven byte-identical payloads with a batch size of six meant
305
+ six delivered and all seven gone. Nothing counted the loss, nothing logged it,
306
+ and the queue drained normally, so it read as if nothing had happened.
307
+
308
+ Equal payloads are ordinary rather than exotic. Payloads are hashes, and two
309
+ requests to the same endpoint, from the same application, in the same
310
+ environment differ only in high-cardinality fields; `sent_at` at millisecond
311
+ precision is not a reliable discriminator at ingest volumes. Any genuinely
312
+ duplicate pair lost a member every time it landed in an over-full batch.
313
+
314
+ Batches are now cut by position — `each_slice(BATCH_SIZE)` over the pending
315
+ list, with no removal step at all — which makes equality irrelevant rather
316
+ than merely handled. The batch size (6) is a named constant instead of a
317
+ `[0..5]` in the middle of the loop; the batching behaviour is otherwise
318
+ unchanged.
319
+
320
+ - **The authenticate payload names the caller's IP `source_ip`.** It sent
321
+ `ip_address`; intake reads `source_ip` and casts it into `source_ip_address`.
322
+ intake ignores keys it does not cast, so nothing crashed and nothing was
323
+ refused — `source_ip_address` was simply NULL on every authenticate row from a
324
+ Rails application, and any per-source-IP question about authenticate traffic
325
+ read as though there were no traffic.
326
+
327
+ This was not a porting slip. This gem has always sent `ip_address`, and the
328
+ JS, Python and Java ports copied it faithfully, so all four were wrong
329
+ together until those three were fixed. `EndpointAuthorize`, on the next path
330
+ over, has always sent `source_ip` correctly.
331
+
332
+ Because intake never read the old key, this can only begin populating a column
333
+ that has never held anything: no migration, and nothing to be compatible with.
334
+
335
+ ## 0.9.0
336
+
337
+ ### Fixed
338
+
339
+ - **One unreachable intake no longer ends telemetry for the life of the
340
+ process.** `Commands::Http.post` returns `nil` once its three attempts are
341
+ exhausted; `DirectWriter#write` passed that straight back; and the delayed
342
+ writer's worker loop called `response.status` on it. The resulting
343
+ `NoMethodError` escaped `loop do`, which is the whole body of the worker
344
+ thread, so the thread died — and a dead worker is not a failed delivery, it is
345
+ the end of delivery. Every payload enqueued afterwards sat in the queue until
346
+ it was pushed out by the drop-oldest bound, and nothing said so, because a
347
+ dead thread raises nothing and logs nothing. The trigger was the most ordinary
348
+ event there is: the intake briefly unreachable.
349
+
350
+ Every other telemetry drop in this gem costs one batch. This one cost all of
351
+ them, from one transient failure, with no recovery short of restarting the
352
+ host application.
353
+
354
+ - **A `nil` response is treated as the absence of an answer, not as a status.**
355
+ There is nothing to compare `nil` against, so it is not compared. The batch is
356
+ reported through `on_failure` — with `nil`, meaning *nothing answered* — and
357
+ named in an `error` log line that says how many payloads went with it. A
358
+ writer that implements neither callback is unaffected, as before.
359
+
360
+ ### Changed
361
+
362
+ - **The worker loop now survives anything a delivery can throw.** The `nil`
363
+ above is one defect of a shape that has now been fixed in a writer three
364
+ times, so this release fixes the shape rather than the instance: every
365
+ `StandardError` raised anywhere in an iteration is caught, logged, and
366
+ followed by another iteration. `DirectWriter#write` calls
367
+ `Authorization.header` before it calls the transport, which is a second
368
+ unguarded path into the same loop; it is covered too, without having had to be
369
+ enumerated.
370
+
371
+ Recovering silently would only trade one silent failure for another, so it is
372
+ not silent:
373
+
374
+ - every recovered error is logged at `error` level through
375
+ `EndPointBlank.logger`, with the exception class, its message, the top
376
+ backtrace frame, and a **count of consecutive failures** — so a persistent
377
+ fault reads as `consecutive failure 47`, not as forty-seven
378
+ indistinguishable lines;
379
+ - consecutive failures back off, `WORKER_BACKOFF_SECONDS` (0.1s) doubling to
380
+ `MAX_WORKER_BACKOFF_SECONDS` (30s), so a permanently broken send path is a
381
+ slow loud retry rather than a hot loop with a fan attached. The counter
382
+ resets on the first clean pass.
383
+
384
+ What is deliberately *not* caught is anything outside `StandardError` —
385
+ `SystemExit`, `Interrupt`, `SignalException`, `NoMemoryError`. Those mean the
386
+ process itself is going down or is already broken, and a fire-and-forget
387
+ telemetry worker has no business arguing with that.
388
+
389
+ - **`worker_count` is documented as what it is.** The README described it as
390
+ "currently unused by the delayed writer (which always spins up 2 threads);
391
+ reserved". It has been honoured for some time: the pool is `worker_count`
392
+ threads, and the hardcoded 2 survives only as the fallback used when it is set
393
+ to `nil`.
394
+
395
+ ### Unchanged
396
+
397
+ - No public API is removed or renamed. `on_success`/`on_failure` remain
398
+ optional, and the only change to their contract is that `on_failure` can now
399
+ receive `nil`, in the case where it previously could not be reached at all.
400
+
401
+ ## 0.8.0
402
+
403
+ ### Fixed
404
+
405
+ - **`EndPointBlank::Rails::Authenticated` works.** It could not have worked
406
+ once: it called `EndPointBlank::Commands::EndpointAuthenticate`, a constant
407
+ this gem has never defined, so every action of every controller including the
408
+ concern raised `NameError` from its `before_action` — before authentication
409
+ could succeed or fail. It now calls
410
+ `EndPointBlank::Commands::BasicAuthenticate`, the command that was in the tree
411
+ the whole time and that the JS, Java and Python SDKs each document their own
412
+ authenticate command as a port of.
413
+
414
+ It was implemented rather than removed because three other SDKs expose an
415
+ authenticate path and all three name this gem as the original — Elixir has
416
+ none, so sc-306's "four other SDKs" is three, but it is three of three. A
417
+ missing one in Ruby is a gap, not a decision. No public constant is removed:
418
+ `Commands::EndpointAuthenticate` never resolved, so nothing could have been
419
+ depending on it, and no `Commands::EndpointAuthenticate` is being introduced
420
+ either — that would have been a second command for a job this gem already had
421
+ a command for, with `BasicAuthenticate` left dead beside it.
422
+
423
+ - **`Commands::BasicAuthenticate` works.** It built its `Authorization` header
424
+ from `AuthorizationGenerate.generate` — a second constant this gem has never
425
+ defined — so repairing only the concern's constant would have moved the same
426
+ `NameError` one frame deeper. It now uses `Authorization.header`, as
427
+ `EndpointAuthorize` and all three ports of this command do. Neither defect was
428
+ reachable by any test or any application, which is how both survived a
429
+ coverage pass: a file no spec requires and no application includes is
430
+ invisible to a coverage number.
431
+
432
+ - **A nil answer from intake reaches the branch written for it.**
433
+ `authenticate!` parsed the response body on the line *above* its own
434
+ `if !result` guard, so a nil result died with `NoMethodError` on `nil.body`
435
+ and the nil branch could never run. Fixed in the same pass rather than left to
436
+ be uncovered by fixing the constant.
437
+
438
+ - **A refusal from `Authenticated` now says which refusal it was.** The concern
439
+ used `raise UnauthorizedError, "message"` — the two-argument
440
+ `raise Class, message` form, which calls `Class.exception(message)` and can
441
+ pass nothing else, so it structurally could not carry intake's status however
442
+ willing `UnauthorizedError` was to accept one. Every refusal would therefore
443
+ have arrived as the class's 401 default, including the 403 that means
444
+ `access_denied`. `Authorized` has always passed intake's status through, so
445
+ the same denial gave a caller two different answers depending on which concern
446
+ the controller included.
447
+
448
+ 401 and 403 send an integrator to two different places: *re-check the
449
+ credential* versus *ask for a grant covering this endpoint*. Collapsing them
450
+ sends half of them to debug the wrong thing.
451
+
452
+ | intake answered | `error.status` | what it tells the integrator |
453
+ | --- | --- | --- |
454
+ | 401 | `401` | the credential was not accepted — re-check or re-issue it |
455
+ | 403 | `403` | the credential is fine; no grant covers this endpoint |
456
+ | any other non-201 | that status | intake's own verdict, verbatim |
457
+ | nothing at all | `503` | the check could not be made; nothing judged this caller |
458
+
459
+ The README's own suggested handler — `status: e.status` — was written as
460
+ though this already worked, and on the `Authorized` path it did. It now works
461
+ on both.
462
+
463
+ ### Changed
464
+
465
+ - **An intake 5xx now reaches a caller of an `Authenticated` controller as that
466
+ 5xx**, where it would previously have reached them as a 401. An outage in
467
+ intake presents as an outage rather than as a rejected credential, so a client
468
+ branching on `401` to trigger a re-login will no longer do so for a fault that
469
+ has nothing to do with its credential. The same is true of an unreachable
470
+ intake, which is now a 503 — this is what `Authorized` has always answered for
471
+ that case, and what all four other SDKs answer.
472
+
473
+ In practice no deployment can have observed the old behaviour, because the
474
+ concern raised `NameError` before reaching any of it. It is called out anyway
475
+ because the other three SDKs called out exactly this change for exactly this
476
+ reason, and because anyone reading their changelogs should find the Ruby entry
477
+ saying the same thing.
478
+
479
+ - The two concerns' refusal handling — two transcriptions of one decision, which
480
+ had drifted — is now one method,
481
+ `EndPointBlank::UnauthorizedError.refusal_from(result, action)`. Two copies is
482
+ how one path acquires a fix the other does not, which is precisely what
483
+ happened here. `Authorized` behaves exactly as before: its refusal path moved
484
+ into the shared method without changing the message or the status it produces
485
+ for any input, including the unreachable case, whose message has never carried
486
+ a `"Authorization failed:"` prefix and still does not.
487
+ - `Rails::Authorized#authorize_error_message`, a private method, is gone; its
488
+ body is now the shared `refusal_from`.
489
+
490
+ ### Unchanged
491
+
492
+ - `UnauthorizedError.new(message)` still means what it meant: the status
493
+ defaults to 401, and the status remains the optional second argument. The
494
+ class itself is otherwise untouched — it always accepted a status, which is
495
+ why nothing ever complained that `authenticate!` was not passing one.
496
+ - `Authenticated` deliberately does **not** cache intake's answer, matching every
497
+ other SDK's authenticate command. `Authorized` caches as before.
498
+
499
+ ## 0.7.0
500
+
501
+ ### Added
502
+
503
+ - **You can now tell a rejected credential from a server that is merely
504
+ having a bad day.** intake answers `401` when the API credential itself is
505
+ refused and something else for everything else, but the SDK threw the status
506
+ away: `Commands::GenerateAccessToken.token` returned the parsed body no
507
+ matter what came back, and `AccessTokens#token` turned every failure into
508
+ the same `nil` and the same `"Failed to generate access token"` log line. A
509
+ revoked credential and a transient 503 were indistinguishable, so nothing
510
+ could decide whether to retry or to stop and ask a human.
511
+
512
+ Two additive entry points:
513
+
514
+ - `Commands::GenerateAccessToken.token_result(base_url)` returns an
515
+ immutable `Commands::AccessTokenResult` carrying `outcome`, `status` and
516
+ `payload`, with `#success?`, `#credential_rejected?`, `#request_rejected?`,
517
+ `#server_error?`, `#transport_error?` and `#failure?`.
518
+ - `EndPointBlank::AccessTokens.last_failure(base_url)` (and the instance
519
+ method) returns an `AccessTokens::Failure` — `base_url`, `outcome`,
520
+ `status`, `reason`, `at`, and the same predicates — describing why the
521
+ last mint for that URL failed, or `nil` when the last one succeeded.
522
+
523
+ ```ruby
524
+ result = EndPointBlank::Commands::GenerateAccessToken.token_result("https://api.example.com/orders")
525
+ result.credential_rejected? # => true; re-issue the credential, retrying will not help
526
+ result.status # => 401
527
+
528
+ EndPointBlank::AccessTokens.token("https://api.example.com/orders") # => nil, as before
529
+ EndPointBlank::AccessTokens.last_failure("https://api.example.com/orders").status # => 401
530
+ ```
531
+
532
+ The five outcomes are `:success` (a token really was minted: a 2xx whose
533
+ body parsed and carries a non-empty `token` and the non-empty `base_url` to
534
+ cache it under), `:credential_rejected` (401), `:request_rejected` (any
535
+ other 4xx), `:server_error` (5xx, any other unexpected non-2xx, and a 2xx
536
+ that carried nothing usable) and `:transport_error` (no usable HTTP status
537
+ was obtained at all).
538
+
539
+ `#success?` is worth reading precisely: it is true only when there is a
540
+ token on the payload to read, so a caller that branches on it never has to
541
+ check for one again. A success predicate that can be true while the token is
542
+ absent makes every caller re-check the payload by hand, and that is the
543
+ check that gets forgotten.
544
+
545
+ There is deliberately **no** `#retriable?` or equivalent retry/no-retry
546
+ boolean. intake answers `400` for an invalid `token_ttl` or a missing
547
+ `base_url` and `422` for an unresolvable target/source application; those
548
+ are as permanent as a 401, with a different remedy, and folding five honest
549
+ names back into one boolean is a smaller version of the bug this release
550
+ fixes. Branch on the outcome you can see.
551
+
552
+ - **Classification is on the HTTP status first, the body second.** A `401`
553
+ whose body will not parse is still `:credential_rejected` — the SDK reaches
554
+ intake through a proxy, and a WAF or load balancer can answer 401 with an
555
+ HTML page intake never generated. `:transport_error` means one thing only:
556
+ no usable HTTP status was obtained.
557
+
558
+ - A rejected credential now gets its own loud log line naming the remedy,
559
+ instead of scrolling past as the same generic failure as a network blip.
560
+
561
+ ### Changed
562
+
563
+ - A response that carries a token and a `base_url` is cached only when it
564
+ arrived with a 2xx status. Previously any status would do, so a 4xx that
565
+ echoed a token back would have been cached. A 2xx that carried no token, or
566
+ a token with no `base_url` to key it under, is now reported as a
567
+ `:server_error` with its real 2xx status attached; the specific reason
568
+ ("no token in response", "response carried a token but no base_url") still
569
+ appears in the log line, exactly as before.
570
+ - An **empty** `token` or `base_url` counts as a missing one. `""` is truthy
571
+ in Ruby, so a plain presence check called such a response a success and
572
+ handed the caller an empty bearer token to send, or cached a token under a
573
+ key no lookup could ever match. Both are now `:server_error` with the real
574
+ 2xx status, and the value must be a non-empty String — an array or an object
575
+ where a token belongs is a broken server too.
576
+ - The parsed body is still attached to the result when an unusable 2xx is
577
+ classified `:server_error`, so `Commands::GenerateAccessToken.token` — the
578
+ published payload-or-`nil` accessor — hands back exactly the body it always
579
+ did for such a response.
580
+ - A response body that will not parse is logged as an error rather than
581
+ silently becoming `nil`. It is still classified by the status that carried
582
+ it, never as a transport error: unreadable under a 2xx is a `:server_error`,
583
+ unreadable under a 401 is still `:credential_rejected`.
584
+ - `AccessTokens#clear` also drops the recorded failures.
585
+
586
+ ### Changed
587
+
588
+ - **`Commands::GenerateAccessToken.token` now returns `nil` unless a token was
589
+ actually minted.** It previously returned the symbol-keyed body of any status
590
+ it could read — an `error` document from a 401 or 422, or a 2xx that parsed
591
+ into something with no usable token in it. Each of those handed the caller a
592
+ truthy value for a request that produced no token, which is the failure
593
+ `token_result` was added to remove, one layer down.
594
+
595
+ This aligns all five SDKs with Elixir, whose equivalent has always answered
596
+ nil for anything that was not a mint.
597
+
598
+ **Upgrade note:** nothing in this gem calls `token` — `AccessTokens` reads
599
+ `token_result(base_url).payload` — so no log line or diagnostic changes. A
600
+ caller that read an error out of the return value should call `token_result`
601
+ instead: `.payload` is exactly what `token` used to hand back, now alongside
602
+ the outcome that explains it. A caller that only ever read `[:token]` needs
603
+ no change, because a body without a usable token was never something it
604
+ could act on.
605
+
606
+ ### Compatibility
607
+
608
+ - `AccessTokens#token` still returns a token String or `nil`, and `#exists?`
609
+ still returns a Boolean.
610
+
611
+ ## 0.6.1
612
+
613
+ ### Fixed
614
+
615
+ - **Diagnostics now go to stderr, not stdout.** `EndPointBlank.logger` defaulted
616
+ to `Logger.new($stdout)`. This gem runs inside your process, so everything it
617
+ logged landed in your application's own output — which corrupts any program
618
+ whose stdout carries structured data, such as a CLI emitting JSON or a worker
619
+ writing a protocol stream, with no way for you to separate the two.
620
+
621
+ If you were relying on SDK log lines appearing on stdout, they now appear on
622
+ stderr. `EndPointBlank.logger=` still overrides, unchanged.
623
+
3
624
  ## 0.6.0
4
625
 
5
626
  ### Breaking