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