concerns_on_rails 1.28.7 → 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: 5b713f3fd8cd14e5f3a12db7b3c390111118df1fa143d5d833dee009544a8a64
4
- data.tar.gz: 16f6d5447ac1d44c5be3dc1cdb0113d9a9e64457de14b87d4199bb95993189d8
3
+ metadata.gz: 3ae4fb29ea12389fc8aaff341a565a482bf3c8ef99fdcfc7791c64c7302d1ca5
4
+ data.tar.gz: 02ea08bceb25e2a479e27ef00342c69cfeac2ffc8129b689269f423819c0c0ae
5
5
  SHA512:
6
- metadata.gz: 7afa091b97fe9359b7294f965593af629d5d66d797e77a7c3ebde396eddc7fcdf627937fae2c90c2ee0c034c23e91f3713ac3827ab201ee8edda0438094ad89c
7
- data.tar.gz: 683d74ea9d1b258e1c79bcdcd7243bcbcd22b10ba5c862946267e5baab118c3753c69baf7af496436a7424286cbd33be9f5e6bfba889928f6f212b190d9ed3ca
6
+ metadata.gz: 4ac2edd1717ef0e4c157b851e75554ba057321e1ae9f283bcdb015e9e6d49d63f1f4bf9bc9ee4419e0b28a111487147516957a03b17ae3915c7ac23f8a721109
7
+ data.tar.gz: 826dd0234e22faa23b089fbedd7900c8bc847f8f7af5670c6a6b0dafc22ec838b29e0cf196b0269b8dde798302d43c677eb4c93d60396db207d6e733d0f16ff2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,130 @@
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
+
3
128
  ## 1.28.7 (2026-09-19)
4
129
 
5
130
  The eight PRs held back from 1.28.6, released as a patch by request. Each carried a
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,730 RSpec examples** run against a real database on every CI build
151
+ - **Tested like an app, not a snippet** — **1,828 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
  ---
@@ -1035,6 +1035,26 @@ guard rejects are skipped (not errors). Unlike every other batch verb in this ge
1035
1035
  the guarded `<event>!` method, because that path runs validations via `update!` while a bulk
1036
1036
  `update_all` would silently skip them.
1037
1037
 
1038
+ **Timestamps and per-event hooks**
1039
+
1040
+ ```ruby
1041
+ stateable_by :status, states: %i[draft review published archived], default: :draft,
1042
+ timestamps: true, # or %i[published archived] — stamps <state>_at
1043
+ transitions: { publish: { from: %i[draft review], to: :published }, archive: { to: :archived } }
1044
+
1045
+ article.publish! # status = "published" AND published_at = Time.current, in ONE update!
1046
+ article.archived! # direct setters and transition_to! stamp too; the default state on create does not
1047
+
1048
+ def before_publish = check_embargo! # per-event hooks, fired inside the generic pair and the
1049
+ def after_publish = notify_subscribers # same transaction: before_transition → before_publish →
1050
+ # write → after_publish → after_transition
1051
+ ```
1052
+
1053
+ `timestamps:` requires the `<state>_at` columns (checked at class load, one typed migration hint); the column is
1054
+ **not** affixed, and one Rails owns (`created_at` / `updated_at`) is refused. Per-event hooks follow the affixed
1055
+ event name (`before_status_publish` with `prefix: true`), may be private, and — like the generic hooks — fire only
1056
+ for guarded `<event>!` transitions. `Model.stateable_timestamps` lists the stamped states.
1057
+
1038
1058
  **Prefix / suffix** — avoid clashes when the state names overlap with other concerns or scopes:
1039
1059
 
1040
1060
  ```ruby
@@ -1360,7 +1380,7 @@ One entry is recorded **per changed field per save** (creates record `"from" =>
1360
1380
 
1361
1381
  ## 🔐 Lockable
1362
1382
 
1363
- Failed-attempt tracking + **account lockout** ("Devise lockable-lite") for apps rolling their own authentication (Rails 8 auth generator / `has_secure_password`) — which ships **no brute-force protection** out of the box. Two columns on the model's own table; no tokens, no mailers.
1383
+ Failed-attempt tracking + **account lockout** ("Devise lockable-lite") for apps rolling their own authentication (Rails 8 auth generator / `has_secure_password`) — which ships **no brute-force protection** out of the box. Two columns on the model's own table (plus an optional unlock-token column for self-service unlock links); no mailers.
1364
1384
 
1365
1385
  ```ruby
1366
1386
  class User < ApplicationRecord
@@ -1380,9 +1400,14 @@ user.unlock_access! # manual unlock (hooks: before/after_unlock)
1380
1400
  User.locked / User.unlocked # expiry-aware scopes
1381
1401
 
1382
1402
  User.unlock_expired # => 3 — unlocks every row whose unlock_in window has elapsed
1403
+
1404
+ # Self-service unlock (Devise's :email strategy, minus the mailer) — needs a string column:
1405
+ # lockable_by max_attempts: 5, unlock_token: :unlock_token
1406
+ user.lock_access!; user.unlock_token # minted in the same write as the lock — mail it as a link
1407
+ User.unlock_by_token(params[:token]) # constant-time lookup; unlocks once (hooks fire), returns the user or nil
1383
1408
  ```
1384
1409
 
1385
- **Options**: `attempts:` (`:failed_attempts`, must be an integer column), `locked_at:` (`:locked_at`, datetime column), `max_attempts:` (`5`; `nil` = count but never auto-lock), `unlock_in:` (`nil` = locked until manual unlock; a duration makes the lock lapse by itself), `prefix:` / `suffix:` (affix the scope names).
1410
+ **Options**: `attempts:` (`:failed_attempts`, must be an integer column), `locked_at:` (`:locked_at`, datetime column), `max_attempts:` (`5`; `nil` = count but never auto-lock), `unlock_in:` (`nil` = locked until manual unlock; a duration makes the lock lapse by itself), `unlock_token:` (`nil`; a string column that receives a 43-char URL-safe token on lock, is cleared by every unlock path, and is honoured only while the lock is live — so `unlock_in:` doubles as the link's TTL), `prefix:` / `suffix:` (affix the scope names).
1386
1411
 
1387
1412
  **Bulk operations**
1388
1413
 
@@ -1532,13 +1557,31 @@ Patient.where_email("a@b.com") # chainable Relation (accepts arrays too)
1532
1557
 
1533
1558
  **Options** (`encryptable *fields, …`, repeatable): `type:` (cast the decrypted value — `:string` default, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`), `key:` (per-field override; a String or lazy Proc), `blind_index:` (`true`, or `{ column:, expression: }` — maintains a deterministic keyed-HMAC companion column, default `<field>_bidx`, for equality lookups; `expression:` normalizes symmetrically on write and query).
1534
1559
 
1560
+ **Key rotation** — bump the key id, keep the old key for decrypting, re-encrypt, drop the old key:
1561
+
1562
+ ```ruby
1563
+ ConcernsOnRails.configure_encryption do |c|
1564
+ c.key = ENV["ENCRYPTION_KEY_V2"] # encrypts every new write
1565
+ c.key_id = 1 # stamped into the envelope header (any id 0..255)
1566
+ c.previous_keys = { 0 => ENV["ENCRYPTION_KEY_V1"] } # still DECRYPTS rows written before the rotation
1567
+ end
1568
+
1569
+ Patient.needs_reencryption.count # rows still under an old key — a prefix compare on the envelope, no decryption
1570
+ Patient.reencrypt_all! # rewrite them (and their blind indexes) under the current key → count
1571
+ patient.ssn_key_id # => 1
1572
+ # then remove `0 =>` from previous_keys
1573
+ ```
1574
+
1575
+ Reads pick the key by the envelope's id, so old and new rows coexist; `find_by_<field>` / `where_<field>` match blind-index digests under the current **and** previous keys during the window. Per-field `key:` fields sit outside rotation.
1576
+
1535
1577
  **Notes**
1536
1578
  - 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.
1537
1579
  - Ciphertext is non-deterministic (random IV), so `where(ssn: ...)` matches nothing — query through a blind index. `nil` stays `nil`; presence checks work normally.
1538
1580
  - `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.
1539
- - 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).
1581
+ - `update_column(s)` on an encrypted field DOES encrypt (the value still serializes through the attribute type), but it skips validations, callbacks, dirty tracking and the blind-index refresh — so a value written that way is unsearchable until the row is saved normally. Declaring a field with both `encryptable` and `auditable_by` raises (either order).
1540
1582
  - 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.
1541
- - 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).
1583
+ - Rotation is gem-level (`key_id` / `previous_keys`); `reencrypt_all!` streams with `find_each` and rewrites each row with one UPDATE no validations/callbacks (only the ciphertext changes), guarded on the ciphertext it read so a concurrent write is never reverted, and skipping any field with an unsaved change. A row whose key id is no longer configured raises `DecryptionError` naming the id.
1584
+ - Reach for [`lockbox`](https://github.com/ankane/lockbox) or Rails 7+ native `encrypts` when you need Rails-managed key infrastructure (KMS, per-record keys) or deterministic encryption.
1542
1585
 
1543
1586
  ---
1544
1587
 
@@ -1753,6 +1796,8 @@ class ArticlesController < ApplicationController
1753
1796
  filter_by :status, :category # ?status=draft → .where(status: 'draft')
1754
1797
  filter_by :published, scope: :published # ?published=1 → Article.published
1755
1798
  filter_by :q, with: ->(rel, v) { rel.where("title ILIKE ?", "%#{v}%") }
1799
+ filter_by :price, :created_at, operators: true # ?price_gte=10&created_at_lt=2026-01-01
1800
+ filter_by :min_stock, type: :integer, with: ->(rel, v) { rel.where(rel.model.arel_table[:stock].gteq(v)) }
1756
1801
 
1757
1802
  def index
1758
1803
  render json: filtered(Article.all)
@@ -1760,6 +1805,24 @@ class ArticlesController < ApplicationController
1760
1805
  end
1761
1806
  ```
1762
1807
 
1808
+ **Operators** (opt-in per filter, direct-where mode only — `operators: true` or a subset like `%i[gte lte]`),
1809
+ accepted as a suffix `?price_gte=10` or in bracket form `?price[gte]=10&price[lte]=50`:
1810
+
1811
+ | Operator | Param | SQL |
1812
+ |----------|------------------------------------|---------------------------------------|
1813
+ | `not` | `?status_not=draft` | `status != 'draft'` |
1814
+ | `gt` `gte` `lt` `lte` | `?price_gte=10` | `price >= 10` (cast through the column type) |
1815
+ | `in` `not_in` | `?status_in=a,b` or `?status_in[]=a` | `status IN ('a','b')` |
1816
+ | `null` | `?deleted_at_null=true` | `deleted_at IS NULL` (`false` → `IS NOT NULL`) |
1817
+ | `contains` `starts_with` | `?title_contains=rails` | `title LIKE '%rails%'` (wildcards escaped; ILIKE on PostgreSQL) |
1818
+
1819
+ Comparison values are cast the way ActiveRecord casts them (the column's own type), or through `type:`
1820
+ (any ActiveModel type name); `type:` also pre-casts the value handed to a `with:` lambda. Blank values
1821
+ are skipped and unknown operators / non-scalar values ignored. A `gt`/`gte`/`lt`/`lte` value the type
1822
+ cannot represent (`?price_gte=abc`) matches **nothing** rather than silently comparing against `0` —
1823
+ nothing raises at request time. `contains`/`starts_with` are case-insensitive on PostgreSQL, MySQL and
1824
+ SQLite alike. For strict, validated contracts reach for `Permittable`.
1825
+
1763
1826
  **Modes**
1764
1827
 
1765
1828
  | Mode | Declaration | What it does |
@@ -1770,7 +1833,7 @@ end
1770
1833
 
1771
1834
  **Notes**
1772
1835
  - Blank params are skipped — unset filters don't narrow the relation.
1773
- - Passing both `:scope` and `:with` raises `ArgumentError`.
1836
+ - Passing both `:scope` and `:with` raises `ArgumentError`; so do `operators:` on a `scope:`/`with:` filter, an unknown operator name, or an unknown `type:` — all at class load.
1774
1837
  - Scope mode pairs naturally with `Publishable.published`, `SoftDeletable.active`, `Expirable.active`, etc.
1775
1838
 
1776
1839
  ---
@@ -1825,9 +1888,9 @@ class Api::ArticlesController < ApplicationController
1825
1888
  def create
1826
1889
  article = Article.new(article_params)
1827
1890
  if article.save
1828
- render_success(data: article, status: :created)
1891
+ render_created(data: article, location: article_url(article)) # 201 + Location
1829
1892
  else
1830
- render_error(message: "Invalid", errors: article.errors.full_messages)
1893
+ render_invalid(article) # 422 record_invalid + full_messages
1831
1894
  end
1832
1895
  end
1833
1896
  end
@@ -1861,7 +1924,9 @@ respondable_by error_format: :problem_details, problem_type_base: "https://api.e
1861
1924
 
1862
1925
  | Method | Signature |
1863
1926
  |-------------------|--------------------------------------------------------------------------------------------|
1864
- | `render_success` | `render_success(data: nil, status: :ok, meta: {})` |
1927
+ | `render_success` | `render_success(data: nil, status: :ok, meta: {}, location: nil, headers: {})` — `location:` sets the `Location` header (a String, or anything `url_for` resolves); `headers:` sets extra response headers (names and values are coerced to String and stripped of CR/LF, so caller data cannot split the response) |
1928
+ | `render_created` | `render_created(data: nil, location: nil, meta: {}, headers: {})` — `render_success` with `status: :created` |
1929
+ | `render_invalid` | `render_invalid(record_or_errors, message: "Validation failed", status: :unprocessable_entity, code: "record_invalid")` — `render_error` with `errors.full_messages` as `details` (omitted when empty, and when an app's `render_error` override cannot take an `errors:` keyword); same shape as ErrorHandleable's `RecordInvalid` handler, problem-details aware |
1865
1930
  | `render_error` | `render_error(message:, status: :unprocessable_entity, code: nil, errors: nil)` |
1866
1931
  | `respondable_by` | `respondable_by(error_format: :envelope, problem_type_base: nil)` — class-level; `error_format:` is `:envelope` (default) or `:problem_details` |
1867
1932
 
@@ -2229,12 +2294,13 @@ end
2229
2294
  | `:stripe` | `Stripe-Signature` | `t=<unix>,v1=<hex>[,v1=…]` — signs `"#{t}.#{body}"`, every `v1` tried, `tolerance:` rejects stale **and** future timestamps |
2230
2295
  | `:hex` / `:base64` | — (`header:` required) | plain hex / strict Base64 HMAC of the body |
2231
2296
 
2232
- **Options**: `*actions` (none = catch-all; the first matching rule wins), `secret:` (String, callable `instance_exec`'d per request, or Array for rotation — any match passes), `scheme:` (`:hex`), `header:` (overrides the preset), `tolerance:` (Stripe only, `300`s default), `digest:` (`:sha256`; `:sha1`/`:sha512` for `:hex`/`:base64` only).
2297
+ **Options**: `*actions` (none = catch-all; the first matching rule wins), `secret:` (String, callable `instance_exec`'d per request, or Array for rotation — any match passes), `scheme:` (`:hex`), `header:` (overrides the preset), `tolerance:` (Stripe only, `300`s default), `digest:` (`:sha256`; `:sha1`/`:sha512` for `:hex`/`:base64` only), `replay:` (`true` = the gem-wide `cache_store`, a store object, or `false`/`nil` for off) + `replay_ttl:` (`24.hours`) — replay protection for the schemes that carry no timestamp.
2233
2298
 
2234
2299
  **Notes**
2235
2300
  - Comparison is constant-time and the attacker-controlled header is **never decoded** — garbage (including invalid UTF-8 bytes) just fails with 401, it cannot raise.
2236
2301
  - A secret that resolves **blank at request time raises `ArgumentError`** — a misconfigured endpoint should page you, not 401 into the provider's silent retry loop.
2237
- - Failure codes: `webhook_signature_missing` / `webhook_signature_invalid` / `webhook_timestamp_stale` 401; `webhook_signature_malformed` (unparseable Stripe header) 400. With `Respondable`, bodies delegate to `render_error`; override `webhook_verification_failed` to customize.
2302
+ - **Replay protection** (`replay:`): GitHub, Shopify and plain-HMAC signatures carry no timestamp, so a captured delivery verifies forever. After a signature verifies, a SHA256 of what identifies the delivery — the signature header, or for Stripe the signed `"#{t}.#{body}"` payload, since unknown `v0=` keys and stray whitespace let a captured Stripe header be mutated without invalidating it — is written to the store with `unless_exist:` (atomic — memcached `add` / Redis `SET NX` via `Rails.cache`); a second delivery with the same signature within `replay_ttl:` is rejected with **409 `webhook_replayed`**. Forged traffic never consumes a slot; the key is scoped per controller action. The store must answer `#write(key, value, expires_in:, unless_exist:)` and `#read(key)` (`#delete(key)` is optional but recommended) — `Rails.cache` does. The marker is a short 60-second claim until the action finishes, then promoted to `replay_ttl:` so a handler that 500s releases it at once, and one that raises (or a filter that halts after verification) leaves only the 60-second claim to expire, either way the provider's retry (identical body, identical signature) gets through. If the store is unreachable the check fails **open**: Rails' Redis and memcached stores return false from `#write` on a connection error, and rejecting every inbound delivery during a cache blip would be worse than accepting a rare duplicate. A per-process `MemoryStore` gives no protection across workers. Stripe's own retries re-sign with a new `t=`, so they pass; a manual GitHub redelivery has the identical signature and is treated as a replay — override `webhook_verification_failed` if you'd rather answer 200.
2303
+ - Failure codes: `webhook_signature_missing` / `webhook_signature_invalid` / `webhook_timestamp_stale` → 401; `webhook_signature_malformed` (unparseable Stripe header) → 400; `webhook_replayed` → 409. With `Respondable`, bodies delegate to `render_error`; override `webhook_verification_failed` to customize.
2238
2304
  - Declare **before** `Idempotentable` (a 401 cached by its around filter would be replayed) and before `Throttleable` (forged traffic shouldn't burn rate budget). Webhook endpoints also need `skip_before_action :verify_authenticity_token`.
2239
2305
  - In tests: `skip_before_action :verify_webhook_signature!`, or sign payloads for real with `OpenSSL::HMAC`. After a pass, `webhook_verified?` is true.
2240
2306
 
@@ -2401,12 +2467,12 @@ Both forms reference the same module, so you can freely mix them.
2401
2467
 
2402
2468
  | Need | Use instead |
2403
2469
  |------|-------------|
2404
- | Complex state machines (callbacks, transition logging) | [`aasm`](https://github.com/aasm/aasm) |
2470
+ | Complex state machines — guard clauses, multi-state events, a full transition audit log (`Stateable` has per-event hooks and `<state>_at` stamps, but records no transition history) | [`aasm`](https://github.com/aasm/aasm) |
2405
2471
  | Sentinel-aware unique indexes on soft-deleted rows (`deleted_at` in the index) | [`paranoia`](https://github.com/rubysherpas/paranoia) or [`discard`](https://github.com/jhawthorn/discard) |
2406
2472
  | Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
2407
2473
  | Indexed full-text search — stemming, tsvector/GIN, typo tolerance (`Searchable` ranks LIKE matches, but never builds an index) | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
2408
2474
  | Versioned audit trails with undo/reify, who-dunnit queries, or association tracking | [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) |
2409
- | Field encryption with managed key rotation / Rails-native key infrastructure | [`lockbox`](https://github.com/ankane/lockbox) / Rails 7+ native `encrypts` |
2475
+ | Field encryption with KMS-backed / per-record keys or Rails-native key infrastructure | [`lockbox`](https://github.com/ankane/lockbox) / Rails 7+ native `encrypts` |
2410
2476
  | Deep clone with per-attribute regex/prepend rules or belongs_to graph copying | [`amoeba`](https://github.com/amoeba-rb/amoeba) |
2411
2477
 
2412
2478
  `Sluggable` wraps [`friendly_id`](https://github.com/norman/friendly_id) and `Sortable` wraps [`acts_as_list`](https://github.com/brendon/acts_as_list), so you get those leaders' engines behind the declarative macro.
@@ -2436,9 +2502,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
2436
2502
 
2437
2503
  ```sh
2438
2504
  bundle install # install dev dependencies
2439
- bundle exec rspec # run the test suite (1,730 examples)
2505
+ bundle exec rspec # run the test suite (1,828 examples)
2440
2506
  gem build concerns_on_rails.gemspec # build the gem
2441
- gem install ./concerns_on_rails-1.28.7.gem # install locally
2507
+ gem install ./concerns_on_rails-1.28.8.gem # install locally
2442
2508
 
2443
2509
  # Preview the docs site locally (GitHub Pages serves docs/ as-is):
2444
2510
  cd docs && python3 -m http.server 8000 # → http://localhost:8000
@@ -1,4 +1,5 @@
1
1
  require "active_support/concern"
2
+ require "active_model/type"
2
3
  require "concerns_on_rails/support/scalar_param"
3
4
 
4
5
  module ConcernsOnRails
@@ -9,6 +10,24 @@ module ConcernsOnRails
9
10
  # filter_by :published, scope: :published # ?published=1 -> .published
10
11
  # filter_by :q, with: ->(rel, v) { rel.where(...) } # ?q=foo -> lambda is called
11
12
  #
13
+ # Direct-where filters can opt into comparison operators, accepted either as
14
+ # a suffix (`?price_gte=10`) or in bracket form (`?price[gte]=10`):
15
+ #
16
+ # filter_by :price, :stock, operators: true # every operator below
17
+ # filter_by :created_at, operators: %i[gte lte] # a subset
18
+ #
19
+ # not · gt · gte · lt · lte · in · not_in (comma list or array) ·
20
+ # null (true/false) · contains · starts_with (LIKE, wildcards escaped)
21
+ #
22
+ # Comparison values are cast the way ActiveRecord casts them — through the
23
+ # column's own attribute type — or through `type:` (any ActiveModel type
24
+ # name: :integer, :decimal, :boolean, :date, :datetime, ...). `type:` also
25
+ # pre-casts the value handed to a `with:` lambda. Blank values are skipped,
26
+ # unknown operators and non-scalar values are ignored, and a comparison
27
+ # value the type cannot represent (`?price_gte=abc`) matches nothing;
28
+ # nothing here raises at request time — for strict, validated params use
29
+ # Permittable.
30
+ #
12
31
  # Usage:
13
32
  # class ArticlesController < ApplicationController
14
33
  # include ConcernsOnRails::Controllers::Filterable
@@ -22,24 +41,64 @@ module ConcernsOnRails
22
41
  module Filterable
23
42
  extend ActiveSupport::Concern
24
43
 
44
+ LABEL = "ConcernsOnRails::Controllers::Filterable".freeze
45
+ OPERATORS = %i[not gt gte lt lte in not_in null contains starts_with].freeze
46
+ COMPARISONS = { gt: :gt, gte: :gteq, lt: :lt, lte: :lteq }.freeze
47
+ LIKE_ESCAPE = "\\".freeze
48
+ LIKE_SPECIAL = /[\\%_]/
49
+ NUMERIC_TYPES = %i[integer float decimal].freeze
50
+ NUMERIC_STRING = /\A\s*[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?\s*\z/
51
+ UNCASTABLE = Object.new.freeze
52
+
25
53
  included do
26
54
  class_attribute :filterable_rules, default: {}
27
55
  end
28
56
 
29
- class_methods do
57
+ module ClassMethods
30
58
  # Declare one or more filterable params. Modes are mutually exclusive
31
59
  # per call; pass either `scope:` or `with:`, or neither (direct where).
32
- def filter_by(*fields, scope: nil, with: nil)
33
- raise ArgumentError, "ConcernsOnRails::Controllers::Filterable: at least one field is required" if fields.empty?
60
+ # `operators:` (true or a list) is direct-where only; `type:` applies to
61
+ # direct-where comparisons and to `with:` lambdas.
62
+ def filter_by(*fields, scope: nil, with: nil, type: nil, operators: nil)
63
+ raise ArgumentError, "#{LABEL}: at least one field is required" if fields.empty?
64
+ raise ArgumentError, "#{LABEL}: pass either :scope or :with, not both" if scope && with
34
65
 
35
- raise ArgumentError, "ConcernsOnRails::Controllers::Filterable: pass either :scope or :with, not both" if scope && with
66
+ operators = filterable_normalize_operators(operators, direct: scope.nil? && with.nil?)
67
+ type = filterable_normalize_type(type)
36
68
 
37
69
  new_rules = filterable_rules.dup
38
70
  fields.each do |field|
39
- new_rules[field.to_sym] = { scope: scope, with: with }
71
+ new_rules[field.to_sym] = { scope: scope, with: with, type: type, operators: operators }
40
72
  end
41
73
  self.filterable_rules = new_rules
42
74
  end
75
+
76
+ def filterable_normalize_operators(operators, direct:)
77
+ return nil if operators.nil? || operators == false
78
+ raise ArgumentError, "#{LABEL}: operators: only apply to direct-where filters (not scope:/with:)" unless direct
79
+
80
+ operators == true ? OPERATORS : filterable_operator_list(operators)
81
+ end
82
+
83
+ def filterable_operator_list(operators)
84
+ list = Array(operators).map(&:to_sym)
85
+ unknown = list - OPERATORS
86
+ return list if unknown.empty?
87
+
88
+ raise ArgumentError,
89
+ "#{LABEL}: unknown operator(s) #{unknown.map(&:inspect).join(', ')} — " \
90
+ "valid: #{OPERATORS.map(&:inspect).join(', ')}"
91
+ end
92
+
93
+ # Resolved eagerly so a typo fails at class load, not on the first request.
94
+ def filterable_normalize_type(type)
95
+ return nil if type.nil?
96
+
97
+ ActiveModel::Type.lookup(type.to_sym)
98
+ rescue ArgumentError
99
+ raise ArgumentError, "#{LABEL}: type: #{type.inspect} is not an ActiveModel type (try :integer, :decimal, :date, ...)"
100
+ end
101
+ private :filterable_normalize_operators, :filterable_operator_list, :filterable_normalize_type
43
102
  end
44
103
 
45
104
  # Apply all declared filters to a relation based on params. Unset values
@@ -47,9 +106,8 @@ module ConcernsOnRails
47
106
  def filtered(relation)
48
107
  self.class.filterable_rules.each do |field, options|
49
108
  value = params[field]
50
- next if filterable_unset?(value)
51
-
52
- relation = apply_filter(relation, field, value, options)
109
+ relation = apply_filter(relation, field, value, options) unless filterable_unset?(value)
110
+ relation = apply_filter_operator_suffixes(relation, field, options) if options[:operators]
53
111
  end
54
112
  relation
55
113
  end
@@ -71,7 +129,7 @@ module ConcernsOnRails
71
129
 
72
130
  def apply_filter(relation, field, value, options)
73
131
  if options[:with]
74
- options[:with].call(relation, value)
132
+ apply_filter_lambda(relation, value, options)
75
133
  elsif options[:scope]
76
134
  # Scope mode discards the value, so an explicit `false` can only mean
77
135
  # "do not apply this scope" — applying it would hand the client the
@@ -81,14 +139,159 @@ module ConcernsOnRails
81
139
  value == false ? relation : relation.public_send(options[:scope])
82
140
  elsif filterable_scalar?(value)
83
141
  relation.where(field => value)
142
+ elsif options[:operators] && value.respond_to?(:each_pair)
143
+ apply_filter_operator_hash(relation, field, value, options)
84
144
  else
85
145
  # A nested/structured param (e.g. ?status[gt]=5) in direct-where mode
86
146
  # would raise TypeError ("can't quote Hash") and surface as a 500.
87
- # Ignore it instead — hash/array shaping must go through a `with:` lambda.
147
+ # Ignore it instead — hash/array shaping must go through a `with:`
148
+ # lambda or `operators:`.
88
149
  relation
89
150
  end
90
151
  end
91
152
 
153
+ # `type:` pre-casts a with: lambda's value; the column's own type must
154
+ # NOT, or every existing lambda on a column-backed param silently starts
155
+ # receiving true / a Time where it used to get "1" / "2020-01-02".
156
+ def apply_filter_lambda(relation, value, options)
157
+ options[:with].call(relation, options[:type] ? options[:type].cast(value) : value)
158
+ end
159
+
160
+ # ?price[gte]=10&price[lte]=50 — unknown keys are ignored.
161
+ def apply_filter_operator_hash(relation, field, hash, options)
162
+ hash.each do |key, raw|
163
+ operator = key.to_s.to_sym
164
+ next unless options[:operators].include?(operator)
165
+
166
+ relation = apply_filter_operator(relation, field, operator, raw, options)
167
+ end
168
+ relation
169
+ end
170
+
171
+ # ?price_gte=10 — one param per declared operator.
172
+ def apply_filter_operator_suffixes(relation, field, options)
173
+ options[:operators].each do |operator|
174
+ relation = apply_filter_operator(relation, field, operator, params[:"#{field}_#{operator}"], options)
175
+ end
176
+ relation
177
+ end
178
+
179
+ # The unset guard lives here, not in the two callers: the suffix form used
180
+ # to skip blanks and the bracket form did not, so `?price[gte]=` cast ""
181
+ # to nil and `price > NULL` handed back ZERO rows, while `?price_gte=` —
182
+ # documented as the same filter — correctly returned everything. It is
183
+ # `filterable_unset?`, not `blank?`, for the same reason `#filtered` uses
184
+ # it: a JSON body's `false` is a value, not an absent param.
185
+ def apply_filter_operator(relation, field, operator, raw, options)
186
+ return relation if filterable_unset?(raw)
187
+
188
+ case operator
189
+ when :in, :not_in then apply_filter_list(relation, field, operator, raw)
190
+ when :null then apply_filter_null(relation, field, raw)
191
+ when :contains, :starts_with then apply_filter_like(relation, field, operator, raw)
192
+ when :not then filterable_operand?(raw) ? relation.where.not(field => raw) : relation
193
+ else apply_filter_comparison(relation, field, operator, raw, options)
194
+ end
195
+ end
196
+
197
+ # `ScalarParam.scalar?` deliberately excludes booleans (nothing there is
198
+ # safe to `.to_i`), but a JSON body carries a real `true`/`false` and
199
+ # `?deleted_at[null]=true` means something — so `null` and `not` take one.
200
+ def filterable_operand?(raw)
201
+ ConcernsOnRails::Support::ScalarParam.scalar?(raw) || raw == true || raw == false
202
+ end
203
+
204
+ def apply_filter_comparison(relation, field, operator, raw, options)
205
+ return relation unless ConcernsOnRails::Support::ScalarParam.scalar?(raw)
206
+
207
+ value = filter_cast(relation, field, raw, options)
208
+ # A value the column's type cannot represent matches nothing. Casting it
209
+ # anyway is worse than useless: `?stock_gt=twelve` becomes `stock > 0`
210
+ # (Integer#cast("twelve") is 0, not nil) and quietly returns rows the
211
+ # caller never asked for, while the same typo against a datetime column
212
+ # casts to nil and returns none — one request answered two opposite ways.
213
+ return relation.none if value.equal?(UNCASTABLE)
214
+
215
+ column = relation.model.arel_table[field]
216
+ relation.where(column.public_send(COMPARISONS.fetch(operator), value))
217
+ end
218
+
219
+ def apply_filter_list(relation, field, operator, raw)
220
+ list = filterable_list(raw)
221
+ return relation if list.nil?
222
+
223
+ operator == :in ? relation.where(field => list) : relation.where.not(field => list)
224
+ end
225
+
226
+ # A comma list ("a, b") or an array (?status_in[]=a) of scalars → the
227
+ # cleaned list, or nil when unsafe or empty. A hash-shaped param is NOT a
228
+ # list: `?status_in[x]=1` used to reach `to_s` and filter on the literal
229
+ # string `{"x"=>"1"}` instead of being ignored as documented.
230
+ def filterable_list(raw)
231
+ list = filterable_raw_list(raw)
232
+ return nil if list.nil? || !ConcernsOnRails::Support::ScalarParam.where_safe?(list)
233
+
234
+ list = list.map { |item| item.is_a?(String) ? item.strip : item }.reject { |item| item.to_s.empty? }
235
+ list.empty? ? nil : list
236
+ end
237
+
238
+ def filterable_raw_list(raw)
239
+ return raw if raw.is_a?(Array)
240
+
241
+ raw.to_s.split(",") if ConcernsOnRails::Support::ScalarParam.scalar?(raw)
242
+ end
243
+
244
+ def apply_filter_null(relation, field, raw)
245
+ return relation unless filterable_operand?(raw)
246
+
247
+ ActiveModel::Type::Boolean.new.cast(raw) ? relation.where(field => nil) : relation.where.not(field => nil)
248
+ end
249
+
250
+ # LIKE with the user's wildcards escaped — with an explicit ESCAPE clause,
251
+ # since SQLite has no default escape character (Searchable does the same).
252
+ # Arel `matches` is ILIKE on PostgreSQL and the adapter's LIKE elsewhere,
253
+ # so matching is case-insensitive on all three supported adapters.
254
+ # Escaped here rather than through `sanitize_sql_like`, which is only
255
+ # public from Rails 5.1 while the gemspec supports >= 5.0 — Searchable
256
+ # hand-rolls the identical gsub for the same reason.
257
+ def apply_filter_like(relation, field, operator, raw)
258
+ return relation unless ConcernsOnRails::Support::ScalarParam.scalar?(raw)
259
+
260
+ escaped = raw.to_s.gsub(LIKE_SPECIAL) { |char| "#{LIKE_ESCAPE}#{char}" }
261
+ pattern = operator == :contains ? "%#{escaped}%" : "#{escaped}%"
262
+ relation.where(relation.model.arel_table[field].matches(pattern, LIKE_ESCAPE))
263
+ end
264
+
265
+ # `type:` wins; otherwise the column's own attribute type (what
266
+ # `where(field => value)` would use); a virtual field passes through raw.
267
+ # Returns UNCASTABLE when the value is not representable in that type.
268
+ def filter_cast(relation, field, value, options)
269
+ type = options[:type] || filterable_column_type(relation, field)
270
+ return value if type.nil?
271
+ return UNCASTABLE if filterable_uncastable?(type, value)
272
+
273
+ type.cast(value)
274
+ end
275
+
276
+ def filterable_column_type(relation, field)
277
+ model = relation.model
278
+ return nil unless model.respond_to?(:attribute_types) && model.attribute_types.key?(field.to_s)
279
+
280
+ model.type_for_attribute(field.to_s)
281
+ end
282
+
283
+ # Numeric types are checked against the string BEFORE casting, because
284
+ # `Integer#cast`/`Decimal#cast` answer 0 for any non-numeric string rather
285
+ # than nil — the cast result alone cannot tell "0" from "twelve". Every
286
+ # other type reports the failure by casting to nil (blank values never get
287
+ # this far; `apply_filter_operator` skipped them).
288
+ def filterable_uncastable?(type, value)
289
+ return type.cast(value).nil? unless NUMERIC_TYPES.include?(type.type)
290
+ return false if value.is_a?(Numeric)
291
+
292
+ !NUMERIC_STRING.match?(value.to_s)
293
+ end
294
+
92
295
  # Scalars (and arrays of scalars, which AR turns into `IN (...)`) are safe
93
296
  # to pass to .where; a Hash / ActionController::Parameters is not — and
94
297
  # since 1.22 neither is an array CONTAINING one (`?status[][x]=1` used to