concerns_on_rails 1.28.3 → 1.28.5

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: d3f06aad189272886038354ad98dfd58a84642d64e1313c4aac5d88e7d1ec90a
4
- data.tar.gz: b38acca4cb5cd6a48496d0d4909da90782c14b6b71748af88b72df482a02ef62
3
+ metadata.gz: 5c6fcd158fd0dce6d1744e9ad6be5d921bf283e921e717a8327f04f53e8668f4
4
+ data.tar.gz: fe3b93a06be3a0cbb4b0e1904a976393b77d24c4075f1d8dccb98e13d3ac6597
5
5
  SHA512:
6
- metadata.gz: 98d77a84da148195bdd1c013054b12bd3b8c4f6ce2bf5b7ae8ee7e91c0a24b4d5d45e6540c139456e1d51b91afbfaed5cf68d9faf9859399cb4ec2b76ccdd90b
7
- data.tar.gz: 6852cb66a64150139c9625d765cd3f724881a6b35a9e5920140713c21b69f148c956353d587765f3ad1eca6018b38de70b98aa8aa9c40006ad0a6919242607c8
6
+ metadata.gz: 1a113631bd4810decc66aad97ba5fc9fe8866d92dcfd1ab1908c2cdafe7e990e961c3655933ad124ae8275f1a199c4275a7ba72a06cc172ecf248e2825eb42de
7
+ data.tar.gz: 7102f8a5cb83e21eabe46066f7a62956adf0950dbe926dc8706c9abd217a87577b6004d625c71d179dc2cf4083dc7fdefd25b47883e1a085fee1ec5bb691b41c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,173 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.28.5 (2026-09-17)
4
+
5
+ Eight feature PRs deepening existing concerns, released as a patch by request:
6
+ no new concerns and no dependency changes, though several add an optional
7
+ column or option. Every PR was reviewed before merge and carries the review's
8
+ fixes; the notes below call out the ones that change a documented behaviour.
9
+ 1522 examples, 0 failures.
10
+
11
+ ### Added
12
+ - **Models::Schedulable**: `overlapping(from, to)` scope and `overlaps?`
13
+ predicate for booking-clash checks. Open-ended windows (a `NULL` start or
14
+ end), touching intervals, reversed arguments and Range forms behave
15
+ identically in SQL and in Ruby. Affixable like the other scopes. (#58)
16
+ - **Models::Duplicable**: per-call `only:` / `except:` association selection —
17
+ `invoice.duplicate!(except: :line_items)`. The macro's list stays the
18
+ ceiling, so a controller param cannot smuggle in an undeclared association.
19
+ An explicit `nil` counts as passed, not absent, so an empty checkbox list
20
+ copies nothing rather than everything. (#73)
21
+ - **Models::Monetizable**: scope-aware `sum_`, `average_`, `minimum_` and
22
+ `maximum_<name>` aggregates plus their `formatted_` twins, with per-call
23
+ formatting overrides. BigDecimal throughout; a grouped relation returns a
24
+ Hash of converted values instead of raising. (#67)
25
+ - **Models::Taggable**: `tag_counts` — tag to record count in one `GROUP BY`
26
+ query, relation-aware, with `limit:` for the top N. A `select`, `group` or
27
+ `order` on the relation is stripped; a `limit`/`offset` window is honoured by
28
+ resolving it to ids first. (#56)
29
+ - **Models::Expirable**: `before_expire` / `after_expire` lifecycle hooks,
30
+ `expire_in!(duration)` and `clear_expiry!`. The hooks fire when a write
31
+ actually expires the record; a future time only schedules expiry and fires
32
+ nothing, so `after_expire { account.downgrade! }` is safe next to
33
+ `trial.expire_in!(14.days)`. Overriding either hook moves `expire_all` to the
34
+ per-record path. (#54)
35
+ - **Controllers::SecureHeadable**: HSTS, cross-origin (COOP/COEP/CORP) and
36
+ Permissions-Policy presets, plus the `:recommended` and
37
+ `:cross_origin_isolation` bundles. `:recommended` uses `(self)`-scoped
38
+ permission values, so it denies third-party frames without disabling the
39
+ app's own camera, microphone, geolocation or Payment Request. HSTS is skipped
40
+ on a plaintext request (RFC 6797 §7.2) and never overwrites a stricter value
41
+ already on the response. (#55)
42
+ - **Models::Addressable**: `address_fingerprint` (SHA-256 of the normalized
43
+ address), `same_address_as?`, `address_parts_changed?` and a `fingerprint:`
44
+ column option with a `with_address` finder for deduplication reports. The
45
+ column is stamped in `before_save`, after every `before_validation`, so a
46
+ sibling concern rewriting a mapped column cannot desync it. (#79)
47
+ - **Controllers::Localizable**: `Content-Language` response header and
48
+ `Vary: Accept-Language`, both written before the action so a
49
+ `rescue_from`-rendered error carries them. Rails' own `Vary: Accept` is
50
+ preserved rather than suppressed, and `Vary` is advertised only when
51
+ `Accept-Language` can actually change the resolved locale. (#53)
52
+
53
+ ### Notes
54
+ - **Addressable** defines `address_changed?` only when the model has no
55
+ `address` column of its own, so it never shadows ActiveModel's dirty
56
+ predicate; `address_parts_changed?` is always available.
57
+ - Existing rows keep a `NULL` address fingerprint until they are re-saved.
58
+ Backfill with `Model.find_each(&:save)`; `update_columns`, `insert_all` and
59
+ `upsert_all` bypass callbacks and leave it stale.
60
+ - The address digest is unkeyed, so treat the column as revealing the address.
61
+ Do not pair it with an `encryptable` address column.
62
+
63
+ ## 1.28.4 (2026-09-16)
64
+
65
+ Eleven bug-fix PRs (#91–#101) from the audit of the shipped gem, released as a
66
+ patch: no new concerns, no new options, no migrations, no dependency changes.
67
+ Two close fail-open holes (WebhookVerifiable, Encryptable); the rest are
68
+ correctness fixes for behaviour the docs already promised. Every fix ships with
69
+ a regression spec that fails on 1.28.3. 1460 examples, 0 failures.
70
+
71
+ ### Security
72
+ - **Controllers::WebhookVerifiable**: verification could be skipped entirely,
73
+ leaving the action to run on an unverified — possibly forged — payload. Two
74
+ paths: `webhook_verification_failed` returned `nil` when there was no response
75
+ object to render into, which left the `before_action` chain unhalted; and
76
+ `webhook_rule_for_action` returned `nil` (read as "no rule applies, carry on")
77
+ when `action_name` was unresolvable or `""`. Both fail closed now — the first
78
+ raises, the second falls back to the catch-all rule, or to a lone declared
79
+ rule, and verifies. With several action-specific rules and no catch-all it
80
+ raises rather than verifying against an arbitrary provider's secret, which
81
+ would reject a valid delivery as "signature invalid". The render guard also
82
+ honours a `render_error` override on its own, so a controller supplying one
83
+ but no response object renders its rejection instead of raising. Mirrors the
84
+ fix Authorizable got in 1.22. A resolvable action simply not covered by any
85
+ rule still passes through untouched. (#92)
86
+ - **Models::Encryptable**: `<field>_ciphertext` — documented for "asserting no
87
+ plaintext is at rest" — returned the caller's **plaintext** whenever the value
88
+ had not round-tripped through the database (a new record, or any pending
89
+ assignment: exactly the state inside a `before_save`, a validator, or an
90
+ error-reporting path), so `log.info(user.ssn_ciphertext)` wrote the SSN
91
+ straight to the log. It returns `nil` in that state now. `<field>_encrypted?`
92
+ used a bare `.present?`, true for plaintext too; it now checks that what is
93
+ stored really is an encryption envelope, via the new
94
+ `Support::Encryptor.envelope?`. (#97)
95
+
96
+ ### Fixed
97
+ - **Models::Aliasable**: an aliased `belongs_to` carrying `counter_cache:`
98
+ double-counted. The alias copy kept the `:counter_cache` option, and because
99
+ the `#association` override maps the alias back to the same association
100
+ object, ActiveRecord's counter-cache pass fired once per name — the parent's
101
+ count came out doubled on create and doubled on destroy, drifting permanently
102
+ negative once rows predating the alias were removed. The copy no longer
103
+ carries the option; the source reflection still owns the counter. (#93)
104
+ - **Controllers::Paginatable**: `?page=99999999999999999999` was an
105
+ unauthenticated 500 — `(page - 1) * per_page` produced an offset no backend
106
+ accepts (`StatementInvalid` on a relation, `RangeError` on an Array). `page`
107
+ is now clamped to `MAX_PAGE` (1,000,000) and comes back as an empty page past
108
+ the end; `per_page` is held under the matching `MAX_PER_PAGE`, since with
109
+ `max_per_page: 0` ("no cap") the identical value overflowed `LIMIT` instead.
110
+ `paginate_by` also validates `per_page` now: 0 and negatives raise
111
+ `ArgumentError` at class-load time instead of misbehaving on every request
112
+ (`per_page: -1` means `LIMIT -1`, i.e. NO LIMIT on SQLite and MySQL —
113
+ serialising the whole table; `per_page: 0` made every page permanently empty).
114
+ A negative `max_per_page` still means "no cap", as documented. (#94)
115
+ - **Models::Taggable**: `all_tags` raised on PostgreSQL for any model that also
116
+ includes `Models::Sortable` — `SELECT DISTINCT` cannot be ordered by a column
117
+ outside the select list, and Sortable installs exactly such a `default_scope`.
118
+ The inherited `ORDER BY` is dropped with `reorder(nil)`; the result is sorted
119
+ in Ruby anyway. Passed on SQLite, which permits it. (#95)
120
+ - **Models::Lockable, Models::Stateable**: `ActiveRecord::Rollback` raised from
121
+ an `after_lock` / `after_transition` hook did nothing when the call was nested
122
+ inside a caller's own transaction — a bare `transaction` joins the enclosing
123
+ one and Rails swallows `Rollback` without rolling anything back. Both open a
124
+ savepoint now (`requires_new: true`), so the documented abort works: Lockable
125
+ no longer leaves a row locked in the database while reporting `false` in
126
+ memory (with `lock_access!`'s idempotency guard then making every retry a
127
+ no-op), and Stateable no longer commits a state change its hook asked to
128
+ abort. Stateable's `<event>!` also took its return value from `update!`, which
129
+ runs *before* the hook, so an aborted transition reported success —
130
+ `raise unless ticket.archive!` never fired and `transition_all` counted a row
131
+ it had rolled back. It reports `false` now, which `transition_all` treats as
132
+ the documented failed-record signal. Note `transition_all` opens one savepoint
133
+ per record. (#96)
134
+ - **Support::ErrorEnvelope**: the `render_error` lookup was public-only, but
135
+ `render_error` is very often declared under `private` — the idiomatic way to
136
+ keep a controller helper from becoming a routable action. Those overrides were
137
+ silently ignored and the gem's inline envelope rendered instead, so an app
138
+ rendering RFC 9457 problem+json got the wrong shape for every Authorizable
139
+ 403, WebhookVerifiable 401, Throttleable 429 and CursorPaginatable 400, with
140
+ no error or warning. Now `respond_to?(:render_error, true)`, the spelling
141
+ Authorizable already used for `current_user`. Controllers::Deprecatable keeps
142
+ its own copy of that check before rendering a sunset 410, and it had the same
143
+ blind spot — a private `render_error` with no response object skipped the 410
144
+ and served the sunset action. (#98)
145
+ - **Controllers::Deprecatable**: `deprecate_actions` mutated the caller's own
146
+ `Time`. `Time#utc` is an alias of `#gmtime` and converts the receiver IN
147
+ PLACE, so a host passing a frozen constant (`SUNSET = Time.new(...).freeze`)
148
+ got a `FrozenError` while the controller class body was still loading — the
149
+ app would not boot — and an unfrozen `Time` was silently rewritten to UTC
150
+ behind the caller's back. Now `getutc`. (#99)
151
+ - **Controllers::Filterable**: a boolean `false` read as "filter not supplied",
152
+ so `filter_by :active` could never select the inactive rows — `false.blank?`
153
+ is true, the rule was skipped and the UNFILTERED relation came back. Only JSON
154
+ request bodies were affected; a query string carries the String `"false"`,
155
+ which is not blank. Everything genuinely empty — `nil`, `""`, `" "`, `[]`,
156
+ `{}` — is still skipped, and in `scope:` mode (which discards the value) an
157
+ explicit `false` still means "do not apply this scope". (#100)
158
+ - **Models::Stateable**: `transition_all` silently skipped rows whose state is
159
+ NULL. `where.not(state: to)` compiles to `NOT (state = 'x')`, which SQL
160
+ three-valued logic evaluates to NULL — never TRUE — for a NULL state, so those
161
+ rows were dropped from the batch and from the returned count even though they
162
+ ARE eligible (`may_<event>?` returns true for them and the per-record
163
+ `<event>!` succeeds). The predicate is NULL-safe now. (#101)
164
+
165
+ ### Internal
166
+ - **Specs**: the Aliasable join-alias SQL assertion accepts both the Rails 8.1
167
+ `AS`-qualified table alias and the older unqualified form, so the suite passes
168
+ on Rails 8.1 — unblocking the pending Rails 8.1 dependency bumps. No library
169
+ change. (#91)
170
+
3
171
  ## 1.28.3 (2026-09-16)
4
172
 
5
173
  Three merged PRs from the September loop (#41, #43, #52), shipped as a patch at
data/README.md CHANGED
@@ -148,7 +148,7 @@ across all 43 concerns — press <kbd>/</kbd> and type.
148
148
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable), and both load **lazily**: an app that never includes those concerns never loads them. Depends on `activerecord`/`actionpack`/`activesupport`, not the full `rails` meta-gem; controller concerns have zero extra deps
149
149
  - **Schema-validated configuration** — every macro checks that the configured columns exist and raises `ArgumentError` early — listing *every* missing column at once, with one ready-to-paste `rails generate migration` command that adds them all
150
150
  - **Composable** — concerns are independent; mix and match per model
151
- - **Tested like an app, not a snippet** — **1,396 RSpec examples** run against a real database on every CI build
151
+ - **Tested like an app, not a snippet** — **1,522 RSpec examples** run against a real database on every CI build
152
152
  - **Documented twice** — everything in this README also lives as a per-concern page on the [docs site](https://vsn2015.github.io/concerns_on_rails), searchable and deep-linkable
153
153
 
154
154
  ---
@@ -601,6 +601,9 @@ Promotion.current # WHERE starts_at <= NOW AND (ends_at IS NU
601
601
  Promotion.upcoming # WHERE starts_at > NOW
602
602
  Promotion.expired # WHERE ends_at <= NOW
603
603
  Promotion.active_at(time) # active at an arbitrary time
604
+ Promotion.overlapping(from, to) # windows intersecting [from, to) — clashing bookings, a calendar page
605
+ Promotion.overlapping(from..to) # Range form; `..` makes the end inclusive; nil on either side = unbounded
606
+ promo.overlaps?(from, to) # the instance-side predicate
604
607
  ```
605
608
 
606
609
  **Configuration**
@@ -667,13 +670,21 @@ ApiToken.expiring_within(1.day) # future expiry within the next 1 day
667
670
  ```ruby
668
671
  token.expire! # expires_at = now
669
672
  token.expire!(2.hours.from_now) # explicit time
673
+ token.expire_in!(15.minutes) # absolute lifetime from now, whatever the current expiry
670
674
  token.extend_expiry!(by: 1.day) # pushes expiry forward
675
+ token.clear_expiry! # never expires (nil)
671
676
  ```
672
677
 
673
678
  `extend_expiry!` is smart about the base:
674
679
  - If `expires_at` is `nil` or in the past → new value is `now + by`
675
680
  - If `expires_at` is still in the future → `by` is added to the existing value
676
681
 
682
+ **Lifecycle hooks** — override `before_expire` / `after_expire` on the model; they fire around a write that
683
+ actually expires the record (`expire!` with a past-or-now time, and `expire_all`) inside one transaction, so
684
+ a raising `after_expire` rolls the expiry back. A future time only *schedules* expiry, so `expire_in!(14.days)`
685
+ fires nothing — as with renewals (`extend_expiry!`) and `clear_expiry!`. Overriding either hook moves
686
+ `expire_all` from its single `UPDATE` to the per-record path so the hooks run for every row.
687
+
677
688
  **Bulk operations**
678
689
 
679
690
  ```ruby
@@ -681,7 +692,7 @@ ApiToken.expiring_within(1.day).expire_all # => 12
681
692
  ```
682
693
 
683
694
  `expire_all(time = Time.zone.now)` expires every currently-active record in the relation and
684
- returns the Integer count, in a transaction. With `expire!` unoverridden and no validations on
695
+ returns the Integer count, in a transaction. With `expire!` and both hooks unoverridden and no validations on
685
696
  the model — neither `validates`/`validates_with`, a custom `validate :method`, nor an
686
697
  association's autosave validation (a bare `has_many` registers one, so most models with
687
698
  associations take the streaming path) — it collapses
@@ -1042,6 +1053,7 @@ end
1042
1053
  | `allow_blank:` | `false` | Per-field opt-out for the length check: an Array of parts (e.g. `%i[line2 state]`), or `true` for all parts. A blank value for an allowed part skips its length check. Independent of `required:`. |
1043
1054
  | `normalize_country:` | `false` | When `true`, canonicalize the country to its ISO 3166-1 alpha-2 code: an English name (`"Canada"`, `"United States"`) or a 3-letter alpha-3 (`"CAN"`, `"USA"`) maps to the alpha-2 (`"CA"`, `"US"`); unrecognized values are left untouched. This also lets postal/state validation recognize a named country. |
1044
1055
  | `verify_with:` | `nil` | A callable for real-world verification (see below). |
1056
+ | `fingerprint:` | `nil` | A `string` column to store `address_fingerprint` in (stamped in `before_validation`, after normalization) so duplicates are one indexed query away: `Location.with_address(record)`. |
1045
1057
  | `if:` / `unless:` | `nil` | Standard Rails validation conditions (Symbol, Proc, or Array) gating the address **validations** — e.g. `if: :on_addresses?`. Normalization still runs unconditionally. |
1046
1058
 
1047
1059
  **What it normalizes** (in `before_validation`)
@@ -1065,6 +1077,18 @@ end
1065
1077
  | `String` | added as a `:base` error |
1066
1078
  | `Array` | each element added as a `:base` error |
1067
1079
 
1080
+ **Dedupe helpers**
1081
+
1082
+ ```ruby
1083
+ loc.address_fingerprint # => "9f2c…" — SHA-256 of the normalized parts: case, whitespace, postal spacing
1084
+ # and a blank country (→ default_country) don't change it; nil for a blank address
1085
+ loc.same_address_as?(other) # fingerprints equal (never true for two blanks)
1086
+ loc.address_changed? # any mapped column dirty (address_parts_changed? when you have an `address` column)
1087
+
1088
+ addressable_by fingerprint: :address_fingerprint # add a string column + index
1089
+ Location.with_address(loc).where.not(id: loc.id) # the duplicates of loc (or pass a fingerprint)
1090
+ ```
1091
+
1068
1092
  **Notes**
1069
1093
  - Scope is **format/structure only** — it checks shape, not real-world deliverability. Plug a USPS/Google/Smarty client into `verify_with:` for that.
1070
1094
  - Error messages are plain English strings — no host-app i18n setup required.
@@ -1096,6 +1120,7 @@ article.save!
1096
1120
  Article.tagged_with("ruby", "rails") # records carrying BOTH tags
1097
1121
  Article.tagged_with("ruby", "go", any: true) # records carrying ANY tag
1098
1122
  Article.all_tags # => sorted unique tags in use
1123
+ Article.published.tag_counts(limit: 20) # => { "ruby" => 12, "rails" => 7, ... } — a tag cloud, relation-aware
1099
1124
  ```
1100
1125
 
1101
1126
  **Options**
@@ -1108,7 +1133,8 @@ Article.all_tags # => sorted unique tags in use
1108
1133
  **Notes**
1109
1134
  - Matching is **boundary-safe** — searching `rail` does not match `rails`. An explicit SQL `ESCAPE` clause makes tags containing `_` / `%` match literally on every adapter.
1110
1135
  - Tags are normalized in `before_validation`, so a direct `record.tags = "a, b"` assignment is cleaned too. An empty list stores `NULL`.
1111
- - Reach for [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) when you need tag contexts, ownership, counts/clouds, or polymorphic tags shared across models.
1136
+ - `tag_counts` runs one `GROUP BY` on the raw column — identical tag strings ship once with their row count and are split in Ruby — so it scales with distinct tag strings, not rows; ordered by count desc then name, `limit:` keeps the top N.
1137
+ - Reach for [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) when you need tag contexts, ownership, or polymorphic tags shared across models.
1112
1138
 
1113
1139
  ---
1114
1140
 
@@ -1206,9 +1232,17 @@ product.formatted_price # => "$19.99"
1206
1232
  |-------------------|-----------------------------------------------|
1207
1233
  | `price` | the amount as a `BigDecimal` (cents ÷ 100) |
1208
1234
  | `price=` | assign in major units; rounded to whole cents |
1209
- | `formatted_price` | a display string (`"$1,234.56"`) |
1235
+ | `formatted_price` | a display string (`"$1,234.56"`); accepts per-call overrides: `formatted_price(unit: "€", delimiter: ".", separator: ",")` |
1210
1236
 
1211
- **Options**: `as:` (explicit method name — required when the column does not end in `_cents`), `unit:` (`"$"`), `precision:` (`2`), `delimiter:` (`","`), `separator:` (`"."`), `subunit_to_unit:` (`100`). `nil` stays `nil` across all three methods.
1237
+ **Aggregates** — class methods that follow the current scope, exact and float-free:
1238
+
1239
+ ```ruby
1240
+ Product.sum_price # => BigDecimal SUM(price_cents) / 100
1241
+ Product.in_stock.average_price # average_ / minimum_ / maximum_ too — nil on an empty set (sum is 0)
1242
+ Order.paid.formatted_sum_total # => "€3.500,50" every aggregate has a formatted_ twin (overrides accepted)
1243
+ ```
1244
+
1245
+ **Options**: `as:` (explicit method name — required when the column does not end in `_cents`), `unit:` (`"$"`), `precision:` (`2`), `delimiter:` (`","`), `separator:` (`"."`), `subunit_to_unit:` (`100`). `nil` stays `nil` across all the accessors.
1212
1246
 
1213
1247
  ---
1214
1248
 
@@ -1417,6 +1451,7 @@ Patient.where_email("a@b.com") # chainable Relation (accepts arrays too)
1417
1451
  **Notes**
1418
1452
  - The declared column must be `text`/binary (it stores an opaque envelope, not the logical type); a blind-index column holds a 64-char hex digest — add an index on it.
1419
1453
  - Ciphertext is non-deterministic (random IV), so `where(ssn: ...)` matches nothing — query through a blind index. `nil` stays `nil`; presence checks work normally.
1454
+ - `ssn_ciphertext` is `nil` while the field has an unsaved change (so it can never return the plaintext you just assigned), and `ssn_encrypted?` asks whether what is stored really is an envelope.
1420
1455
  - Never `update_column(s)` an encrypted field — that bypasses the type and writes raw plaintext. Declaring a field with both `encryptable` and `auditable_by` raises (either order).
1421
1456
  - Wrong key / tampered ciphertext / malformed envelope raise `Encryption::DecryptionError`. Encrypted field names are auto-registered with Rails' `filter_parameters` (via the gem's railtie), so they're redacted from request logs.
1422
1457
  - Reach for [`lockbox`](https://github.com/ankane/lockbox) or Rails 7+ native `encrypts` when you need key rotation today or Rails-managed key infrastructure (rotation is planned — the envelope already reserves the `key_id` byte).
@@ -1470,6 +1505,8 @@ end
1470
1505
 
1471
1506
  copy = invoice.duplicate # unsaved deep copy
1472
1507
  copy = invoice.duplicate!(title: "Q3") # saved (one transaction, autosaved children)
1508
+ copy = invoice.duplicate!(except: :line_items) # this copy skips the line items
1509
+ copy = invoice.duplicate!(only: []) # shallow copy — attributes only
1473
1510
  ```
1474
1511
 
1475
1512
  **Auto-reset identity columns** (no configuration): `created_at`/`updated_at`, Sluggable slug, Tokenizable/Hashable tokens, Sequenceable sequence + `into:` columns, Auditable trail, SoftDeletable timestamp, Lockable attempts/locked_at. Business state (Publishable, Stateable, …) is a judgment call — list it in `reset:`.
@@ -1478,6 +1515,7 @@ copy = invoice.duplicate!(title: "Q3") # saved (one transaction, autosaved chil
1478
1515
 
1479
1516
  **Notes**
1480
1517
  - The macro is optional — bare `include` gives `duplicate`/`duplicate!` with the auto resets.
1518
+ - `only:` / `except:` on `duplicate` / `duplicate!` pick which of the declared associations this particular copy carries ("Duplicate with line items?" checkbox); names outside the allow-list raise. They are reserved keys — pass overrides for attributes literally named `only`/`except` as a braced Hash.
1481
1519
  - Override `on_duplicate(copy)` for custom tweaks; it receives the unsaved copy last.
1482
1520
  - Reach for [`amoeba`](https://github.com/amoeba-rb/amoeba) when you need per-attribute regex/prepend rules or belongs_to graph copying.
1483
1521
 
@@ -1867,6 +1905,9 @@ class ApplicationController < ActionController::Base
1867
1905
  # Preset headers, plus any custom "Header-Name" => "value" pairs:
1868
1906
  secure_headers :nosniff, :sameorigin_frame, :no_referrer_leak, :disable_legacy_xss
1869
1907
  secure_headers "Permissions-Policy" => "geolocation=()"
1908
+ # ...or the break-nothing baseline in one line, then relax what you must (later wins):
1909
+ secure_headers :recommended
1910
+ secure_headers :sameorigin_frame
1870
1911
 
1871
1912
  # Delegates to Rails' native CSP DSL — roll out report-only FIRST:
1872
1913
  content_security_policy_for(report_only: true) do |policy|
@@ -1887,6 +1928,14 @@ end
1887
1928
  | `:no_referrer_leak` | `Referrer-Policy: strict-origin-when-cross-origin` |
1888
1929
  | `:no_cross_domain` | `X-Permitted-Cross-Domain-Policies: none` |
1889
1930
  | `:disable_legacy_xss` | `X-XSS-Protection: 0` (the only correct modern value) |
1931
+ | `:hsts` | `Strict-Transport-Security: max-age=31536000; includeSubDomains` (no `preload` — opt in via a custom pair) |
1932
+ | `:same_origin_opener` / `:same_origin_opener_allow_popups` | `Cross-Origin-Opener-Policy: same-origin` / `same-origin-allow-popups` |
1933
+ | `:require_corp_embedder` | `Cross-Origin-Embedder-Policy: require-corp` |
1934
+ | `:same_origin_resource` | `Cross-Origin-Resource-Policy: same-origin` |
1935
+ | `:no_sensitive_permissions` | `Permissions-Policy` denying camera, microphone, geolocation, payment, usb and motion sensors to everyone, your own pages included |
1936
+ | `:self_sensitive_permissions` | The same list scoped to `(self)` — denies third-party frames, keeps first-party use |
1937
+
1938
+ **Bundles** (expand to presets in place, so a later preset or custom pair still wins): `:recommended` = nosniff, deny_frame, no_referrer_leak, no_cross_domain, disable_legacy_xss, same_origin_opener_allow_popups, self_sensitive_permissions — deliberately *without* COEP/CORP (they block cross-origin embeds of your resources and CDN assets lacking CORP headers) and HSTS (belongs with `force_ssl`); relax `deny_frame` with `:sameorigin_frame` if the app frames itself; `:cross_origin_isolation` = same_origin_opener + require_corp_embedder + same_origin_resource (what SharedArrayBuffer / high-resolution timers require).
1890
1939
 
1891
1940
  **Notes**
1892
1941
  - Headers are applied in an `after_action`, so they reinforce Rails' middleware defaults; later `secure_headers` declarations win on a colliding name.
@@ -1911,7 +1960,9 @@ end
1911
1960
 
1912
1961
  Resolution order: `params[param]` → first match in `Accept-Language` → `default` → `I18n.default_locale`. The chosen locale is always validated against `I18n.available_locales`, so a stray param or a mismatched `available:` list can never raise `I18n::InvalidLocale`.
1913
1962
 
1914
- **Options**: `available:` (allow-list for matching; defaults to `I18n.available_locales`), `default:`, `param:` (default `:locale`), `header:` (default `true`).
1963
+ Every response carries **`Content-Language: <resolved locale>`** (BCP 47 form — `pt_BR` → `pt-BR`) and, when `Accept-Language` is a locale source, **`Vary: Accept-Language`** appended to any existing `Vary` (de-duplicated) so shared caches key on the header. Both are written *before* the action runs, so a `rescue_from`-rendered error still carries them; `response_headers: false` turns them off.
1964
+
1965
+ **Options**: `available:` (allow-list for matching; defaults to `I18n.available_locales`), `default:`, `param:` (default `:locale`), `header:` (default `true`), `response_headers:` (default `true`).
1915
1966
 
1916
1967
  ---
1917
1968
 
@@ -2249,9 +2300,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
2249
2300
 
2250
2301
  ```sh
2251
2302
  bundle install # install dev dependencies
2252
- bundle exec rspec # run the test suite (1,396 examples)
2303
+ bundle exec rspec # run the test suite (1,522 examples)
2253
2304
  gem build concerns_on_rails.gemspec # build the gem
2254
- gem install ./concerns_on_rails-1.28.3.gem # install locally
2305
+ gem install ./concerns_on_rails-1.28.5.gem # install locally
2255
2306
 
2256
2307
  # Preview the docs site locally (GitHub Pages serves docs/ as-is):
2257
2308
  cd docs && python3 -m http.server 8000 # → http://localhost:8000
@@ -141,8 +141,15 @@ module ConcernsOnRails
141
141
  # Module#=== checks the real ancestry, so `when Time` alone would
142
142
  # miss it — and Time.current / 1.month.from_now are exactly the
143
143
  # values Rails hosts pass.
144
- when ActiveSupport::TimeWithZone, Time then value.utc
145
- when DateTime then value.to_time.utc
144
+ # getutc, NOT utc: Time#utc is an alias of #gmtime, which converts the
145
+ # receiver IN PLACE and returns self. A host passing a frozen
146
+ # constant (SUNSET = Time.new(...).freeze) got a FrozenError while
147
+ # the controller class body was still loading — the app would not
148
+ # boot — and an unfrozen Time was silently rewritten to UTC behind
149
+ # the caller's back. TimeWithZone#utc is a harmless reader, but
150
+ # getutc is correct for both, so the branch stays single.
151
+ when ActiveSupport::TimeWithZone, Time then value.getutc
152
+ when DateTime then value.to_time.getutc
146
153
  when Date then Time.utc(value.year, value.month, value.day)
147
154
  when String then parse_deprecation_string(value)
148
155
  end
@@ -247,7 +254,12 @@ module ConcernsOnRails
247
254
  return unless deprecation_sunset_reached?(rule)
248
255
 
249
256
  message = "This endpoint was sunset on #{rule[:sunset_at].httpdate}."
250
- return unless respond_to?(:render_error) || (respond_to?(:response) && response)
257
+ # respond_to?(..., true) for the same reason Support::ErrorEnvelope
258
+ # uses it: render_error is very often declared under `private`. The
259
+ # public-only check skipped the 410 for exactly those controllers and
260
+ # let the sunset action run — a fail-open on the branch whose job is to
261
+ # stop serving the endpoint.
262
+ return unless respond_to?(:render_error, true) || (respond_to?(:response) && response)
251
263
 
252
264
  ConcernsOnRails::Support::ErrorEnvelope.render(self, message: message, status: :gone, code: "endpoint_sunset")
253
265
  end
@@ -42,12 +42,12 @@ module ConcernsOnRails
42
42
  end
43
43
  end
44
44
 
45
- # Apply all declared filters to a relation based on params. Blank values
46
- # are skipped so unset filters don't narrow the relation.
45
+ # Apply all declared filters to a relation based on params. Unset values
46
+ # are skipped so absent filters don't narrow the relation.
47
47
  def filtered(relation)
48
48
  self.class.filterable_rules.each do |field, options|
49
49
  value = params[field]
50
- next if value.blank?
50
+ next if filterable_unset?(value)
51
51
 
52
52
  relation = apply_filter(relation, field, value, options)
53
53
  end
@@ -56,11 +56,29 @@ module ConcernsOnRails
56
56
 
57
57
  private
58
58
 
59
+ # NOT `value.blank?`: `false.blank?` is true, so a genuine boolean false
60
+ # read as "filter not supplied" and the relation came back UNFILTERED —
61
+ # `filter_by :active` could never select the inactive rows. Query strings
62
+ # were unaffected (they carry the String "false", which is not blank), so
63
+ # this only bit JSON request bodies, where the value really is `false`.
64
+ # Everything actually empty — nil, "", " ", [], {} — is still skipped.
65
+ def filterable_unset?(value)
66
+ return false if value == false
67
+ return true if value.nil?
68
+
69
+ value.respond_to?(:blank?) ? value.blank? : false
70
+ end
71
+
59
72
  def apply_filter(relation, field, value, options)
60
73
  if options[:with]
61
74
  options[:with].call(relation, value)
62
75
  elsif options[:scope]
63
- relation.public_send(options[:scope])
76
+ # Scope mode discards the value, so an explicit `false` can only mean
77
+ # "do not apply this scope" — applying it would hand the client the
78
+ # exact opposite of what it asked for. (A query string still carries
79
+ # the String "false", which has always triggered the scope; only a
80
+ # real boolean is read as a negation.)
81
+ value == false ? relation : relation.public_send(options[:scope])
64
82
  elsif filterable_scalar?(value)
65
83
  relation.where(field => value)
66
84
  else
@@ -18,9 +18,15 @@ module ConcernsOnRails
18
18
  # against `I18n.available_locales` before use, so a stray param or a
19
19
  # mismatched `available:` list can never raise `I18n::InvalidLocale`.
20
20
  #
21
+ # Every response carries `Content-Language: <resolved locale>` (BCP 47
22
+ # form, `pt_BR` → `pt-BR`) and, when the header is a locale source,
23
+ # `Vary: Accept-Language` appended to any existing Vary — written before
24
+ # the action runs, so a rescued error still carries them. Both are behind
25
+ # `response_headers:` (default `true`).
26
+ #
21
27
  # Options: `available:` (allow-list for param/header matching; defaults to
22
28
  # `I18n.available_locales`), `default:`, `param:` (default `:locale`),
23
- # `header:` (default `true`).
29
+ # `header:` (default `true`), `response_headers:` (default `true`).
24
30
  module Localizable
25
31
  extend ActiveSupport::Concern
26
32
 
@@ -30,19 +36,23 @@ module ConcernsOnRails
30
36
  end
31
37
 
32
38
  class_methods do
33
- def localizable(available: nil, default: nil, param: :locale, header: true)
39
+ def localizable(available: nil, default: nil, param: :locale, header: true, response_headers: true)
34
40
  self.localizable_options = {
35
41
  available: available&.map(&:to_sym),
36
42
  default: default&.to_sym,
37
43
  param: param&.to_sym,
38
- header: header
44
+ header: header,
45
+ response_headers: response_headers ? true : false
39
46
  }
40
47
  end
41
48
  end
42
49
 
43
- # Public so subclasses can override; runs the action under the resolved locale.
50
+ # Public so subclasses can override; writes the response headers, then
51
+ # runs the action under the resolved locale.
44
52
  def switch_locale(&)
45
- I18n.with_locale(resolved_locale, &)
53
+ locale = resolved_locale
54
+ apply_locale_response_headers(locale)
55
+ I18n.with_locale(locale, &)
46
56
  end
47
57
 
48
58
  # The locale chosen for this request — always one I18n can switch to.
@@ -60,6 +70,49 @@ module ConcernsOnRails
60
70
 
61
71
  private
62
72
 
73
+ # Content-Language always; Vary: Accept-Language only when the header can
74
+ # influence the choice (a param-only setup already differs by URL).
75
+ # Vary is appended and de-duplicated, never clobbered (Cacheable, the
76
+ # paginators' Link header — same rule).
77
+ def apply_locale_response_headers(locale)
78
+ return unless locale_response_headers?
79
+
80
+ response.set_header("Content-Language", locale.to_s.tr("_", "-"))
81
+ # Only advertise the dimension the resolver actually consults: with
82
+ # `header: false` (or no `localizable` call at all) Accept-Language
83
+ # cannot change the answer.
84
+ append_vary_accept_language if self.class.localizable_options[:header]
85
+ end
86
+
87
+ def locale_response_headers?
88
+ opts = self.class.localizable_options
89
+ return false if opts.key?(:response_headers) && !opts[:response_headers]
90
+
91
+ respond_to?(:response) && response.respond_to?(:set_header)
92
+ end
93
+
94
+ def append_vary_accept_language
95
+ existing = response.headers["Vary"].to_s.split(",").map(&:strip).reject(&:empty?)
96
+ return if existing.include?("*")
97
+
98
+ # Rails adds its own `Vary: Accept` during render, but ONLY while the
99
+ # header is still blank (ActionController::Rendering#_set_vary_header).
100
+ # Writing ours before the action would therefore SUPPRESS it and cost a
101
+ # cache dimension, so seed Accept ourselves whenever Rails would have.
102
+ merged = existing + vary_accept_dimension + ["Accept-Language"]
103
+ deduped = merged.each_with_object([]) do |value, list|
104
+ list << value unless list.any? { |seen| seen.casecmp?(value) }
105
+ end
106
+ response.set_header("Vary", deduped.join(", "))
107
+ end
108
+
109
+ def vary_accept_dimension
110
+ return [] unless respond_to?(:request, true) && (req = request)
111
+ return [] unless req.respond_to?(:should_apply_vary_header?) && req.should_apply_vary_header?
112
+
113
+ ["Accept"]
114
+ end
115
+
63
116
  def locale_from_param(opts, allowed)
64
117
  return nil unless opts[:param] && respond_to?(:params) && params
65
118
 
@@ -36,6 +36,21 @@ module ConcernsOnRails
36
36
  LABEL = "ConcernsOnRails::Controllers::Paginatable".freeze
37
37
  DEFAULT_PER_PAGE = 25
38
38
  DEFAULT_MAX_PER_PAGE = 200
39
+ # Upper bound on the requested page. `page` is untrusted input and its
40
+ # only job is to become `(page - 1) * per_page`, so an unbounded value
41
+ # produced an offset no backend can take: the relation branch raised
42
+ # StatementInvalid and Array#[] raised RangeError ("bignum too big to
43
+ # convert into `long'") — a 500 from `?page=99999999999999999999`.
44
+ # Clamping keeps the request in range; the page is far past any real
45
+ # dataset, so it simply comes back empty. Deep pagination at this depth
46
+ # wants Controllers::CursorPaginatable instead.
47
+ MAX_PAGE = 1_000_000
48
+ # The same guard for per_page. `max_per_page: 0` is documented as "no
49
+ # cap", and with no cap the identical untrusted value overflowed LIMIT
50
+ # instead of OFFSET — the same unauthenticated 500, one option away. "No
51
+ # cap" means no CONFIGURED cap, not an unbounded LIMIT; a page of a
52
+ # million records is already far past what any client can render.
53
+ MAX_PER_PAGE = 1_000_000
39
54
 
40
55
  included do
41
56
  class_attribute :paginatable_per_page, default: DEFAULT_PER_PAGE
@@ -59,8 +74,8 @@ module ConcernsOnRails
59
74
  # paginate_by per_page: 50, max_per_page: 500, link_header: false
60
75
  def paginate_by(per_page: DEFAULT_PER_PAGE, max_per_page: DEFAULT_MAX_PER_PAGE, link_header: true,
61
76
  page_param: nil, per_page_param: nil, style: :flat, window: nil)
62
- self.paginatable_per_page = per_page.to_i
63
- self.paginatable_max_per_page = max_per_page.to_i
77
+ self.paginatable_per_page = paginatable_per_page!(per_page)
78
+ self.paginatable_max_per_page = paginatable_max_per_page!(max_per_page)
64
79
  self.paginatable_link_header = link_header ? true : false
65
80
  self.paginatable_window = paginatable_window!(window)
66
81
  defaults = paginatable_style_params!(style)
@@ -79,6 +94,29 @@ module ConcernsOnRails
79
94
  end
80
95
  end
81
96
 
97
+ # per_page must be positive. A bare `.to_i` let a negative through, and
98
+ # `LIMIT -1` means NO LIMIT on SQLite and MySQL — so `per_page: -1`
99
+ # silently serialized the entire table on every request, while
100
+ # `per_page: 0` made every page permanently empty. Both are broken
101
+ # configuration with no sane reading, so they raise at class-load time
102
+ # rather than misbehaving on every request.
103
+ def paginatable_per_page!(value)
104
+ size = value.to_i
105
+ return size if size.positive?
106
+
107
+ raise ArgumentError, "#{LABEL}: per_page: must be a positive integer (got #{value.inspect})"
108
+ end
109
+
110
+ # max_per_page does NOT raise: "0 or a negative integer disables the
111
+ # cap" is this option's documented contract, so rejecting a negative
112
+ # would fail the boot of an app that is configured exactly as written —
113
+ # and on a patch upgrade at that. Normalize to 0 instead; the reader's
114
+ # guard only asks whether the cap is positive.
115
+ def paginatable_max_per_page!(value)
116
+ size = value.to_i
117
+ size.negative? ? 0 : size
118
+ end
119
+
82
120
  # nil / false disable the window (no `pages:` key). `0` is meaningful:
83
121
  # first, current and last only.
84
122
  def paginatable_window!(value)
@@ -206,14 +244,18 @@ module ConcernsOnRails
206
244
  # Both readers route through ScalarParam: `?page[]=1` / `?page[x]=1`
207
245
  # arrive as Array/Parameters, and calling .to_i on those was a 500.
208
246
  def pagination_page
209
- [ConcernsOnRails::Support::ScalarParam.to_i(pagination_param(self.class.paginatable_page_param), default: 0), 1].max
247
+ requested = ConcernsOnRails::Support::ScalarParam.to_i(pagination_param(self.class.paginatable_page_param), default: 0)
248
+ requested.clamp(1, MAX_PAGE)
210
249
  end
211
250
 
212
251
  def pagination_per_page
213
252
  requested = ConcernsOnRails::Support::ScalarParam.to_i(pagination_param(self.class.paginatable_per_page_param), default: 0)
214
253
  requested = self.class.paginatable_per_page if requested < 1
215
254
  cap = self.class.paginatable_max_per_page
216
- cap.positive? ? [requested, cap].min : requested
255
+ requested = [requested, cap].min if cap.positive?
256
+ # Applied even when a cap IS configured: `max_per_page: 10**30` is its
257
+ # own way of asking for the overflow back.
258
+ [requested, MAX_PER_PAGE].min
217
259
  end
218
260
 
219
261
  # Dig the configured path out of params: `["page"]` → params[:page];