concerns_on_rails 1.28.6 → 1.28.8

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: d01a41891ae267bab705b85ab63d6555e7057719a218738bd82c858c63febfa9
4
- data.tar.gz: 3bcaebd299e65af41029baebb90ba1670a3502fde222071bffeeef86a4e1fb7b
3
+ metadata.gz: 3ae4fb29ea12389fc8aaff341a565a482bf3c8ef99fdcfc7791c64c7302d1ca5
4
+ data.tar.gz: 02ea08bceb25e2a479e27ef00342c69cfeac2ffc8129b689269f423819c0c0ae
5
5
  SHA512:
6
- metadata.gz: 602d50edd8fd84224c73a6ddc09e756bf322f4b3b4e526d34e55774caa88594c54c3ba05d1920005c2152986035b230e2f982e5b6916c2a3a2ec402e1ac2c3c6
7
- data.tar.gz: 9ab3f9c635ab132b284d673861c3a3a8282f93aa370f6f75cb1890a1205b9e3c924705b52afd7d182a94fb5247fdc38f51e94bcc4d2a0b1c8de39ea53ee03b54
6
+ metadata.gz: 4ac2edd1717ef0e4c157b851e75554ba057321e1ae9f283bcdb015e9e6d49d63f1f4bf9bc9ee4419e0b28a111487147516957a03b17ae3915c7ac23f8a721109
7
+ data.tar.gz: 826dd0234e22faa23b089fbedd7900c8bc847f8f7af5670c6a6b0dafc22ec838b29e0cf196b0269b8dde798302d43c677eb4c93d60396db207d6e733d0f16ff2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,264 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.28.8 (2026-09-19)
4
+
5
+ The last six open feature PRs, released as a patch by request. These had never been
6
+ reviewed — they were excluded from the previous two waves because they would not merge —
7
+ so each was rebased onto master, reviewed adversarially, and fixed. **Five of the six
8
+ carried a CRITICAL defect**, two of which could destroy data. The `### Fixed` section is
9
+ the important one; several entries describe behaviour that never worked as documented.
10
+
11
+ Also fixes a live defect in `Support::ErrorEnvelope` found during the review, which
12
+ affected eight concerns on 1.28.7 and earlier. 1828 examples, 0 failures.
13
+
14
+ ### Added
15
+ - **Models::Encryptable**: key rotation. `ConcernsOnRails.configure_encryption { |c|
16
+ c.key_id = 1; c.previous_keys = { 0 => old } }` — new writes stamp `key_id`, reads
17
+ decrypt with whichever configured key the envelope names (unknown id → `DecryptionError`
18
+ naming it), and blind-index lookups match current + previous digests.
19
+ `Model.needs_reencryption(*fields)`, `Model.reencrypt_all!(*fields)` /
20
+ `record.reencrypt!`, and `<field>_key_id`. Per-field `key:` fields sit outside rotation.
21
+ The envelope format is unchanged: 1.28.x already wrote and authenticated a `key_id` byte,
22
+ so every existing row is a key-id-0 envelope and decrypts untouched after upgrading. (#77)
23
+ - **Models::Lockable**: `lockable_by … unlock_token:` mints a URL-safe token in the same
24
+ write as the lock, for self-service unlock links without Devise.
25
+ `User.unlock_by_token(token)` consumes it once — constant-time compare, one conditional
26
+ UPDATE decides the winner, hooks fire — and the token lives exactly as long as the lock.
27
+ Cleared on every unlock path, reset by Duplicable, and registered with the
28
+ filter_parameters registry. (#61)
29
+ - **Models::Stateable**: `stateable_by … timestamps: true` (or a list of states) stamps
30
+ `<state>_at` in the same write as the state change, for guarded events, direct setters,
31
+ `transition_to!` and `transition_all`. Per-event `before_<event>` / `after_<event>` hooks
32
+ fire inside the generic `before_transition` / `after_transition` pair and the same
33
+ transaction, and may be declared `private`. (#49)
34
+ - **Controllers::Filterable**: `filter_by … operators: true` (or a subset) adds `not`,
35
+ `gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `null`, `contains` and `starts_with` to
36
+ direct-where filters, read as a suffix (`?price_gte=10`) or in bracket form
37
+ (`?price[gte]=10`). Every operator comes from a frozen allow-list mapped to a fixed Arel
38
+ node, so no param string reaches SQL. Values are cast through the column's type or the
39
+ new `type:` option, which also pre-casts what a `with:` lambda receives. Opt-in per
40
+ filter; existing filters are unchanged. (#46)
41
+ - **Controllers::WebhookVerifiable**: `verify_webhook … replay:` / `replay_ttl:` rejects a
42
+ redelivery with 409 `webhook_replayed` — replay protection for the schemes that carry no
43
+ timestamp (GitHub, Shopify, plain HMAC). The check runs only after the signature
44
+ verifies. A short claim is taken before the action and promoted on completion, and
45
+ released on a 5xx so a transient failure does not burn the delivery id. (#60)
46
+ - **Controllers::Respondable**: `render_success` accepts `location:` and `headers:`, and
47
+ gains `render_created(data:, location:)` (201 + `Location`) and `render_invalid(record)`
48
+ (422 + the record's `full_messages`). Header names and values are stripped of bytes a
49
+ header line cannot carry, and a `Link` value is appended to an existing one — matched
50
+ case-insensitively — rather than replacing it. Works in both the envelope and the
51
+ problem-details format. (#105, replacing #74)
52
+
53
+ ### Changed
54
+ - **Controllers::Filterable**: an uncastable comparison value (`?price_gte=abc`) returns
55
+ `none` rather than matching. Fail-closed was chosen because ignoring the filter would
56
+ return the very rows the client tried to exclude; it matches what plain `where` already
57
+ does with the same input, and is scoped to the four new comparison operators so no
58
+ pre-existing filter changes on upgrade. `contains` / `starts_with` are case-INsensitive
59
+ on every adapter — there is no `case_sensitive:` option. (#46)
60
+ - **Models::Stateable**: `timestamps:` refuses to derive a stamp column from a state named
61
+ `created` or `updated`, which would otherwise target Rails' own `created_at`/`updated_at`.
62
+ Stamp columns are NOT affixed by `prefix:`/`suffix:`, so a state named `published` or
63
+ `deleted` collides with Publishable's and SoftDeletable's columns — rename the state or
64
+ leave it out of `timestamps:`. (#49)
65
+ - **Models::Encryptable**: `record.reencrypt!` rewrites through a guarded `UPDATE` that
66
+ matches the ciphertext read at load, then reloads. A row written by someone else in the
67
+ meantime is left alone instead of being reverted, and a field with unsaved changes is
68
+ skipped rather than silently committed. (#77)
69
+
70
+ ### Fixed
71
+ - **Support::ErrorEnvelope**: the `errors:` keyword was passed to a host app's
72
+ `render_error` whenever details were present, but several concerns document the override
73
+ contract as `render_error(message:, status:, code:)`. Such an app got `ArgumentError:
74
+ unknown keyword: :errors` at request time — a 500 instead of the 4xx, on exactly the path
75
+ that has something to report. The previous guard tested `details` alone, so it covered
76
+ only the empty case, i.e. the one that was never broken. This reached every concern that
77
+ funnels through the envelope, most commonly ErrorHandleable's rescued `RecordInvalid`.
78
+ - **Models::Encryptable**: `previous_keys=` printed the offending hash's keys on a bad
79
+ shape, so an inverted `{ ENV["KEY_V1"] => 0 }` raised **with the live key in the exception
80
+ message** — into logs, backtraces and error trackers. Only the value's class is reported
81
+ now. (#77)
82
+ - **Models::Encryptable**: `needs_reencryption` detected MySQL by adapter name, so Trilogy
83
+ (Rails 7.1+) and MariaDB fell through to a case-folding comparison and returned **zero
84
+ rows**. `reencrypt_all!` then reported 0, an operator would conclude the rotation was
85
+ complete and drop the old key, and every pre-rotation row would become permanently
86
+ undecryptable. (#77)
87
+ - **Models::Lockable**: a `before_unlock` / `after_unlock` hook that vetoed the unlock
88
+ permanently burned the token while leaving the account locked — the claim ran in a bare
89
+ transaction that joined the enclosing one, so `unlock_access!`'s savepoint rolled back
90
+ while the claim committed. The user's emailed link was dead with no way back. The claim
91
+ now takes its own savepoint and is rolled back with the unlock. (#61)
92
+ - **Models::Lockable**: `unlock_by_token` no longer honours a token once the lock has
93
+ lapsed, and no longer uses `unscoped` — a mailed link could otherwise reach a row hidden
94
+ behind a `default_scope` (a soft-deleted or deactivated account). (#61)
95
+ - **Controllers::WebhookVerifiable**: a store answering `#write` but not `#read` passed
96
+ declaration-time validation and then provided **no replay protection at all**, silently —
97
+ the endpoint looked protected and accepted every redelivery. Both methods are now
98
+ required. (#60)
99
+ - **Controllers::WebhookVerifiable**: the Stripe replay key hashed the raw
100
+ `Stripe-Signature` header, which is *parsed* rather than compared — appending an unknown
101
+ `v0=…` pair or re-spacing the commas produced a still-valid header with a fresh key,
102
+ defeating the protection. Stripe now keys off the signed `"<timestamp>.<body>"` payload.
103
+ (#60)
104
+ - **Controllers::WebhookVerifiable**: `replay: false` raised at class load, so
105
+ `replay: Rails.env.production?` broke controller loading in development and test. It is
106
+ now a synonym for "off". (#60)
107
+ - **Controllers::Respondable**: caller-supplied header **names** reached the response
108
+ unsanitized, so a CR/LF in an interpolated name was response splitting. Names now go
109
+ through the same filter as values, widened from CR/LF to the full illegal-byte range.
110
+ (#105)
111
+ - **Controllers::Filterable**: a bracket-form operator with a blank value
112
+ (`?price[gte]=`) became `price > NULL` and returned **nothing**, while the documented
113
+ equivalent `?price_gte=` correctly returned everything — an empty min/max box silently
114
+ emptied the result set. Both forms now skip blanks, and both honour a boolean `false`.
115
+ (#46)
116
+ - **Controllers::Filterable**: `?stock_gt=twelve` silently became `stock > 0` and returned
117
+ the stocked rows (`ActiveModel::Type::Integer#cast("twelve")` is `0`, not `nil`), while
118
+ the same typo on a datetime column returned none — one malformed request answered two
119
+ opposite ways. (#46)
120
+ - **Controllers::Filterable**: LIKE escaping no longer relies on `sanitize_sql_like`, which
121
+ is not public before Rails 5.1 while the gemspec claims `>= 5.0`. (#46)
122
+
123
+ ### Internal
124
+ - Docs corrected: "Never `update_column(s)` an encrypted field — those bypass the type and
125
+ write raw plaintext" was **false** (`update_columns` serializes through the attribute
126
+ type), and `reencrypt_all!` is built on it. (#77)
127
+
128
+ ## 1.28.7 (2026-09-19)
129
+
130
+ The eight PRs held back from 1.28.6, released as a patch by request. Each carried a
131
+ CRITICAL or a design-level defect found in review; each now carries the fix, and in
132
+ most cases a spec that was verified to fail against the unfixed code. Read the
133
+ `### Fixed` section: several of these defects were live in the PRs' own green CI,
134
+ and two of them are security-shaped.
135
+
136
+ Also in this release: the Rails 8.1 component bumps are unblocked, and `json` is
137
+ pinned below 3 (json 3 removes `JSON.generate(..., quirks_mode:)`, which
138
+ ActiveSupport 7.1 calls, and changes `JSON.parse`'s positional options, which
139
+ ActiveSupport 8.1 uses — with json 3.0.2 the suite fails in Storable's decode path).
140
+ 1730 examples, 0 failures.
141
+
142
+ ### Added
143
+ - **Controllers::Cacheable**: `etag_with` folds request context into the ETag —
144
+ presets `:locale` / `:format` / `:query`, controller-method Symbols, or a block — so
145
+ locale-, fieldset- or role-dependent representations of one resource never share a
146
+ validator; each source adds its implied `Vary` (`vary:` overrides, `vary: false`
147
+ suppresses), merged with the `http_cache_actions` policy. `stale_resource?` /
148
+ `set_cache_validators` gain a per-call `extras:`. (#48)
149
+ - **Controllers::Authorizable**: denials instrument
150
+ `authorization_denied.concerns_on_rails` (controller, action, actor_id, actor_type,
151
+ rule name, status, message) via the `on_authorization_denied(rule)` override point;
152
+ `authorize_by`/`require_role` accept `name:`. `skip_authorization only:/except:`
153
+ exempts actions from every rule, inherited ones included. `authorized?(action)`
154
+ evaluates the rules without rendering, for view predicates. (#68)
155
+ - **Controllers::Timezoneable**: `persist:` writes a param-chosen zone into the
156
+ `cookie:` cookie; `response_header:` emits the resolved zone (`X-Time-Zone` or a
157
+ custom name) and appends `Vary: Time-Zone`; `time_zone_source` reports which source
158
+ won. (#71)
159
+ - **Controllers::Sortable**: `params[:sort]` accepts JSON:API-style `-key` / `+key`
160
+ per-column direction prefixes, and `sortable_by` accepts rule hashes —
161
+ `key: { column: "table.column", joins:, join: :left|:inner, nulls: :first|:last }` —
162
+ for association-column sorting (lazy LEFT OUTER JOIN by default) and NULLs pinned
163
+ first or last (Rails 6.1+). (#63)
164
+ - **Models::Searchable**: `searchable_by ..., ranked: true` orders `search` results by
165
+ relevance — exact, then prefix, then substring, earlier-declared columns first
166
+ within a tier — via a portable CASE expression; the relation's existing ORDER BY
167
+ becomes the tiebreaker. `search(q, ranked:)` overrides per call and `search_rank(q)`
168
+ exposes the score expression. (#64)
169
+ - **Models::Normalizable**: `with:` accepts an Array of presets/callables applied left
170
+ to right, validated at class load. New presets `:strip`, `:capitalize`, `:titleize`,
171
+ `:parameterize`, `:nullify_blank` and `:url`. `Model.normalize(field, value)` applies
172
+ a field's rule to a bare value for lookups and params. (#66)
173
+ - **Models::Sanitizable**: `sanitized_attributes` and a `sanitized:` serialization
174
+ option — `as_json(sanitized: true | [:fields])` — which composes with
175
+ `only:`/`except:`, is carried into `include:` children, and sanitizes the *serialized*
176
+ value so a Maskable mask survives. `Model.sanitize_all!(*fields)` rewrites legacy rows
177
+ in place for the current scope (by default the `on: :write` fields only), transactional
178
+ via `Support::BatchOps`, refreshing Encryptable blind indexes. (#72)
179
+ - **Models::Storable**: `where_<accessor>(value)` scope per key — equality on a stored
180
+ key via `json_extract` (SQLite), `->>` (PostgreSQL) or `JSON_UNQUOTE(JSON_EXTRACT())`
181
+ (MySQL). Values are cast as the writer stores them; `where_<key>(nil)` matches
182
+ unset/null. Opt out per key or per macro with `query: false`. (#76)
183
+ - **Support::VaryHeader**: shared `Vary` appender used by Timezoneable and Localizable —
184
+ seeds Rails' own `Accept` dimension, appends rather than clobbers, de-duplicates
185
+ case-insensitively and leaves `Vary: *` alone. (#71)
186
+
187
+ ### Changed
188
+ - **Controllers::Cacheable**: a response whose ETag varies on a dimension `Vary` cannot
189
+ express — a block or controller-method source, or any source with `vary: false` — is
190
+ now emitted as `Cache-Control: private` regardless of the rule's declared
191
+ `visibility:`. Such a response is not shareable, and there is no `Vary` that makes it
192
+ so. (#48)
193
+ - **Controllers::Sortable**: PostgreSQL uses native `NULLS FIRST/LAST`; every other
194
+ adapter gets the portable `CASE WHEN col IS NULL` equivalent. A `default:` outside the
195
+ allow-list orders the relation without becoming client-selectable, repeated sort keys
196
+ collapse to their first occurrence, and `+` must be percent-encoded as `%2B` (Rack
197
+ decodes a raw `+` to a space). `sort_requests` is the override point; `sort_fields` is
198
+ read-only. (#63)
199
+ - **Controllers::Authorizable**: the denial payload carries `actor_id:`/`actor_type:`
200
+ rather than the `current_user` object — notification payloads are not filtered by
201
+ `config.filter_parameters`. (#68)
202
+ - **Models::Normalizable**: `:url` accepts only `http`/`https`; a value carrying any
203
+ other scheme is returned stripped rather than blessed as normalized. `:titleize` is
204
+ deliberately **not** `String#titleize`. (#66)
205
+ - **Models::Storable**: a `where_<key>` scope whose name is already taken no longer
206
+ aborts the declaration — it is skipped with a deprecator warning, so an existing model
207
+ defining that method still boots after an upgrade. (#76)
208
+ - **Models::Searchable**: a grouped relation is returned unranked, since a rank
209
+ `ORDER BY` over `GROUP BY` is an error on PostgreSQL and on MySQL under
210
+ `ONLY_FULL_GROUP_BY`. (#64)
211
+
212
+ ### Fixed
213
+ - **Controllers::Authorizable**: `skip_authorization except: []` (or `false`, or `""`)
214
+ exempted **every** action of the controller and all its subclasses — each of those
215
+ values is truthy while matching no real action name, so the `!except.include?(action)`
216
+ test was true everywhere. `except: Rails.env.production? && :destroy` is the realistic
217
+ spelling. Now rejected at class load, along with non-Symbol/String entries; `only:`
218
+ still accepts them, where they are inert. (#68)
219
+ - **Models::Sanitizable**: `serializable_hash` re-read the raw column instead of
220
+ post-processing the serialized value, so on a model including both Maskable and
221
+ Sanitizable it overwrote the mask with sanitized plaintext — order-dependently, and
222
+ therefore silently. (#72)
223
+ - **Controllers::Timezoneable**: the `cookie:` source now works on a real
224
+ `ActionController::Base`. `#cookies` is PRIVATE there, so the `respond_to?(:cookies)`
225
+ guard was always false and the documented cookie source silently did nothing in every
226
+ real Rails app; only the specs' public-`cookies` double made it look alive. Both guards
227
+ now ask `respond_to?(:cookies, true)`. (#71)
228
+ - **Controllers::Timezoneable**: `Vary` is no longer written before the action runs,
229
+ which suppressed Rails' own `Vary: Accept` (`_set_vary_header` only adds it when `Vary`
230
+ is blank) and let a shared cache serve a JSON body to an HTML request. (#71)
231
+ - **Controllers::Sortable**: MySQL is detected by behaviour rather than by adapter name.
232
+ The previous `adapter_name.include?("mysql")` test was false for Trilogy, so a
233
+ `nulls:` rule emitted PostgreSQL syntax against MySQL 8 — a 1064 parse error on every
234
+ request using that sort key. A dotted Symbol column (`sortable_by :"authors.name"`)
235
+ is quoted correctly again; it had regressed to `"posts"."authors.name"`. Sort keys are
236
+ de-duplicated, so `?sort=` with thousands of repeated keys no longer builds thousands
237
+ of ORDER BY terms. (#63)
238
+ - **Models::Normalizable**: `:titleize` no longer destroys data. It was
239
+ `Inflector.titleize`, i.e. `humanize(underscore(v))`, which deleted characters —
240
+ `"Jean-Luc Picard"` → `"Jean Luc Picard"`, `"customer_id"` → `"Customer"` — and ran in
241
+ `before_validation`, so the original was gone. `:url` no longer drops a URL's
242
+ `userinfo` on Ruby's newer `uri` versions. (#66)
243
+ - **Models::Storable**: `serialize :settings, coder: JSON, type: Hash` — the form Rails
244
+ 7.1's own deprecation message directs users to — was misclassified as a non-JSON coder,
245
+ so the whole query feature refused to run on a perfectly queryable column. A blank or
246
+ corrupt store value no longer makes every `where_` query raise on SQLite. A read-only
247
+ finder no longer mutates the caller's `Time`. Key names are validated at macro time.
248
+ (#76)
249
+ - **Models::CounterCacheable**: the locking spec added in 1.28.6 matched the SQLite
250
+ transaction statement with `start_with?("begin")`; Rails 7.2+ switched SQLite to
251
+ IMMEDIATE transactions and upcased it, so the assertion silently found nothing on
252
+ Rails 8.x. Test-only.
253
+
254
+ ### Internal
255
+ - `json` is pinned to `< 3` in the Gemfile. Verified against a real 8.1.3.1 gemset:
256
+ with json 3.0.2 the suite fails in Storable's decode path; with `json < 3` Rails
257
+ 8.1.3.1 is green.
258
+ - `require "active_support/notifications"` added to `authorizable.rb` and
259
+ `error_handleable.rb`, which instrument without requiring it — a direct require of
260
+ either file used to `NameError` on the first event. (#68)
261
+
3
262
  ## 1.28.6 (2026-09-18)
4
263
 
5
264
  Ten feature PRs deepening existing concerns, released as a patch by request: no new