concerns_on_rails 1.28.7 → 1.28.9
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 +4 -4
- data/CHANGELOG.md +180 -0
- data/README.md +82 -15
- data/lib/concerns_on_rails/controllers/filterable.rb +213 -10
- data/lib/concerns_on_rails/controllers/respondable.rb +120 -3
- data/lib/concerns_on_rails/controllers/webhook_verifiable.rb +152 -2
- data/lib/concerns_on_rails/encryption.rb +59 -0
- data/lib/concerns_on_rails/models/duplicable.rb +9 -1
- data/lib/concerns_on_rails/models/encryptable.rb +196 -18
- data/lib/concerns_on_rails/models/lockable.rb +138 -16
- data/lib/concerns_on_rails/models/stateable.rb +84 -13
- data/lib/concerns_on_rails/models/taggable.rb +38 -17
- data/lib/concerns_on_rails/support/encryptor.rb +33 -11
- data/lib/concerns_on_rails/support/error_envelope.rb +20 -4
- data/lib/concerns_on_rails/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f292a3f1cf7d1fae3b1e47be9daa416ae0b6c8b69ce0d4fd2efd583496f2d9aa
|
|
4
|
+
data.tar.gz: 661ffce3f3fa4fb305957b239fbb560752ea22f369da3268d03575efb35701da
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a49931859e637327e55313e7473236a4ceca6a5f61c01f7f701a7d7acba317c8fe6a83ec05ec2dafa5d7e61da45d8d2623a26f15a876d9e7d12036cd9c40c451
|
|
7
|
+
data.tar.gz: 625395f36729c2d789b303cfeec6537dfa173364b70c0a6c971707616a0fb3161d5eef083417aadcc9586a7b99c89d9a7617355e08284cdb76d210a62a3df82f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,185 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.28.9 (2026-09-19)
|
|
4
|
+
|
|
5
|
+
CI now runs the matrix the gemspec actually claims: Rails 7.0–8.0 across Ruby 3.2/3.3/3.4,
|
|
6
|
+
plus **PostgreSQL and MySQL** alongside SQLite. The suite had only ever run on SQLite, which
|
|
7
|
+
is the most permissive of the three, and the first green-on-SQLite run of the new matrix
|
|
8
|
+
failed 29 examples on MySQL and 7 on PostgreSQL.
|
|
9
|
+
|
|
10
|
+
Almost all of those were specs asserting SQLite's own SQL rather than the gem misbehaving —
|
|
11
|
+
but one was a real bug, and it is the reason this is a release and not just a CI change.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **Models::Taggable**: `tagged_with` was broken on MySQL and inconsistent on PostgreSQL.
|
|
15
|
+
- On **MySQL** every `tagged_with` query was a **syntax error**. The clause inlined
|
|
16
|
+
`ESCAPE '\'` into hand-written SQL, and MySQL treats backslash as an escape character
|
|
17
|
+
inside string literals, so the escape consumed its own closing quote. SQLite and
|
|
18
|
+
PostgreSQL (with `standard_conforming_strings`) read it as a literal backslash, which is
|
|
19
|
+
why this was invisible for so long. The clause is now built with Arel `matches`, so the
|
|
20
|
+
adapter quotes the escape character itself — one code path, no per-adapter branching.
|
|
21
|
+
- On **PostgreSQL** `tagged_with` was case-SENSITIVE, while on SQLite and MySQL it folded
|
|
22
|
+
case: the same query returned different rows depending on the database. It is now
|
|
23
|
+
case-insensitive on all three (`ILIKE` on PostgreSQL).
|
|
24
|
+
|
|
25
|
+
⚠️ **PostgreSQL users: this widens `tagged_with`.** A lookup that was case-sensitive before
|
|
26
|
+
now also matches differently-cased tags. That is the documented intent of the concern and
|
|
27
|
+
it makes the three adapters agree, but it is a behaviour change on upgrade. If you relied
|
|
28
|
+
on case-sensitive matching, declare `taggable_by :tags, downcase: true` and fold on write.
|
|
29
|
+
SQLite and MySQL users see no change. Note the Ruby-side helpers (`tagged_with?`,
|
|
30
|
+
`all_tags`, `tag_counts`) still compare exactly — `downcase: true` is what makes the scope
|
|
31
|
+
and the helpers agree.
|
|
32
|
+
|
|
33
|
+
### Internal
|
|
34
|
+
- **CI**: `.github/workflows/ci.yml` gains a Rails-version axis (7.0, 7.1, 7.2, 8.0 × Ruby
|
|
35
|
+
3.2/3.3/3.4, via `gemfiles/*.gemfile`) and an adapter axis (PostgreSQL and MySQL service
|
|
36
|
+
containers). `DB=postgresql` / `DB=mysql2` selects the adapter locally; SQLite stays the
|
|
37
|
+
default so a plain checkout still needs no services. (#102)
|
|
38
|
+
- **Specs**: examples that asserted SQL now build the expected fragment from the
|
|
39
|
+
connection's own quoting, via new `TestDatabase.quoted_table` / `quoted_column` /
|
|
40
|
+
`qualified` helpers and `sqlite?` / `postgresql?` / `mysql?` predicates — so an assertion
|
|
41
|
+
means the same thing on every adapter instead of encoding SQLite's. Several examples got
|
|
42
|
+
*stronger* in the process: the Sortable NULL-ordering pair now asserts the PostgreSQL
|
|
43
|
+
native-`NULLS` branch and the portable-`CASE` branch separately, each also asserting the
|
|
44
|
+
other is absent.
|
|
45
|
+
- Three examples depended on SQLite-only tolerance rather than on the gem: a grouped
|
|
46
|
+
relation selecting `*` (rejected by PostgreSQL, and by MySQL under `only_full_group_by`),
|
|
47
|
+
a non-finite Float written to a float column (SQLite's adapter overrides `quote` for
|
|
48
|
+
those; MySQL emits a bare `NaN`), and a NULL page-boundary fixture that assumed SQLite's
|
|
49
|
+
NULLs-first ordering (PostgreSQL sorts NULLs last ascending).
|
|
50
|
+
|
|
51
|
+
### Known gap
|
|
52
|
+
- `Models::Storable` guards its JSON extraction with `json_valid` on SQLite only. MySQL has
|
|
53
|
+
`JSON_VALID()` (5.7.8+) and could have the same guard; it was left alone deliberately
|
|
54
|
+
rather than shipped unverified, since a wrong guess there would break every
|
|
55
|
+
`where_<key>` query on that adapter. PostgreSQL has no equivalent before PG 16's
|
|
56
|
+
`IS JSON`. Now that the MySQL leg runs, this is a small follow-up.
|
|
57
|
+
|
|
58
|
+
## 1.28.8 (2026-09-19)
|
|
59
|
+
|
|
60
|
+
The last six open feature PRs, released as a patch by request. These had never been
|
|
61
|
+
reviewed — they were excluded from the previous two waves because they would not merge —
|
|
62
|
+
so each was rebased onto master, reviewed adversarially, and fixed. **Five of the six
|
|
63
|
+
carried a CRITICAL defect**, two of which could destroy data. The `### Fixed` section is
|
|
64
|
+
the important one; several entries describe behaviour that never worked as documented.
|
|
65
|
+
|
|
66
|
+
Also fixes a live defect in `Support::ErrorEnvelope` found during the review, which
|
|
67
|
+
affected eight concerns on 1.28.7 and earlier. 1828 examples, 0 failures.
|
|
68
|
+
|
|
69
|
+
### Added
|
|
70
|
+
- **Models::Encryptable**: key rotation. `ConcernsOnRails.configure_encryption { |c|
|
|
71
|
+
c.key_id = 1; c.previous_keys = { 0 => old } }` — new writes stamp `key_id`, reads
|
|
72
|
+
decrypt with whichever configured key the envelope names (unknown id → `DecryptionError`
|
|
73
|
+
naming it), and blind-index lookups match current + previous digests.
|
|
74
|
+
`Model.needs_reencryption(*fields)`, `Model.reencrypt_all!(*fields)` /
|
|
75
|
+
`record.reencrypt!`, and `<field>_key_id`. Per-field `key:` fields sit outside rotation.
|
|
76
|
+
The envelope format is unchanged: 1.28.x already wrote and authenticated a `key_id` byte,
|
|
77
|
+
so every existing row is a key-id-0 envelope and decrypts untouched after upgrading. (#77)
|
|
78
|
+
- **Models::Lockable**: `lockable_by … unlock_token:` mints a URL-safe token in the same
|
|
79
|
+
write as the lock, for self-service unlock links without Devise.
|
|
80
|
+
`User.unlock_by_token(token)` consumes it once — constant-time compare, one conditional
|
|
81
|
+
UPDATE decides the winner, hooks fire — and the token lives exactly as long as the lock.
|
|
82
|
+
Cleared on every unlock path, reset by Duplicable, and registered with the
|
|
83
|
+
filter_parameters registry. (#61)
|
|
84
|
+
- **Models::Stateable**: `stateable_by … timestamps: true` (or a list of states) stamps
|
|
85
|
+
`<state>_at` in the same write as the state change, for guarded events, direct setters,
|
|
86
|
+
`transition_to!` and `transition_all`. Per-event `before_<event>` / `after_<event>` hooks
|
|
87
|
+
fire inside the generic `before_transition` / `after_transition` pair and the same
|
|
88
|
+
transaction, and may be declared `private`. (#49)
|
|
89
|
+
- **Controllers::Filterable**: `filter_by … operators: true` (or a subset) adds `not`,
|
|
90
|
+
`gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `null`, `contains` and `starts_with` to
|
|
91
|
+
direct-where filters, read as a suffix (`?price_gte=10`) or in bracket form
|
|
92
|
+
(`?price[gte]=10`). Every operator comes from a frozen allow-list mapped to a fixed Arel
|
|
93
|
+
node, so no param string reaches SQL. Values are cast through the column's type or the
|
|
94
|
+
new `type:` option, which also pre-casts what a `with:` lambda receives. Opt-in per
|
|
95
|
+
filter; existing filters are unchanged. (#46)
|
|
96
|
+
- **Controllers::WebhookVerifiable**: `verify_webhook … replay:` / `replay_ttl:` rejects a
|
|
97
|
+
redelivery with 409 `webhook_replayed` — replay protection for the schemes that carry no
|
|
98
|
+
timestamp (GitHub, Shopify, plain HMAC). The check runs only after the signature
|
|
99
|
+
verifies. A short claim is taken before the action and promoted on completion, and
|
|
100
|
+
released on a 5xx so a transient failure does not burn the delivery id. (#60)
|
|
101
|
+
- **Controllers::Respondable**: `render_success` accepts `location:` and `headers:`, and
|
|
102
|
+
gains `render_created(data:, location:)` (201 + `Location`) and `render_invalid(record)`
|
|
103
|
+
(422 + the record's `full_messages`). Header names and values are stripped of bytes a
|
|
104
|
+
header line cannot carry, and a `Link` value is appended to an existing one — matched
|
|
105
|
+
case-insensitively — rather than replacing it. Works in both the envelope and the
|
|
106
|
+
problem-details format. (#105, replacing #74)
|
|
107
|
+
|
|
108
|
+
### Changed
|
|
109
|
+
- **Controllers::Filterable**: an uncastable comparison value (`?price_gte=abc`) returns
|
|
110
|
+
`none` rather than matching. Fail-closed was chosen because ignoring the filter would
|
|
111
|
+
return the very rows the client tried to exclude; it matches what plain `where` already
|
|
112
|
+
does with the same input, and is scoped to the four new comparison operators so no
|
|
113
|
+
pre-existing filter changes on upgrade. `contains` / `starts_with` are case-INsensitive
|
|
114
|
+
on every adapter — there is no `case_sensitive:` option. (#46)
|
|
115
|
+
- **Models::Stateable**: `timestamps:` refuses to derive a stamp column from a state named
|
|
116
|
+
`created` or `updated`, which would otherwise target Rails' own `created_at`/`updated_at`.
|
|
117
|
+
Stamp columns are NOT affixed by `prefix:`/`suffix:`, so a state named `published` or
|
|
118
|
+
`deleted` collides with Publishable's and SoftDeletable's columns — rename the state or
|
|
119
|
+
leave it out of `timestamps:`. (#49)
|
|
120
|
+
- **Models::Encryptable**: `record.reencrypt!` rewrites through a guarded `UPDATE` that
|
|
121
|
+
matches the ciphertext read at load, then reloads. A row written by someone else in the
|
|
122
|
+
meantime is left alone instead of being reverted, and a field with unsaved changes is
|
|
123
|
+
skipped rather than silently committed. (#77)
|
|
124
|
+
|
|
125
|
+
### Fixed
|
|
126
|
+
- **Support::ErrorEnvelope**: the `errors:` keyword was passed to a host app's
|
|
127
|
+
`render_error` whenever details were present, but several concerns document the override
|
|
128
|
+
contract as `render_error(message:, status:, code:)`. Such an app got `ArgumentError:
|
|
129
|
+
unknown keyword: :errors` at request time — a 500 instead of the 4xx, on exactly the path
|
|
130
|
+
that has something to report. The previous guard tested `details` alone, so it covered
|
|
131
|
+
only the empty case, i.e. the one that was never broken. This reached every concern that
|
|
132
|
+
funnels through the envelope, most commonly ErrorHandleable's rescued `RecordInvalid`.
|
|
133
|
+
- **Models::Encryptable**: `previous_keys=` printed the offending hash's keys on a bad
|
|
134
|
+
shape, so an inverted `{ ENV["KEY_V1"] => 0 }` raised **with the live key in the exception
|
|
135
|
+
message** — into logs, backtraces and error trackers. Only the value's class is reported
|
|
136
|
+
now. (#77)
|
|
137
|
+
- **Models::Encryptable**: `needs_reencryption` detected MySQL by adapter name, so Trilogy
|
|
138
|
+
(Rails 7.1+) and MariaDB fell through to a case-folding comparison and returned **zero
|
|
139
|
+
rows**. `reencrypt_all!` then reported 0, an operator would conclude the rotation was
|
|
140
|
+
complete and drop the old key, and every pre-rotation row would become permanently
|
|
141
|
+
undecryptable. (#77)
|
|
142
|
+
- **Models::Lockable**: a `before_unlock` / `after_unlock` hook that vetoed the unlock
|
|
143
|
+
permanently burned the token while leaving the account locked — the claim ran in a bare
|
|
144
|
+
transaction that joined the enclosing one, so `unlock_access!`'s savepoint rolled back
|
|
145
|
+
while the claim committed. The user's emailed link was dead with no way back. The claim
|
|
146
|
+
now takes its own savepoint and is rolled back with the unlock. (#61)
|
|
147
|
+
- **Models::Lockable**: `unlock_by_token` no longer honours a token once the lock has
|
|
148
|
+
lapsed, and no longer uses `unscoped` — a mailed link could otherwise reach a row hidden
|
|
149
|
+
behind a `default_scope` (a soft-deleted or deactivated account). (#61)
|
|
150
|
+
- **Controllers::WebhookVerifiable**: a store answering `#write` but not `#read` passed
|
|
151
|
+
declaration-time validation and then provided **no replay protection at all**, silently —
|
|
152
|
+
the endpoint looked protected and accepted every redelivery. Both methods are now
|
|
153
|
+
required. (#60)
|
|
154
|
+
- **Controllers::WebhookVerifiable**: the Stripe replay key hashed the raw
|
|
155
|
+
`Stripe-Signature` header, which is *parsed* rather than compared — appending an unknown
|
|
156
|
+
`v0=…` pair or re-spacing the commas produced a still-valid header with a fresh key,
|
|
157
|
+
defeating the protection. Stripe now keys off the signed `"<timestamp>.<body>"` payload.
|
|
158
|
+
(#60)
|
|
159
|
+
- **Controllers::WebhookVerifiable**: `replay: false` raised at class load, so
|
|
160
|
+
`replay: Rails.env.production?` broke controller loading in development and test. It is
|
|
161
|
+
now a synonym for "off". (#60)
|
|
162
|
+
- **Controllers::Respondable**: caller-supplied header **names** reached the response
|
|
163
|
+
unsanitized, so a CR/LF in an interpolated name was response splitting. Names now go
|
|
164
|
+
through the same filter as values, widened from CR/LF to the full illegal-byte range.
|
|
165
|
+
(#105)
|
|
166
|
+
- **Controllers::Filterable**: a bracket-form operator with a blank value
|
|
167
|
+
(`?price[gte]=`) became `price > NULL` and returned **nothing**, while the documented
|
|
168
|
+
equivalent `?price_gte=` correctly returned everything — an empty min/max box silently
|
|
169
|
+
emptied the result set. Both forms now skip blanks, and both honour a boolean `false`.
|
|
170
|
+
(#46)
|
|
171
|
+
- **Controllers::Filterable**: `?stock_gt=twelve` silently became `stock > 0` and returned
|
|
172
|
+
the stocked rows (`ActiveModel::Type::Integer#cast("twelve")` is `0`, not `nil`), while
|
|
173
|
+
the same typo on a datetime column returned none — one malformed request answered two
|
|
174
|
+
opposite ways. (#46)
|
|
175
|
+
- **Controllers::Filterable**: LIKE escaping no longer relies on `sanitize_sql_like`, which
|
|
176
|
+
is not public before Rails 5.1 while the gemspec claims `>= 5.0`. (#46)
|
|
177
|
+
|
|
178
|
+
### Internal
|
|
179
|
+
- Docs corrected: "Never `update_column(s)` an encrypted field — those bypass the type and
|
|
180
|
+
write raw plaintext" was **false** (`update_columns` serializes through the attribute
|
|
181
|
+
type), and `reencrypt_all!` is built on it. (#77)
|
|
182
|
+
|
|
3
183
|
## 1.28.7 (2026-09-19)
|
|
4
184
|
|
|
5
185
|
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,
|
|
151
|
+
- **Tested like an app, not a snippet** — **1,830 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
|
|
@@ -1180,6 +1200,7 @@ Article.published.tag_counts(limit: 20) # => { "ruby" => 12, "rails" => 7
|
|
|
1180
1200
|
|
|
1181
1201
|
**Notes**
|
|
1182
1202
|
- Matching is **boundary-safe** — searching `rail` does not match `rails`. An explicit SQL `ESCAPE` clause makes tags containing `_` / `%` match literally on every adapter.
|
|
1203
|
+
- `tagged_with` matches **case-insensitively on every adapter** — `LIKE` on SQLite and MySQL, `ILIKE` on PostgreSQL — so one call means one thing everywhere (how non-ASCII characters fold is still the database collation's business). The Ruby-side helpers (`tagged_with?`, `all_tags`, `tag_counts`) compare exactly, so `downcase: true` — which folds on write — is what makes the scope and the helpers agree.
|
|
1183
1204
|
- Tags are normalized in `before_validation`, so a direct `record.tags = "a, b"` assignment is cleaned too. An empty list stores `NULL`.
|
|
1184
1205
|
- `tag_counts` runs one `GROUP BY` on the raw column — identical tag strings ship once with their row count and are split in Ruby — so it scales with distinct tag strings, not rows; ordered by count desc then name, `limit:` keeps the top N.
|
|
1185
1206
|
- Reach for [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) when you need tag contexts, ownership, or polymorphic tags shared across models.
|
|
@@ -1360,7 +1381,7 @@ One entry is recorded **per changed field per save** (creates record `"from" =>
|
|
|
1360
1381
|
|
|
1361
1382
|
## 🔐 Lockable
|
|
1362
1383
|
|
|
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
|
|
1384
|
+
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
1385
|
|
|
1365
1386
|
```ruby
|
|
1366
1387
|
class User < ApplicationRecord
|
|
@@ -1380,9 +1401,14 @@ user.unlock_access! # manual unlock (hooks: before/after_unlock)
|
|
|
1380
1401
|
User.locked / User.unlocked # expiry-aware scopes
|
|
1381
1402
|
|
|
1382
1403
|
User.unlock_expired # => 3 — unlocks every row whose unlock_in window has elapsed
|
|
1404
|
+
|
|
1405
|
+
# Self-service unlock (Devise's :email strategy, minus the mailer) — needs a string column:
|
|
1406
|
+
# lockable_by max_attempts: 5, unlock_token: :unlock_token
|
|
1407
|
+
user.lock_access!; user.unlock_token # minted in the same write as the lock — mail it as a link
|
|
1408
|
+
User.unlock_by_token(params[:token]) # constant-time lookup; unlocks once (hooks fire), returns the user or nil
|
|
1383
1409
|
```
|
|
1384
1410
|
|
|
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).
|
|
1411
|
+
**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
1412
|
|
|
1387
1413
|
**Bulk operations**
|
|
1388
1414
|
|
|
@@ -1532,13 +1558,31 @@ Patient.where_email("a@b.com") # chainable Relation (accepts arrays too)
|
|
|
1532
1558
|
|
|
1533
1559
|
**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
1560
|
|
|
1561
|
+
**Key rotation** — bump the key id, keep the old key for decrypting, re-encrypt, drop the old key:
|
|
1562
|
+
|
|
1563
|
+
```ruby
|
|
1564
|
+
ConcernsOnRails.configure_encryption do |c|
|
|
1565
|
+
c.key = ENV["ENCRYPTION_KEY_V2"] # encrypts every new write
|
|
1566
|
+
c.key_id = 1 # stamped into the envelope header (any id 0..255)
|
|
1567
|
+
c.previous_keys = { 0 => ENV["ENCRYPTION_KEY_V1"] } # still DECRYPTS rows written before the rotation
|
|
1568
|
+
end
|
|
1569
|
+
|
|
1570
|
+
Patient.needs_reencryption.count # rows still under an old key — a prefix compare on the envelope, no decryption
|
|
1571
|
+
Patient.reencrypt_all! # rewrite them (and their blind indexes) under the current key → count
|
|
1572
|
+
patient.ssn_key_id # => 1
|
|
1573
|
+
# then remove `0 =>` from previous_keys
|
|
1574
|
+
```
|
|
1575
|
+
|
|
1576
|
+
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.
|
|
1577
|
+
|
|
1535
1578
|
**Notes**
|
|
1536
1579
|
- 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
1580
|
- Ciphertext is non-deterministic (random IV), so `where(ssn: ...)` matches nothing — query through a blind index. `nil` stays `nil`; presence checks work normally.
|
|
1538
1581
|
- `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
|
-
-
|
|
1582
|
+
- `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
1583
|
- 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
|
-
-
|
|
1584
|
+
- 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.
|
|
1585
|
+
- 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
1586
|
|
|
1543
1587
|
---
|
|
1544
1588
|
|
|
@@ -1753,6 +1797,8 @@ class ArticlesController < ApplicationController
|
|
|
1753
1797
|
filter_by :status, :category # ?status=draft → .where(status: 'draft')
|
|
1754
1798
|
filter_by :published, scope: :published # ?published=1 → Article.published
|
|
1755
1799
|
filter_by :q, with: ->(rel, v) { rel.where("title ILIKE ?", "%#{v}%") }
|
|
1800
|
+
filter_by :price, :created_at, operators: true # ?price_gte=10&created_at_lt=2026-01-01
|
|
1801
|
+
filter_by :min_stock, type: :integer, with: ->(rel, v) { rel.where(rel.model.arel_table[:stock].gteq(v)) }
|
|
1756
1802
|
|
|
1757
1803
|
def index
|
|
1758
1804
|
render json: filtered(Article.all)
|
|
@@ -1760,6 +1806,24 @@ class ArticlesController < ApplicationController
|
|
|
1760
1806
|
end
|
|
1761
1807
|
```
|
|
1762
1808
|
|
|
1809
|
+
**Operators** (opt-in per filter, direct-where mode only — `operators: true` or a subset like `%i[gte lte]`),
|
|
1810
|
+
accepted as a suffix `?price_gte=10` or in bracket form `?price[gte]=10&price[lte]=50`:
|
|
1811
|
+
|
|
1812
|
+
| Operator | Param | SQL |
|
|
1813
|
+
|----------|------------------------------------|---------------------------------------|
|
|
1814
|
+
| `not` | `?status_not=draft` | `status != 'draft'` |
|
|
1815
|
+
| `gt` `gte` `lt` `lte` | `?price_gte=10` | `price >= 10` (cast through the column type) |
|
|
1816
|
+
| `in` `not_in` | `?status_in=a,b` or `?status_in[]=a` | `status IN ('a','b')` |
|
|
1817
|
+
| `null` | `?deleted_at_null=true` | `deleted_at IS NULL` (`false` → `IS NOT NULL`) |
|
|
1818
|
+
| `contains` `starts_with` | `?title_contains=rails` | `title LIKE '%rails%'` (wildcards escaped; ILIKE on PostgreSQL) |
|
|
1819
|
+
|
|
1820
|
+
Comparison values are cast the way ActiveRecord casts them (the column's own type), or through `type:`
|
|
1821
|
+
(any ActiveModel type name); `type:` also pre-casts the value handed to a `with:` lambda. Blank values
|
|
1822
|
+
are skipped and unknown operators / non-scalar values ignored. A `gt`/`gte`/`lt`/`lte` value the type
|
|
1823
|
+
cannot represent (`?price_gte=abc`) matches **nothing** rather than silently comparing against `0` —
|
|
1824
|
+
nothing raises at request time. `contains`/`starts_with` are case-insensitive on PostgreSQL, MySQL and
|
|
1825
|
+
SQLite alike. For strict, validated contracts reach for `Permittable`.
|
|
1826
|
+
|
|
1763
1827
|
**Modes**
|
|
1764
1828
|
|
|
1765
1829
|
| Mode | Declaration | What it does |
|
|
@@ -1770,7 +1834,7 @@ end
|
|
|
1770
1834
|
|
|
1771
1835
|
**Notes**
|
|
1772
1836
|
- Blank params are skipped — unset filters don't narrow the relation.
|
|
1773
|
-
- Passing both `:scope` and `:with` raises `ArgumentError
|
|
1837
|
+
- 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
1838
|
- Scope mode pairs naturally with `Publishable.published`, `SoftDeletable.active`, `Expirable.active`, etc.
|
|
1775
1839
|
|
|
1776
1840
|
---
|
|
@@ -1825,9 +1889,9 @@ class Api::ArticlesController < ApplicationController
|
|
|
1825
1889
|
def create
|
|
1826
1890
|
article = Article.new(article_params)
|
|
1827
1891
|
if article.save
|
|
1828
|
-
|
|
1892
|
+
render_created(data: article, location: article_url(article)) # 201 + Location
|
|
1829
1893
|
else
|
|
1830
|
-
|
|
1894
|
+
render_invalid(article) # 422 record_invalid + full_messages
|
|
1831
1895
|
end
|
|
1832
1896
|
end
|
|
1833
1897
|
end
|
|
@@ -1861,7 +1925,9 @@ respondable_by error_format: :problem_details, problem_type_base: "https://api.e
|
|
|
1861
1925
|
|
|
1862
1926
|
| Method | Signature |
|
|
1863
1927
|
|-------------------|--------------------------------------------------------------------------------------------|
|
|
1864
|
-
| `render_success` | `render_success(data: nil, status: :ok, meta: {})`
|
|
1928
|
+
| `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) |
|
|
1929
|
+
| `render_created` | `render_created(data: nil, location: nil, meta: {}, headers: {})` — `render_success` with `status: :created` |
|
|
1930
|
+
| `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
1931
|
| `render_error` | `render_error(message:, status: :unprocessable_entity, code: nil, errors: nil)` |
|
|
1866
1932
|
| `respondable_by` | `respondable_by(error_format: :envelope, problem_type_base: nil)` — class-level; `error_format:` is `:envelope` (default) or `:problem_details` |
|
|
1867
1933
|
|
|
@@ -2229,12 +2295,13 @@ end
|
|
|
2229
2295
|
| `:stripe` | `Stripe-Signature` | `t=<unix>,v1=<hex>[,v1=…]` — signs `"#{t}.#{body}"`, every `v1` tried, `tolerance:` rejects stale **and** future timestamps |
|
|
2230
2296
|
| `:hex` / `:base64` | — (`header:` required) | plain hex / strict Base64 HMAC of the body |
|
|
2231
2297
|
|
|
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).
|
|
2298
|
+
**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
2299
|
|
|
2234
2300
|
**Notes**
|
|
2235
2301
|
- 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
2302
|
- 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
|
-
-
|
|
2303
|
+
- **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.
|
|
2304
|
+
- 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
2305
|
- 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
2306
|
- In tests: `skip_before_action :verify_webhook_signature!`, or sign payloads for real with `OpenSSL::HMAC`. After a pass, `webhook_verified?` is true.
|
|
2240
2307
|
|
|
@@ -2401,12 +2468,12 @@ Both forms reference the same module, so you can freely mix them.
|
|
|
2401
2468
|
|
|
2402
2469
|
| Need | Use instead |
|
|
2403
2470
|
|------|-------------|
|
|
2404
|
-
| Complex state machines (
|
|
2471
|
+
| 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
2472
|
| 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
2473
|
| Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
|
|
2407
2474
|
| 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
2475
|
| 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
|
|
2476
|
+
| Field encryption with KMS-backed / per-record keys or Rails-native key infrastructure | [`lockbox`](https://github.com/ankane/lockbox) / Rails 7+ native `encrypts` |
|
|
2410
2477
|
| Deep clone with per-attribute regex/prepend rules or belongs_to graph copying | [`amoeba`](https://github.com/amoeba-rb/amoeba) |
|
|
2411
2478
|
|
|
2412
2479
|
`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 +2503,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
|
|
|
2436
2503
|
|
|
2437
2504
|
```sh
|
|
2438
2505
|
bundle install # install dev dependencies
|
|
2439
|
-
bundle exec rspec # run the test suite (1,
|
|
2506
|
+
bundle exec rspec # run the test suite (1,830 examples)
|
|
2440
2507
|
gem build concerns_on_rails.gemspec # build the gem
|
|
2441
|
-
gem install ./concerns_on_rails-1.28.
|
|
2508
|
+
gem install ./concerns_on_rails-1.28.9.gem # install locally
|
|
2442
2509
|
|
|
2443
2510
|
# Preview the docs site locally (GitHub Pages serves docs/ as-is):
|
|
2444
2511
|
cd docs && python3 -m http.server 8000 # → http://localhost:8000
|