concerns_on_rails 1.21.1 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/README.md +111 -9
  4. data/lib/concerns_on_rails/controllers/authorizable.rb +14 -6
  5. data/lib/concerns_on_rails/controllers/cacheable.rb +25 -7
  6. data/lib/concerns_on_rails/controllers/cursor_paginatable.rb +23 -8
  7. data/lib/concerns_on_rails/controllers/deprecatable.rb +18 -10
  8. data/lib/concerns_on_rails/controllers/error_handleable.rb +4 -5
  9. data/lib/concerns_on_rails/controllers/filterable.rb +6 -6
  10. data/lib/concerns_on_rails/controllers/idempotentable.rb +15 -6
  11. data/lib/concerns_on_rails/controllers/includable.rb +5 -1
  12. data/lib/concerns_on_rails/controllers/localizable.rb +17 -8
  13. data/lib/concerns_on_rails/controllers/paginatable.rb +37 -16
  14. data/lib/concerns_on_rails/controllers/secure_headable.rb +9 -2
  15. data/lib/concerns_on_rails/controllers/throttleable.rb +24 -6
  16. data/lib/concerns_on_rails/controllers/timezoneable.rb +20 -10
  17. data/lib/concerns_on_rails/controllers/webhook_verifiable.rb +2 -3
  18. data/lib/concerns_on_rails/legacy_aliases.rb +2 -0
  19. data/lib/concerns_on_rails/models/activatable.rb +1 -0
  20. data/lib/concerns_on_rails/models/addressable.rb +23 -5
  21. data/lib/concerns_on_rails/models/aliasable.rb +7 -7
  22. data/lib/concerns_on_rails/models/anonymizable.rb +218 -0
  23. data/lib/concerns_on_rails/models/auditable.rb +37 -1
  24. data/lib/concerns_on_rails/models/counter_cacheable.rb +35 -18
  25. data/lib/concerns_on_rails/models/duplicable.rb +207 -0
  26. data/lib/concerns_on_rails/models/encryptable.rb +21 -18
  27. data/lib/concerns_on_rails/models/expirable.rb +5 -0
  28. data/lib/concerns_on_rails/models/hashable.rb +22 -2
  29. data/lib/concerns_on_rails/models/lockable.rb +1 -0
  30. data/lib/concerns_on_rails/models/maskable.rb +1 -0
  31. data/lib/concerns_on_rails/models/monetizable.rb +14 -1
  32. data/lib/concerns_on_rails/models/normalizable.rb +8 -1
  33. data/lib/concerns_on_rails/models/publishable.rb +32 -10
  34. data/lib/concerns_on_rails/models/sanitizable.rb +1 -0
  35. data/lib/concerns_on_rails/models/schedulable.rb +17 -6
  36. data/lib/concerns_on_rails/models/searchable.rb +1 -0
  37. data/lib/concerns_on_rails/models/sequenceable.rb +15 -0
  38. data/lib/concerns_on_rails/models/sluggable.rb +6 -1
  39. data/lib/concerns_on_rails/models/soft_deletable.rb +67 -11
  40. data/lib/concerns_on_rails/models/sortable.rb +32 -14
  41. data/lib/concerns_on_rails/models/stateable.rb +16 -6
  42. data/lib/concerns_on_rails/models/storable.rb +66 -10
  43. data/lib/concerns_on_rails/models/taggable.rb +1 -0
  44. data/lib/concerns_on_rails/models/tokenizable.rb +19 -7
  45. data/lib/concerns_on_rails/railtie.rb +18 -0
  46. data/lib/concerns_on_rails/support/column_guard.rb +30 -4
  47. data/lib/concerns_on_rails/support/encryptor.rb +40 -11
  48. data/lib/concerns_on_rails/support/error_envelope.rb +29 -0
  49. data/lib/concerns_on_rails/support/filter_parameter_registry.rb +67 -0
  50. data/lib/concerns_on_rails/support/money.rb +3 -1
  51. data/lib/concerns_on_rails/support/random_value.rb +18 -1
  52. data/lib/concerns_on_rails/support/scalar_param.rb +35 -0
  53. data/lib/concerns_on_rails/support/unique_retry.rb +38 -0
  54. data/lib/concerns_on_rails/version.rb +1 -1
  55. data/lib/concerns_on_rails.rb +31 -2
  56. metadata +9 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 87a3ceebf2b9eb0f8a00c9d6a8cc57d8f50c8b1ff698be887aef42da64eb08c3
4
- data.tar.gz: 1a82dd3dbe363e65f3a0d91c66cb453d54415032def91e90a388663ce2e90dab
3
+ metadata.gz: ba3e5dc1bc6af51ec2bd4e29946973c5fc68736d4c1994fdbd472874f965c4bc
4
+ data.tar.gz: 16b2372635abe33484efabcae5407d1debe5a0627df9df0496a04e930c46ec65
5
5
  SHA512:
6
- metadata.gz: ebfb6e59e582240aab7bc0fe18e64e48f1f291f482b38df2dac97c3cc843f5731857594724868484fee75b9269ccaca923f6d229d862b3ce4ddfdf49c3f9aa27
7
- data.tar.gz: 53d1b47d39588d102a4b6527b43b1801a1f87c47c1da7a67b823fb2138160e0dc8e1de9ea6d3b16070a2a33ca247a88832556de01e512b8ac474a9b5de288b6e
6
+ metadata.gz: 8a159b0026bca1ebc72dbc11c45e875caaa19ad2ec0615ee658abd5b3e8ab53e0bb309d816a5371ddba6bad5c609b82a76228e66d678d3346d624021634eddf5
7
+ data.tar.gz: da89fb098f9e17fd9ae49662efdb027efb64958007bf790ee895c0faf39a1c9f2cb76d2e95a4b0b3b075bcb404fe926c36d970f78ca152bdd287be81e7a3a991
data/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.23.0 (2026-07-26)
4
+
5
+ Two new model concerns — the roadmap picks after 1.22's fixes round: the right-to-erasure capability that completes the sensitive-data suite, and the concern-aware deep copy that makes "duplicate this invoice" safe next to unique slugs, tokens, and sequence numbers. 1075 examples, 0 failures.
6
+
7
+ ### Added
8
+ - **Models::Anonymizable**: declarative right-to-erasure ("GDPR-lite") for personal data — the fourth member of the sensitive-data suite (Maskable masks display, Sanitizable strips HTML, Encryptable protects at rest; Anonymizable DESTROYS). `anonymizable *fields, with:` declares an erasure strategy per field group (repeatable; rules merge): presets `:nullify`, `:redact`, `:hash` (SHA-256 — deterministic pseudonymization, joins keep working), `:email` (random unique `anon-…@anonymized.invalid`, so NOT NULL + unique email columns survive erasure), `:random_hex`, or a callable (`->(value)` / `->(value, record)`); presets pass nil through untouched. `anonymize!` runs `before_anonymize` + ONE `update_columns` UPDATE + `after_anonymize` in a single transaction — deliberately skipping validations (erasure must not be blocked by a presence check) and callbacks (nothing may copy the old values elsewhere; Auditable's capture hook is the canonical example) — then reloads. Values serialize through the model's attribute types, so a field that is also `encryptable` stores a fresh ciphertext envelope of the anonymized value, never plaintext (spec-verified). When an erased field is also `auditable_by`, the audit column (which holds its plaintext history) is cleared in the same UPDATE (`clear_audit_trail: false` opts out). `stamp:` column (default `:anonymized_at`; `false` opts out) powers `anonymized?`, the `anonymized`/`not_anonymized` scopes (affixable via `prefix:`/`suffix:`), and the idempotent batch `anonymize_all!` (Integer count, skips stamped records — the 1.22 batch contract). Macro-time `ArgumentError` validation for fields, presets, callables, and columns. Zero new dependencies.
9
+ - **Models::Duplicable**: concern-aware deep copy ("clone this invoice/template"). A bare AR `dup` copies identity-bearing columns — slug, API token, invoice number, audit trail, even `created_at` (which AR preserves on save when present) — so naive copies collide with unique indexes or lie about their history. `duplicate` (unsaved) / `duplicate!(overrides)` (saved, one transaction, autosaved children) blank exactly those columns automatically: timestamps, Sluggable's slug, Tokenizable/Hashable columns, Sequenceable sequence + `into:` columns, Auditable's trail (the copy inherits no history; its own creation is then audited normally), SoftDeletable's timestamp (a copy of trash is live), and Lockable's attempts (0) / locked_at (nil) — each concern regenerates fresh values on the copy's save. Business state (Publishable/Stateable/…) is deliberately NOT auto-reset — list it in `reset:`. `duplicable_by associations:, reset:, suffix:` (optional — bare include works): the association allow-list is validated at macro time (declared-before, the CounterCacheable convention); `has_many`/`has_one` children deep-copy — a child whose class also includes Duplicable copies via ITS OWN rules, so nested graphs stay declarative; `has_and_belongs_to_many` re-links the same records; `belongs_to` and `has_many :through` are rejected with explanations. `suffix: { title: " (copy)" }` appends to present values; `on_duplicate(copy)` is the override hook. Zero new dependencies.
10
+
11
+ ### Internal
12
+ - Top-level aliases `ConcernsOnRails::Anonymizable` / `ConcernsOnRails::Duplicable` registered alongside the existing set; docs site pages + registry entries added for both concerns.
13
+
14
+ ## 1.22.0 (2026-07-26)
15
+
16
+ A fixes-and-optimizations release driven by a full-library review of all 40 concerns: one data-loss bug, three request-crashing 500s verified through real ActionController dispatch, security headers restored on rescued error responses, a fail-open authorization path closed, and the gem's largest performance defect (unmemoized PBKDF2 per encrypted-value access) eliminated. 1031 examples, 0 failures.
17
+
18
+ ### Fixed
19
+ - **Models::SoftDeletable**: `really_destroy_all` now hard-deletes only the records matching the calling relation (soft-deleted included) — previously it ran `unscoped.delete_all` and deleted the ENTIRE table regardless of scope (`User.where(account_id: 1).really_destroy_all` wiped every account). Note `unscope` also drops a caller's own predicate on the soft-delete column, so `only_deleted.really_destroy_all` widens to the whole relation — use `soft_deleted.delete_all` to purge trash.
20
+ - **Controllers::Includable**: sparse fieldsets (`?fields[articles]=id,title`) no longer 500 — in a real controller `params[:fields]` is an `ActionController::Parameters`, which has no `each_with_object`; every documented fieldsets request raised `NoMethodError`. Verified through a new real-ActionController integration harness.
21
+ - **Controllers::Paginatable / CursorPaginatable**: `?page[]=1`, `?per_page[x]=5` and friends no longer 500 — untrusted param reads route through the new `Support::ScalarParam` (Filterable's guard, now shared; Filterable also rejects arrays *containing* nested hashes, the `?status[][x]=1` shape).
22
+ - **Controllers::SecureHeadable**: security headers are now also applied via `prepend_before_action` — Rails skips `after_action` callbacks entirely when `rescue_from` handles an exception, so every handled 404/422/500 previously shipped with NO security headers. (Opting out now requires `skip_before_action :apply_secure_headers` alongside the existing `skip_after_action`.)
23
+ - **Controllers::Cacheable**: `no_store` policies are likewise applied early, so rescued error responses on `no_store` endpoints carry `Cache-Control: no-store`; positive freshness deliberately stays post-action (a rescued 500 must never become CDN-cacheable). `stale_resource?` no longer writes ETag/Last-Modified validators for unsafe (non-GET/HEAD) requests, and the 304 path is a single write.
24
+ - **Controllers::Authorizable**: fails CLOSED — a denial that cannot be rendered (no response object) now raises instead of silently letting the action run; `require_role` supports actors whose role method returns an Array (`roles: ["admin"]` previously stringified and was always denied).
25
+ - **Controllers::Localizable**: regional locales match — `Accept-Language: fr-CA` now tries the full tag before falling back to the primary subtag (previously the region was discarded, so `available: [:"fr-CA"]` could never match).
26
+ - **Controllers::Throttleable**: the first-hit seed uses `unless_exist:` so two concurrent first requests can't under-count the window; a nil/blank discriminator (e.g. `-> { current_user&.id }` on an anonymous request) raises instead of collapsing every client into one shared bucket.
27
+ - **Controllers::Idempotentable**: the 409 conflict's `Retry-After` reports the remaining lock time (from the claim's `claimed_at`) instead of the full `lock_ttl`.
28
+ - **Controllers::Deprecatable**: `link:`/`successor:` values are validated against control characters and `<`/`>` at macro time (Link-header injection).
29
+ - **Support::ColumnGuard** (all 24 model concerns): macros no longer raise when the schema is unreachable — models using these concerns previously could not even load without a migrated database, breaking `db:create`, fresh `db:migrate`, `assets:precompile`, and CI bootstrap. Validation is skipped (never silently for a *reachable* schema with a missing column) and the guard is shared with CounterCacheable/Aliasable/Storable, which had each hand-rolled their own.
30
+ - **Models::Sortable**: a bare `sortable_by` no longer crashes with `NoMethodError` (uses the documented `:position` default); the `default_scope` no longer re-validates the schema on every relation construction; new `default_scope: false` opt-out with a `sorted` scope for explicit ordering.
31
+ - **Models::Monetizable**: the money setter casts garbage input (`"abc"`, `""`) to nil — ActiveModel convention — instead of raising `ArgumentError` out of a form assignment.
32
+ - **Models::Encryptable**: `where_<field>(nil)` / `find_by_<field>(nil)` return `none`/nil instead of matching every row without a fingerprint; encrypted field names now register with Rails' parameter filtering through a live registry + railtie (`before: "active_record.set_filter_attributes"`), so redaction works with boot-time snapshots (ActiveRecord `filter_attributes`, lograge-style initializers) and lazily-loaded model classes.
33
+ - **Models::Sequenceable**: with `reset:` enabled, `created_at` is pinned to the period-anchor instant during `before_create`, so a row can no longer be numbered for one year/month/day but timestamped into the next.
34
+ - **Models::Sluggable**: the slug column itself is validated at macro time (a missing `slug` column previously failed at first save with an opaque friendly_id error).
35
+ - **Models::Schedulable**: `reschedule!` accepts either/both keywords (single-column windows no longer fabricate the missing kwarg) and raises when a value targets an unconfigured column instead of silently dropping it.
36
+ - **Models::CounterCacheable**: `touch: true` raises at macro time on Rails < 6.0 (`update_counters` lacks the option there and would read it as a column named `touch`).
37
+
38
+ ### Changed
39
+ - **Models::SoftDeletable**: `soft_delete_all` / `restore_all` (and the `destroy_all` alias) now return the Integer count of records transitioned and raise `ActiveRecord::RecordNotSaved` (rolling the batch back) when a record fails — previously the rollback happened silently and the methods returned nil/records. With `touch: false` and no overridden hooks both collapse to a single `UPDATE`.
40
+ - **Models::Publishable / Stateable**: lifecycle hooks now share one transaction with the state write (SoftDeletable's pattern) — a raising `after_publish`/`after_transition` rolls the change back instead of leaving it committed with the side effect half-done. `publish_at!` now fires the publish hooks.
41
+ - **Models::Encryptable + Auditable**: the encrypted×audited overlap is rejected at macro time from BOTH declaration orders (Auditable gained the mirror guard); the per-save backstop callback is gone.
42
+ - **Models::Hashable / Tokenizable**: `regenerate_<field>!` goes through the same uniqueness path as create-time assignment (precheck + bounded retry on `ActiveRecord::RecordNotUnique` via the new `Support::UniqueRetry`) — previously one blind `update!` with no collision handling. Hashable's `type: :integer` now generates fixed-width codes (`length: 6` is always 6 digits).
43
+ - **Effective Rails support documented**: the gemspec keeps `rails >= 5.0, < 9`, but Ruby 3.2 (required) means Rails 7.0.4+ in practice; README updated accordingly.
44
+
45
+ ### Performance
46
+ - **Support::Encryptor**: PBKDF2-derived keys are memoized (bounded, mutex-guarded, keyed by digest+salt+iterations) — previously EVERY encrypt/decrypt/blind-index call re-ran the 65,536-iteration KDF, turning a 200-row load of one encrypted column into seconds of key stretching. `configure_encryption` purges the cache.
47
+ - **Models::Storable**: per-record decode memoization — readers/writers/dirty-checks/validation shared one `JSON.parse` per raw column value instead of re-parsing per key access (a ten-key form save cost dozens of parses). Read-back semantics are byte-identical (pinned by spec); the native-hash column detection is now mutex-guarded and never caches a negative result taken without a reachable schema.
48
+ - **Models::Auditable**: saves that touch no tracked field skip building the changes hash (`will_save_change_to_attribute?` pre-check); `audit_trail` is memoized per raw column value (returns a fresh Array each call; entry hashes shared, treat as read-only).
49
+ - **Controllers::Paginatable**: `paginated` + `pagination_meta` (no argument) now share one memoized COUNT — the documented records+meta composition ran the identical COUNT twice. The COUNT also strips `:select` and uses `count(:all)`, so custom SELECT lists and DISTINCT relations no longer produce invalid SQL or skewed totals.
50
+ - **Models::Stateable**: the state default uses `attribute ... default:` instead of an `after_initialize` that ran for every row materialized from the database. (Edge: `Model.new(state_field => nil)` now keeps the explicit nil.)
51
+ - **Models::CounterCacheable**: `recount_counter_caches!` runs in a transaction (a crash mid-repair could previously leave every counter zeroed) and groups parents by tally value — O(distinct counts) UPDATEs instead of one per parent row.
52
+ - **Models::Normalizable / Addressable**: normalization skips fields that are not part of the current save on persisted records; `resolved_country` is memoized per raw value (was computed three times per validation cycle).
53
+ - **Controllers::Localizable / Timezoneable / Deprecatable / CursorPaginatable**: per-request memoization of the resolved locale/zone/deprecation rule; the timezone wrapper is skipped when it would be a no-op; the row-predicate adapter check is cached per model class.
54
+ - **Support::RandomValue / Money**: batched CSPRNG draws with rejection sampling (one `SecureRandom` call per token instead of per character); single-pass thousands delimiter.
55
+
56
+ ### Internal
57
+ - New support modules: `ScalarParam` (shared untrusted-param coercion), `UniqueRetry` (bounded `RecordNotUnique` retry — also usable directly: `UniqueRetry.with_retries { Invoice.create!(attrs) }`), `ErrorEnvelope` (the `render_error`-or-inline dance, previously hand-copied across seven controller concerns — custom `render_error(message:, status:, code:)` overrides keep working), `FilterParameterRegistry` + `ConcernsOnRails::Railtie`.
58
+ - `ConcernsOnRails.deprecator` / `.encryption` / `.filter_parameter_registry` memoization is now thread-safe; every concern file explicitly requires the support modules it uses (files are self-contained under direct `require`).
59
+ - New real-ActionController integration harness (`spec/support/integration_harness.rb`) — the dependency-free FakeController cannot reproduce `ActionController::Parameters` or `rescue_from` semantics, which is how the fixed 500s previously stayed green; regression specs now assert mechanisms (statement counts, parse counts, KDF invocations) through real dispatch.
60
+
3
61
  ## 1.21.1 (2026-07-18)
4
62
 
5
63
  Blind-index support for Models::Encryptable, making encrypted fields queryable by exact match — the follow-up promised in 1.21.0. 991 examples, 0 failures.
data/README.md CHANGED
@@ -65,6 +65,9 @@ Article.published.without_deleted.find("hello-world")
65
65
  | [🪞 Aliasable](#-aliasable) | Full read / write / query association aliases |
66
66
  | [⚙️ Storable](#-storable) | Typed accessors over one JSON column ("store_attribute-lite") |
67
67
  | [🧮 CounterCacheable](#-countercacheable) | Conditional denormalized counters ("counter_culture-lite") |
68
+ | [🔏 Encryptable](#-encryptable) | Transparent field encryption (AES-256-GCM) + blind-index lookups |
69
+ | [🕵️ Anonymizable](#-anonymizable) | GDPR right-to-erasure with per-field strategies |
70
+ | [🧬 Duplicable](#-duplicable) | Concern-aware deep copy ("clone this invoice") |
68
71
 
69
72
  ### 🎮 Controller concerns
70
73
 
@@ -91,7 +94,7 @@ Article.published.without_deleted.find("hello-world")
91
94
 
92
95
  ## ✨ Why this gem?
93
96
 
94
- - **Twenty-three model concerns + sixteen controller concerns**, all production-ready
97
+ - **Twenty-six model concerns + sixteen controller concerns**, all production-ready
95
98
  - **One include, one macro** — no boilerplate, no glue code
96
99
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
97
100
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -104,7 +107,7 @@ Article.published.without_deleted.find("hello-world")
104
107
  Add to your application's `Gemfile`:
105
108
 
106
109
  ```ruby
107
- gem "concerns_on_rails", "~> 1.11"
110
+ gem "concerns_on_rails", "~> 1.23"
108
111
  ```
109
112
 
110
113
  Or pull the latest from GitHub:
@@ -124,7 +127,7 @@ bundle install
124
127
  ## 🧪 Compatibility
125
128
 
126
129
  - **Ruby**: 3.2+
127
- - **Rails**: 5.0 through 8.x
130
+ - **Rails**: declared 5.0 through 8.x — in practice the Ruby 3.2 requirement means Rails 7.0.4+ is what can actually install the gem; the test suite runs against Rails 7.1
128
131
 
129
132
  ---
130
133
 
@@ -325,13 +328,17 @@ User.unscoped # everything (deleted + non-deleted)
325
328
  **Bulk operations**
326
329
 
327
330
  ```ruby
328
- User.soft_delete_all # soft-deletes all matching records (explicit — preferred)
329
- User.destroy_all # alias of soft_delete_all (kept for backwards compatibility)
330
- User.really_destroy_all # hard-deletes all matching records
331
- User.restore_all # restores all soft-deleted records
331
+ User.soft_delete_all # soft-deletes all matching records; returns the count (explicit — preferred)
332
+ User.destroy_all # alias of soft_delete_all (kept for backwards compatibility; returns a count, not records)
333
+ User.really_destroy_all # hard-deletes the records matching the CURRENT relation (soft-deleted included)
334
+ User.restore_all # restores the matching soft-deleted records; returns the count
332
335
  ```
333
336
 
334
- All of these run in a transaction, so a raising hook rolls the whole batch back.
337
+ A record that fails to transition raises `ActiveRecord::RecordNotSaved` and rolls the whole
338
+ batch back. With `touch: false` and no overridden hooks, `soft_delete_all` / `restore_all`
339
+ collapse to a single `UPDATE`. Note that `really_destroy_all` peels the soft-delete
340
+ predicate off the relation, so `only_deleted.really_destroy_all` widens to the whole
341
+ relation — purge trash with `User.soft_deleted.delete_all` instead.
335
342
 
336
343
  **Lifecycle hooks** — override these methods on the model:
337
344
 
@@ -1129,6 +1136,99 @@ Counters are adjusted with `update_counters` (a single atomic SQL `COALESCE(col,
1129
1136
 
1130
1137
  ---
1131
1138
 
1139
+ ## 🔏 Encryptable
1140
+
1141
+ Transparent per-field encryption for sensitive columns (SSN, DOB, cards) — AES-256-GCM on stdlib OpenSSL, zero new dependencies. Reads and writes stay plaintext in memory; the DB column holds a versioned, authenticated Base64 envelope.
1142
+
1143
+ ```ruby
1144
+ ConcernsOnRails.configure_encryption { |c| c.key = ENV["ENCRYPTION_KEY"] }
1145
+
1146
+ class Patient < ApplicationRecord
1147
+ include ConcernsOnRails::Models::Encryptable
1148
+
1149
+ encryptable :ssn, :notes # transparent string encryption
1150
+ encryptable :dob, type: :date # decrypts back to a Date
1151
+ encryptable :email, blind_index: true # + a queryable fingerprint column
1152
+ end
1153
+
1154
+ patient.ssn # => "123-45-6789" (plaintext in memory)
1155
+ patient.ssn_ciphertext # => "AQEA..." (what's actually at rest)
1156
+ patient.ssn_encrypted? # => true
1157
+ Patient.find_by_email("a@b.com") # exact-match lookup via the blind index
1158
+ Patient.where_email("a@b.com") # chainable Relation (accepts arrays too)
1159
+ ```
1160
+
1161
+ **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).
1162
+
1163
+ **Notes**
1164
+ - 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.
1165
+ - Ciphertext is non-deterministic (random IV), so `where(ssn: ...)` matches nothing — query through a blind index. `nil` stays `nil`; presence checks work normally.
1166
+ - 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).
1167
+ - 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.
1168
+ - 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).
1169
+
1170
+ ---
1171
+
1172
+ ## 🕵️ Anonymizable
1173
+
1174
+ Declarative right-to-erasure ("GDPR-lite"): each personal-data field gets an erasure strategy, and `anonymize!` rewrites them all in **one UPDATE** while stamping `anonymized_at`. Completes the sensitive-data suite — Maskable masks *display*, Sanitizable strips *HTML*, Encryptable protects *at rest*; Anonymizable **destroys**.
1175
+
1176
+ ```ruby
1177
+ class User < ApplicationRecord
1178
+ include ConcernsOnRails::Models::Anonymizable
1179
+
1180
+ anonymizable :email, with: :email # unique fake address
1181
+ anonymizable :first_name, :last_name, with: :redact
1182
+ anonymizable :ssn, with: :nullify
1183
+ anonymizable :bio, with: ->(value) { value && "removed by user request" }
1184
+ end
1185
+
1186
+ user.anonymize! # hooks + single UPDATE + stamp, in one transaction
1187
+ user.anonymized? # => true
1188
+ User.not_anonymized # scope (and .anonymized)
1189
+ User.where(...).anonymize_all! # batch; returns the count, skips stamped records
1190
+ ```
1191
+
1192
+ **Strategies** (`with:`): `:nullify`, `:redact` (`"[REDACTED]"`), `:hash` (SHA-256 — deterministic *pseudonymization*, joins keep working), `:email` (random unique `anon-…@anonymized.invalid`, so NOT NULL + unique email columns survive), `:random_hex`, or a callable (`->(value)` / `->(value, record)`). Presets pass `nil` through untouched.
1193
+
1194
+ **Options** (repeatable; field rules merge): `stamp:` (default `:anonymized_at`; `false` disables stamping + scopes), `clear_audit_trail:` (default `true` — when an erased field is also `auditable_by`, the trail holding its plaintext history is cleared in the same UPDATE), `prefix:`/`suffix:` (scope names).
1195
+
1196
+ **Notes**
1197
+ - Deliberately `update_columns`: erasure is never blocked by validations and never runs callbacks that could copy old values elsewhere. Values still serialize through the attribute types, so an `encryptable` field stores a fresh ciphertext envelope — never plaintext.
1198
+ - `before_anonymize`/`after_anonymize` hooks run inside the transaction; the record reloads afterwards (erasure is terminal for the instance).
1199
+ - `:hash` is pseudonymization — use `:nullify`/`:random_hex` for true erasure. Backups/replicas/logs are out of scope.
1200
+
1201
+ ---
1202
+
1203
+ ## 🧬 Duplicable
1204
+
1205
+ Concern-aware deep copy — the "clone this invoice / duplicate this template" feature. A bare `dup` copies identity-bearing columns (slug, token, invoice number, audit trail, even `created_at`, which AR preserves on save), so naive copies collide with unique indexes. Duplicable blanks exactly those columns and lets each sibling concern regenerate fresh values on save.
1206
+
1207
+ ```ruby
1208
+ class Invoice < ApplicationRecord
1209
+ include ConcernsOnRails::Models::Duplicable
1210
+
1211
+ has_many :line_items
1212
+ duplicable_by associations: %i[line_items],
1213
+ reset: %i[issued_at],
1214
+ suffix: { title: " (copy)" }
1215
+ end
1216
+
1217
+ copy = invoice.duplicate # unsaved deep copy
1218
+ copy = invoice.duplicate!(title: "Q3") # saved (one transaction, autosaved children)
1219
+ ```
1220
+
1221
+ **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:`.
1222
+
1223
+ **Associations** (`associations:` allow-list, declared before the macro, validated at macro time): `has_many`/`has_one` children are deep-copied — a child that also includes Duplicable copies via **its own** rules, so nested graphs stay declarative; `has_and_belongs_to_many` re-links the *same* records; `belongs_to` and `has_many :through` are rejected with an explanation.
1224
+
1225
+ **Notes**
1226
+ - The macro is optional — bare `include` gives `duplicate`/`duplicate!` with the auto resets.
1227
+ - Override `on_duplicate(copy)` for custom tweaks; it receives the unsaved copy last.
1228
+ - Reach for [`amoeba`](https://github.com/amoeba-rb/amoeba) when you need per-attribute regex/prepend rules or belongs_to graph copying.
1229
+
1230
+ ---
1231
+
1132
1232
  # 🎮 Controller Concerns
1133
1233
 
1134
1234
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -1700,6 +1800,8 @@ Both forms reference the same module, so you can freely mix them.
1700
1800
  | Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
1701
1801
  | Full-text search with ranking / stemming | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
1702
1802
  | 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) |
1803
+ | Field encryption with managed key rotation / Rails-native key infrastructure | [`lockbox`](https://github.com/ankane/lockbox) / Rails 7+ native `encrypts` |
1804
+ | Deep clone with per-attribute regex/prepend rules or belongs_to graph copying | [`amoeba`](https://github.com/amoeba-rb/amoeba) |
1703
1805
 
1704
1806
  `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.
1705
1807
 
@@ -1711,7 +1813,7 @@ Both forms reference the same module, so you can freely mix them.
1711
1813
  bundle install # install dev dependencies
1712
1814
  bundle exec rspec # run the test suite
1713
1815
  gem build concerns_on_rails.gemspec # build the gem
1714
- gem install ./concerns_on_rails-1.11.2.gem # install locally
1816
+ gem install ./concerns_on_rails-1.23.0.gem # install locally
1715
1817
  ```
1716
1818
 
1717
1819
  The test suite uses an in-memory SQLite database and a lightweight `FakeController` harness for controller-concern specs — no Rails routes or boot required.
@@ -1,4 +1,5 @@
1
1
  require "active_support/concern"
2
+ require "concerns_on_rails/support/error_envelope"
2
3
 
3
4
  module ConcernsOnRails
4
5
  module Controllers
@@ -54,7 +55,11 @@ module ConcernsOnRails
54
55
  # a helper_method (which keeps it private on the instance), so the
55
56
  # default public-only check would resolve nil and deny everyone.
56
57
  actor = respond_to?(via, true) ? send(via) : nil
57
- actor.respond_to?(role_method, true) && wanted.include?(actor.send(role_method).to_s)
58
+ # Array() handles both a scalar role and an array-valued `roles`
59
+ # method — pre-1.22 an actor with roles = ["admin"] stringified to
60
+ # '["admin"]' and was always denied.
61
+ actor.respond_to?(role_method, true) &&
62
+ Array(actor.send(role_method)).any? { |role| wanted.include?(role.to_s) }
58
63
  end
59
64
  add_authorization_rule(check: check, only: only, except: except, status: status, message: message)
60
65
  end
@@ -87,13 +92,16 @@ module ConcernsOnRails
87
92
  nil
88
93
  end
89
94
 
90
- # Public override point for how a denial is rendered.
95
+ # Public override point for how a denial is rendered. Fails CLOSED: when
96
+ # there is no response object to render into, raise — returning nil here
97
+ # (the pre-1.22 behavior) let the action run unauthorized.
91
98
  def authorization_denied(status:, message:)
92
- return unless respond_to?(:response) && response
93
-
94
- return render_error(message: message, status: status, code: "forbidden") if respond_to?(:render_error)
99
+ unless respond_to?(:response) && response
100
+ raise "ConcernsOnRails::Controllers::Authorizable: denial for '#{authorization_action_name}' " \
101
+ "could not be rendered (no response object) refusing to fail open"
102
+ end
95
103
 
96
- render json: { success: false, error: { message: message, code: "forbidden" } }, status: status
104
+ ConcernsOnRails::Support::ErrorEnvelope.render(self, message: message, status: status, code: "forbidden")
97
105
  end
98
106
 
99
107
  private
@@ -61,6 +61,13 @@ module ConcernsOnRails
61
61
 
62
62
  included do
63
63
  class_attribute :cacheable_rules, instance_accessor: false, default: []
64
+ # no_store is applied EARLY as well: rescue_from-rendered errors skip
65
+ # after_action entirely, and for a no_store endpoint (tokens, PII) even
66
+ # the error response must not be stored. Positive freshness policies
67
+ # deliberately do NOT move early — rescued errors already get Rails'
68
+ # safe `max-age=0, private` default, and emitting `public, max-age` on
69
+ # a rescued 500 would let shared caches serve the error.
70
+ prepend_before_action :apply_http_no_store if respond_to?(:prepend_before_action)
64
71
  after_action :apply_http_cache_headers
65
72
  end
66
73
 
@@ -143,12 +150,26 @@ module ConcernsOnRails
143
150
  nil
144
151
  end
145
152
 
153
+ # Early half of the policy (see the included block): only no_store, and
154
+ # idempotent with apply_http_cache_headers re-applying it after render.
155
+ def apply_http_no_store
156
+ rule = http_cache_rule_for_action
157
+ return unless rule && rule[:no_store]
158
+ return unless respond_to?(:response) && response
159
+
160
+ response.set_header("Cache-Control", "no-store")
161
+ end
162
+
146
163
  # Set ETag/Last-Modified validators for the resource, then — for a safe
147
164
  # request whose precondition matches — send 304 and return false. Returns
148
165
  # true when the client must be sent a fresh body. Mirrors Rails `stale?`.
149
166
  def stale_resource?(resource = nil, etag: nil, last_modified: nil)
150
- validators = set_cache_validators(resource, etag: etag, last_modified: last_modified)
167
+ # Unsafe methods get neither a 304 nor validators: a POST response must
168
+ # not advertise an ETag a client could replay against GET (pre-1.22 the
169
+ # validators were written before the safe-method check).
151
170
  return true unless http_cache_safe_request?
171
+
172
+ validators = set_cache_validators(resource, etag: etag, last_modified: last_modified)
152
173
  return true unless request_matches_cache?(etag: validators[:etag], last_modified: validators[:last_modified])
153
174
 
154
175
  http_cache_send_not_modified
@@ -225,13 +246,10 @@ module ConcernsOnRails
225
246
  end
226
247
 
227
248
  def http_cache_send_not_modified
228
- response.status = 304 if respond_to?(:response) && response
249
+ return head(:not_modified) if respond_to?(:head)
229
250
 
230
- if respond_to?(:head)
231
- head :not_modified
232
- else
233
- render(status: :not_modified)
234
- end
251
+ response.status = 304 if respond_to?(:response) && response
252
+ render(status: :not_modified)
235
253
  end
236
254
 
237
255
  def http_cache_safe_request?
@@ -1,4 +1,6 @@
1
1
  require "active_support/concern"
2
+ require "concerns_on_rails/support/error_envelope"
3
+ require "concerns_on_rails/support/scalar_param"
2
4
  require "json"
3
5
  require "time" # Time#iso8601(fraction_digits) lives in the stdlib time library
4
6
 
@@ -56,6 +58,9 @@ module ConcernsOnRails
56
58
  CURSOR_DIRECTIONS = %w[next prev].freeze
57
59
  # Adapters whose SQL supports row-value (tuple) comparison: (a, b) > (x, y).
58
60
  ROW_PREDICATE_ADAPTERS = /postgres|mysql|trilogy|sqlite/i
61
+ # Deliberately mutable: memoizes adapter row-predicate support per model
62
+ # class — stable for the life of the process, benign write race.
63
+ ROW_PREDICATE_SUPPORT_CACHE = {} # rubocop:disable Style/MutableConstant
59
64
 
60
65
  # Raised when params[:cursor] is malformed, tampered with, or was minted
61
66
  # under a different table/order configuration. Auto-rescued to a 400 when
@@ -195,16 +200,16 @@ module ConcernsOnRails
195
200
  # Public override point (mirrors ErrorHandleable's public handlers):
196
201
  # delegates to Respondable#render_error when available.
197
202
  def render_invalid_cursor(error)
198
- return render_error(message: error.message, status: :bad_request, code: "invalid_cursor") if respond_to?(:render_error)
199
-
200
- render json: { success: false, error: { message: error.message, code: "invalid_cursor" } }, status: :bad_request
203
+ ConcernsOnRails::Support::ErrorEnvelope.render(
204
+ self, message: error.message, status: :bad_request, code: "invalid_cursor"
205
+ )
201
206
  end
202
207
 
203
208
  # Same override contract for unknown ?order= preset names.
204
209
  def render_invalid_order_preset(error)
205
- return render_error(message: error.message, status: :bad_request, code: "invalid_order_preset") if respond_to?(:render_error)
206
-
207
- render json: { success: false, error: { message: error.message, code: "invalid_order_preset" } }, status: :bad_request
210
+ ConcernsOnRails::Support::ErrorEnvelope.render(
211
+ self, message: error.message, status: :bad_request, code: "invalid_order_preset"
212
+ )
208
213
  end
209
214
 
210
215
  private
@@ -311,7 +316,9 @@ module ConcernsOnRails
311
316
  end
312
317
 
313
318
  def cursor_per_page(override)
314
- requested = (override || params[:per_page]).to_i
319
+ # ScalarParam: `?per_page[]=50` arrives as an Array and .to_i on it was
320
+ # a 500 (mirrors Paginatable).
321
+ requested = ConcernsOnRails::Support::ScalarParam.to_i(override || params[:per_page], default: 0)
315
322
  requested = self.class.cursor_paginatable_per_page if requested < 1
316
323
  cap = self.class.cursor_paginatable_max_per_page
317
324
  cap.positive? ? [requested, cap].min : requested
@@ -462,7 +469,15 @@ module ConcernsOnRails
462
469
 
463
470
  return true
464
471
  end
465
- uniform && model.connection.adapter_name.match?(ROW_PREDICATE_ADAPTERS)
472
+ uniform && cursor_adapter_row_predicate?(model)
473
+ end
474
+
475
+ def cursor_adapter_row_predicate?(model)
476
+ key = model.name || model.table_name
477
+ cached = ROW_PREDICATE_SUPPORT_CACHE[key]
478
+ return cached unless cached.nil?
479
+
480
+ ROW_PREDICATE_SUPPORT_CACHE[key] = model.connection.adapter_name.match?(ROW_PREDICATE_ADAPTERS)
466
481
  end
467
482
 
468
483
  def cursor_row_predicate(model, pairs, values)
@@ -1,4 +1,5 @@
1
1
  require "active_support/concern"
2
+ require "concerns_on_rails/support/error_envelope"
2
3
  require "active_support/notifications"
3
4
  require "date"
4
5
  require "time"
@@ -122,9 +123,14 @@ module ConcernsOnRails
122
123
 
123
124
  def validate_deprecation_link!(name, value)
124
125
  return if value.nil?
125
- return if value.is_a?(String) && !value.strip.empty?
126
126
 
127
- raise ArgumentError, "#{LABEL}: :#{name} must be a non-blank String"
127
+ raise ArgumentError, "#{LABEL}: :#{name} must be a non-blank String" unless value.is_a?(String) && !value.strip.empty?
128
+ return unless value.match?(/[[:cntrl:]<>]/)
129
+
130
+ # These values are interpolated into the Link header: a control
131
+ # character would split the header (response splitting), and <> would
132
+ # terminate or forge the URI-Reference delimiters.
133
+ raise ArgumentError, "#{LABEL}: :#{name} must not contain control characters or '<'/'>'"
128
134
  end
129
135
 
130
136
  # Eager parse to a UTC Time. A bare Date (or date-only String) becomes
@@ -194,14 +200,17 @@ module ConcernsOnRails
194
200
  private
195
201
 
196
202
  def deprecation_rule_for_action
197
- action = deprecation_action_name
198
- return nil unless action
203
+ # Memoized (nil included) — the before_action plus the two public
204
+ # predicates each re-scanned the rule list every request.
205
+ return @deprecation_rule_for_action if defined?(@deprecation_rule_for_action)
199
206
 
207
+ action = deprecation_action_name
200
208
  # Last match wins — see the module comment. reverse_each.find returns the
201
209
  # most recently declared rule covering this action (catch-all or not).
202
- self.class.deprecatable_rules.reverse_each.find do |rule|
203
- rule[:actions].empty? || rule[:actions].include?(action)
204
- end
210
+ @deprecation_rule_for_action =
211
+ action && self.class.deprecatable_rules.reverse_each.find do |rule|
212
+ rule[:actions].empty? || rule[:actions].include?(action)
213
+ end
205
214
  end
206
215
 
207
216
  def emit_deprecation_headers(rule)
@@ -238,10 +247,9 @@ module ConcernsOnRails
238
247
  return unless deprecation_sunset_reached?(rule)
239
248
 
240
249
  message = "This endpoint was sunset on #{rule[:sunset_at].httpdate}."
241
- return render_error(message: message, status: :gone, code: "endpoint_sunset") if respond_to?(:render_error)
242
- return unless respond_to?(:response) && response
250
+ return unless respond_to?(:render_error) || (respond_to?(:response) && response)
243
251
 
244
- render json: { success: false, error: { message: message, code: "endpoint_sunset" } }, status: :gone
252
+ ConcernsOnRails::Support::ErrorEnvelope.render(self, message: message, status: :gone, code: "endpoint_sunset")
245
253
  end
246
254
 
247
255
  # Inclusive: the boundary instant itself counts as sunset.
@@ -1,4 +1,5 @@
1
1
  require "active_support/concern"
2
+ require "concerns_on_rails/support/error_envelope"
2
3
 
3
4
  module ConcernsOnRails
4
5
  module Controllers
@@ -64,11 +65,9 @@ module ConcernsOnRails
64
65
  private
65
66
 
66
67
  def render_error_envelope(message:, code:, status:, errors: nil)
67
- return render_error(message: message, code: code, status: status, errors: errors) if respond_to?(:render_error)
68
-
69
- error = { message: message, code: code }
70
- error[:details] = errors if errors
71
- render json: { success: false, error: error }, status: status
68
+ ConcernsOnRails::Support::ErrorEnvelope.render(
69
+ self, message: message, code: code, status: status, details: errors
70
+ )
72
71
  end
73
72
  end
74
73
  end
@@ -1,4 +1,5 @@
1
1
  require "active_support/concern"
2
+ require "concerns_on_rails/support/scalar_param"
2
3
 
3
4
  module ConcernsOnRails
4
5
  module Controllers
@@ -70,13 +71,12 @@ module ConcernsOnRails
70
71
  end
71
72
  end
72
73
 
73
- # Scalars (and arrays, which AR turns into `IN (...)`) are safe to pass to
74
- # .where; a Hash / ActionController::Parameters is not.
74
+ # Scalars (and arrays of scalars, which AR turns into `IN (...)`) are safe
75
+ # to pass to .where; a Hash / ActionController::Parameters is not — and
76
+ # since 1.22 neither is an array CONTAINING one (`?status[][x]=1` used to
77
+ # slip through as [Parameters] and 500 with a TypeError).
75
78
  def filterable_scalar?(value)
76
- return false if value.is_a?(Hash)
77
- return false if defined?(ActionController::Parameters) && value.is_a?(ActionController::Parameters)
78
-
79
- true
79
+ ConcernsOnRails::Support::ScalarParam.where_safe?(value)
80
80
  end
81
81
  end
82
82
  end
@@ -1,4 +1,5 @@
1
1
  require "active_support/concern"
2
+ require "concerns_on_rails/support/error_envelope"
2
3
  require "digest"
3
4
  require "json"
4
5
 
@@ -136,9 +137,7 @@ module ConcernsOnRails
136
137
  def idempotency_error_response(message:, status:, code:)
137
138
  return unless respond_to?(:response) && response
138
139
 
139
- return render_error(message: message, status: status, code: code) if respond_to?(:render_error)
140
-
141
- render json: { success: false, error: { message: message, code: code } }, status: status
140
+ ConcernsOnRails::Support::ErrorEnvelope.render(self, message: message, status: status, code: code)
142
141
  end
143
142
 
144
143
  private
@@ -209,15 +208,25 @@ module ConcernsOnRails
209
208
 
210
209
  # In flight — or the claim expired between our failed write and this
211
210
  # read (rare); answering 409 is the conservative, retry-safe choice.
212
- idempotency_conflict_response(rule)
211
+ idempotency_conflict_response(rule, record)
213
212
  end
214
213
 
215
- def idempotency_conflict_response(rule)
216
- response.set_header("Retry-After", rule[:lock_ttl].to_s) if respond_to?(:response) && response
214
+ def idempotency_conflict_response(rule, record = nil)
215
+ response.set_header("Retry-After", idempotency_retry_after(rule, record).to_s) if respond_to?(:response) && response
217
216
  idempotency_error_response(message: "A request with this #{rule[:header]} is already in progress.",
218
217
  status: :conflict, code: "idempotency_conflict")
219
218
  end
220
219
 
220
+ # Remaining lock time (floored at 1s), not the full lock TTL — the claim
221
+ # record carries when it was taken, so clients don't back off for a lock
222
+ # that is about to expire.
223
+ def idempotency_retry_after(rule, record)
224
+ claimed_at = record.is_a?(Hash) ? record["claimed_at"] : nil
225
+ return rule[:lock_ttl] unless claimed_at
226
+
227
+ [rule[:lock_ttl] - (Time.now.to_i - claimed_at.to_i), 1].max
228
+ end
229
+
221
230
  def read_idempotency_header(rule)
222
231
  return nil unless respond_to?(:request) && request.respond_to?(:headers) && request.headers
223
232
 
@@ -65,7 +65,11 @@ module ConcernsOnRails
65
65
  return {} unless raw.respond_to?(:each_pair)
66
66
 
67
67
  allowed = self.class.includable_fields
68
- raw.each_with_object({}) do |(table, cols), memo|
68
+ # Iterate via each_pair: in a real controller `raw` is an
69
+ # ActionController::Parameters, which has each_pair but no Enumerable —
70
+ # calling each_with_object directly on it was a guaranteed
71
+ # NoMethodError 500 for every ?fields[...]= request.
72
+ raw.each_pair.with_object({}) do |(table, cols), memo|
69
73
  key = table.to_sym
70
74
  next unless allowed.key?(key)
71
75