concerns_on_rails 1.15.0 → 1.17.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d0ca1b10759cf285948bef864e3cba343b3d1caa5e461b000af0486b4d51781
4
- data.tar.gz: 1006768d58216c32a190c551ede050eb2e12ac24d13daf847600e83d1f219f52
3
+ metadata.gz: 5759f8cf9f86e11087369181f53f807875909047c17fe8abb19a5b6bafaae697
4
+ data.tar.gz: d6e34235431b2c54a36de7126b4829b9892b05f66f0fc6b476ff561731452100
5
5
  SHA512:
6
- metadata.gz: 7b8c99595c6fbdd34ba4eb2ed011dee168c6178cffbee5bd1803195e83d5e2301bb87db99bc2dbf1143ba6d5e584f708d44aefbbea13248af2bc0d6eba0772d2
7
- data.tar.gz: b43057a24fd64599ee62ef31dcbddd0daf3b985193a36dd02811c17721ebebf0e73f5793c73ffb41c98524ca9b5bec1a100c9c3619ccf8ceb896ce5ce11051cc
6
+ metadata.gz: 2b101b659f6d47f0adcface39ef7069a978d8342e6499661d358af12037c12fb1b7d317cdd075ca9b5593989df2941e121ecf46e02d51c11e4f445cdf91801dd
7
+ data.tar.gz: 984342a4be9bae7692c880bb2436de85cd86a3a0e8cd4ba1c167168db297bf4a0ce42a3a7025de910b7e33495d67e8352667ea302b8d47e12c1824cd7bd4ae31
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.17.0 (2026-06-10)
4
+
5
+ Two new concerns, hardened by two adversarial review rounds (in-memory state is restored when a raising lock/unlock hook rolls the write back, so retries can't silently no-op; a hook aborting via `ActiveRecord::Rollback` makes `lock_access!`/`unlock_access!` return false instead of a fake success; invalid-UTF-8 signature headers fail closed instead of raising). 679 examples, 0 failures.
6
+
7
+ ### Added
8
+ - **Models::Lockable**: failed-attempt tracking + account lockout ("Devise lockable-lite") for apps on Rails 8 native auth / `has_secure_password`. `lockable_by attempts: :failed_attempts, locked_at: :locked_at, max_attempts: 5, unlock_in: 15.minutes, prefix:/suffix:` — `register_failed_attempt!` increments SQL-side (`update_counters`: atomic under concurrency, NULL-coalescing) and auto-locks at the threshold; `access_locked?` / `lock_expired?` / `lock_expires_at` / `attempts_remaining` readers are side-effect free with lazy expiry (the boundary instant counts as unlocked); `lock_access!` / `unlock_access!` persist via `update_columns` (validations/callbacks bypassed so an invalid record can still be locked) with `before/after_lock`, `before/after_unlock` hooks in a transaction; `reset_failed_attempts!` is the hook-free successful-login path; expiry-aware `.locked` / `.unlocked` scopes (affixable, Ruby-computed cutoff so the SQL stays portable). An expired lock is cleared quietly on the next failed attempt — no unlock hooks fire from an attacker's guess. The attempts column is validated to be an integer column. Zero new runtime dependencies.
9
+ - **Controllers::WebhookVerifiable**: HMAC signature verification for inbound webhooks (Stripe/GitHub/Shopify and generic `:hex`/`:base64`). `verify_webhook *actions, secret:, scheme:, header:, tolerance:, digest:` appends rules (first match wins; no actions = catch-all); the `verify_webhook_signature!` before_action (public, skip-able) renders 401 (`webhook_signature_missing`/`webhook_signature_invalid`/`webhook_timestamp_stale`) or 400 (`webhook_signature_malformed`) and halts the action. Comparison is constant-time (digest-collapsed `secure_compare`, portable to Rails 5.0) and the attacker-controlled header is never decoded — garbage including invalid UTF-8 is scrubbed and fails closed instead of raising. Stripe: signs `"#{t}.#{body}"`, tries every `v1` (≤16), ignores unknown keys, first `t` feeds both the symmetric tolerance check (default 300s) and the payload so a re-stamped stale header stays dead. Secrets: String / callable (`instance_exec`'d per request, multi-tenant) / Array (rotation, any match passes); a secret resolving blank at request time raises `ArgumentError` (misconfiguration should page, not 401 into the provider's retry loop). `:shopify`/`:base64` encode via `pack("m0")` (no base64-gem dependency on Ruby 3.4). Delegates error bodies to `render_error` when Respondable is present. Zero new runtime dependencies.
10
+
11
+ ## 1.16.0 (2026-06-10)
12
+
13
+ Two new concerns, hardened by an adversarial edge-case review. 574 examples, 0 failures.
14
+
15
+ ### Added
16
+ - **Models::Auditable**: lightweight single-column change history ("paper_trail-lite"). `auditable_by :price, :status, into: :audit_log, actor: -> { Current.user&.email }, max_entries: 100` appends one JSON entry per changed field per save (creates record `from: nil`) into one text column — no extra tables, written in the same INSERT/UPDATE via `before_save`. Readers: `audit_trail`, `last_change_for(:field)`, `audited_changes_since(time)`, `clear_audit_trail!`. Tolerant JSON decode (corrupt column → `[]`), newest-N trimming (default 200), opt-in value truncation (`max_value_length:` stores the first N characters of long String values + `…`), values JSON-coerced (times → ISO8601 UTC, BigDecimal → precision-safe string, non-finite floats → `"NaN"`/`"Infinity"` strings), `"by"` omitted when no actor. Entries build on the persisted trail, so a save aborted by a later callback cannot duplicate entries on retry. Zero new runtime dependencies.
17
+ - **Controllers::Idempotentable**: Stripe-style `Idempotency-Key` support with an injectable store (`self.idempotency_store = Rails.cache`; contract: `#read`, `#write(expires_in:, unless_exist:)`, `#delete` — no in-process default on purpose). `idempotent_actions :create, ttl: 24.hours, lock_ttl: 1.minute, header: "Idempotency-Key", required: false` claims each key atomically, caches 2xx–4xx responses and replays them with `X-Idempotency-Replayed: true`; concurrent duplicates get 409 + `Retry-After`, payload mismatches get 422 (`idempotency_key_reuse`, fingerprint overridable), 5xx/exceptions release the claim so retries re-execute. Keys are validated (≤255 chars, control characters rejected to prevent response-header injection via the echoed `X-Idempotency-Key`), SHA256-hashed, and scoped per `controller#action`. Error bodies delegate to `render_error` when Respondable is present. Zero new runtime dependencies.
18
+
3
19
  ## 1.15.0 (2026-06-10)
4
20
 
5
21
  A review-driven release: 23 correctness/safety fixes (each with a regression spec) and 7 backward-compatible enhancements. 510 examples, 0 failures.
data/README.md CHANGED
@@ -43,6 +43,8 @@ Article.published.without_deleted.find("hello-world")
43
43
  - [Sanitizable](#-sanitizable) — opt-in HTML sanitization (XSS defense-in-depth)
44
44
  - [Maskable](#-maskable) — non-destructive display masking of sensitive fields
45
45
  - [Monetizable](#-monetizable) — integer-cents money columns (BigDecimal)
46
+ - [Auditable](#-auditable) — single-column change history ("paper_trail-lite")
47
+ - [Lockable](#-lockable) — failed-attempt tracking + account lockout
46
48
  - **Controller concerns**
47
49
  - [Paginatable](#-paginatable) — offset pagination with headers
48
50
  - [Filterable](#-filterable) — declarative URL-param filters
@@ -55,6 +57,8 @@ Article.published.without_deleted.find("hello-world")
55
57
  - [Authorizable](#-authorizable) — per-action 403 authorization gate (block-based)
56
58
  - [Throttleable](#-throttleable) — rate limiting with 429 + `X-RateLimit-*` headers
57
59
  - [Timezoneable](#-timezoneable) — per-request `Time.zone` from params / header / cookie
60
+ - [Idempotentable](#-idempotentable) — `Idempotency-Key` request replay (409 on concurrent duplicates)
61
+ - [WebhookVerifiable](#-webhookverifiable) — HMAC verification for inbound webhooks (Stripe/GitHub/Shopify)
58
62
  - [Module paths & namespacing](#-module-paths--namespacing)
59
63
  - [Development](#-development)
60
64
  - [Contributing](#-contributing)
@@ -64,7 +68,7 @@ Article.published.without_deleted.find("hello-world")
64
68
 
65
69
  ## ✨ Why this gem?
66
70
 
67
- - **Eighteen model concerns + eight controller concerns**, all production-ready
71
+ - **Twenty model concerns + thirteen controller concerns**, all production-ready
68
72
  - **One include, one macro** — no boilerplate, no glue code
69
73
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
70
74
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -935,6 +939,71 @@ product.formatted_price # => "$19.99"
935
939
 
936
940
  ---
937
941
 
942
+ ## 📜 Auditable
943
+
944
+ Lightweight change history ("paper_trail-lite") stored as JSON entries in **one text column** on the same table — no extra tables, no versioning engine.
945
+
946
+ ```ruby
947
+ class Product < ApplicationRecord
948
+ include ConcernsOnRails::Auditable
949
+
950
+ auditable_by :price, :status # default column :audit_log
951
+ # auditable_by :price, into: :history,
952
+ # actor: -> { Current.user&.email }, # stamps "by" on each entry
953
+ # max_entries: 50 # keep the newest 50
954
+ end
955
+
956
+ product.update!(price: 200)
957
+ product.audit_trail
958
+ # => [{"field"=>"price", "from"=>100, "to"=>200, "at"=>"2026-06-10T12:34:56Z", "by"=>"admin@shop.com"}]
959
+ product.last_change_for(:price) # newest entry for one field
960
+ product.audited_changes_since(1.day.ago) # recent entries, oldest first
961
+ product.clear_audit_trail! # wipe the column (skips callbacks)
962
+ ```
963
+
964
+ One entry is recorded **per changed field per save** (creates record `"from" => nil`), appended in the same `INSERT`/`UPDATE` via `before_save` — zero extra queries.
965
+
966
+ **Options**: `into:` (`:audit_log`), `actor:` (callable, `instance_exec`'d on the record; `"by"` omitted when absent), `max_entries:` (`200`; keeps the newest N, `nil` = unlimited), `max_value_length:` (`nil`; truncates long String `from`/`to` values to the first N characters + `…`).
967
+
968
+ **Notes**
969
+ - Writes that skip callbacks (`update_column(s)`, `touch`, `increment!`) are **not** audited; `save(validate: false)` is.
970
+ - Values are JSON-coerced (times → ISO8601 UTC strings, `BigDecimal` → precision-safe numeric string); a corrupt column decodes as `[]` and is replaced on the next tracked save.
971
+ - Per-record and bounded by design — reach for [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) when you need reify/undo or audit queries across models.
972
+
973
+ ---
974
+
975
+ ## 🔐 Lockable
976
+
977
+ 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.
978
+
979
+ ```ruby
980
+ class User < ApplicationRecord
981
+ include ConcernsOnRails::Lockable
982
+
983
+ lockable_by max_attempts: 5, unlock_in: 15.minutes
984
+ # lockable_by attempts: :failed_logins, locked_at: :locked_until_at,
985
+ # prefix: :account # => .account_locked / .account_unlocked
986
+ end
987
+
988
+ user.register_failed_attempt! # atomic SQL increment; locks at max_attempts
989
+ user.access_locked? # true while locked (lapses after unlock_in)
990
+ user.attempts_remaining # => 3 (for "3 attempts remaining" messaging)
991
+ user.reset_failed_attempts! # call on successful login
992
+ user.lock_access! # manual lock (hooks: before/after_lock)
993
+ user.unlock_access! # manual unlock (hooks: before/after_unlock)
994
+ User.locked / User.unlocked # expiry-aware scopes
995
+ ```
996
+
997
+ **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).
998
+
999
+ **Notes**
1000
+ - The increment is SQL-side (`COALESCE(attempts, 0) + 1` via `update_counters`), so concurrent failures never lose updates and a NULL counter needs no column default; a locked account stops counting.
1001
+ - Expiry is **lazy**: readers and scopes treat a stale lock as unlocked but never write. The column is cleared by the next `unlock_access!` or failed attempt (quietly there — no unlock hooks fire from a failed login).
1002
+ - `lock_access!` / `unlock_access!` persist via `update_columns` — validations and AR callbacks deliberately bypassed so an otherwise-invalid record can still be locked (this also skips `updated_at`/`Auditable`). The `before/after_lock`, `before/after_unlock` hooks run in a transaction; `after_lock` is the place for the "account locked" email.
1003
+ - Reach for Devise's `lockable` when you need unlock tokens, unlock emails, or per-strategy unlocks.
1004
+
1005
+ ---
1006
+
938
1007
  # 🎮 Controller Concerns
939
1008
 
940
1009
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -1299,6 +1368,72 @@ Resolution order: `params[param]` → `Time-Zone` header → cookie (if enabled)
1299
1368
 
1300
1369
  ---
1301
1370
 
1371
+ ## 🔁 Idempotentable
1372
+
1373
+ Stripe-style **`Idempotency-Key`** support for mutating endpoints, with a **store-agnostic, injectable** backend. The first request with a key runs the action and caches the response; a retry **replays** the cached response; a concurrent duplicate gets **409**.
1374
+
1375
+ ```ruby
1376
+ class PaymentsController < ApplicationController
1377
+ include ConcernsOnRails::Controllers::Idempotentable
1378
+
1379
+ self.idempotency_store = Rails.cache # must support #read / #write(expires_in:, unless_exist:) / #delete
1380
+
1381
+ idempotent_actions :create, ttl: 24.hours, required: true
1382
+ end
1383
+ ```
1384
+
1385
+ Per-key lifecycle: claim atomically (`write unless_exist`, TTL `lock_ttl:`) → run action → cache 2xx–4xx responses for `ttl:`; 5xx and raised exceptions release the claim so the client can retry. Replays carry `X-Idempotency-Replayed: true`; duplicates in flight get 409 + `Retry-After`; reusing a key with a **different payload** gets 422 (`idempotency_key_reuse`, fingerprint overridable via `idempotency_fingerprint`).
1386
+
1387
+ **Options**: `*actions` (allow-list, required), `ttl:` (`24.hours`), `lock_ttl:` (`1.minute`), `header:` (`"Idempotency-Key"`), `required:` (`false`).
1388
+
1389
+ **Notes**
1390
+ - Cache keys are scoped per `controller#action` and the client key is SHA256-hashed, so the same key on different endpoints never collides.
1391
+ - There is **no in-process default store** on purpose: the first keyed request raises `ArgumentError` until you set `idempotency_store`.
1392
+ - When `Respondable` is included, the 400/409/422 bodies delegate to `render_error`.
1393
+ - Declare halting filters (authentication, `Throttleable`) **before** including this concern — a 401/403 rendered by an inner filter would be cached and replayed for the full TTL. Responses rendered by `rescue_from` handlers are never cached.
1394
+ - Keys must be ≤255 chars with no control characters (the raw key is echoed in `X-Idempotency-Key`); set `lock_ttl:` above the slowest declared action's worst case.
1395
+
1396
+ ---
1397
+
1398
+ ## 🪝 WebhookVerifiable
1399
+
1400
+ HMAC **signature verification for inbound webhooks** — the receiving side of Stripe/GitHub/Shopify-style integrations. The action runs only when the signature over the **raw request body** verifies; otherwise a 401/400 is rendered and the action never executes.
1401
+
1402
+ ```ruby
1403
+ class WebhooksController < ApplicationController
1404
+ include ConcernsOnRails::Controllers::WebhookVerifiable # declare BEFORE Idempotentable
1405
+
1406
+ verify_webhook :stripe, secret: -> { ENV["STRIPE_WEBHOOK_SECRET"] }, scheme: :stripe
1407
+ verify_webhook :github, secret: -> { ENV["GITHUB_WEBHOOK_SECRET"] }, scheme: :github
1408
+ verify_webhook :shopify, secret: [ENV["NEW_SECRET"], ENV["OLD_SECRET"]], scheme: :shopify # rotation
1409
+ verify_webhook :custom, secret: "s3cr3t", scheme: :hex, header: "X-Acme-Signature"
1410
+ # verify_webhook secret: ... # no actions = catch-all (declare specific rules first)
1411
+
1412
+ def stripe
1413
+ event = JSON.parse(request.raw_post) # parse the raw body — it is what was signed
1414
+ # ...
1415
+ end
1416
+ end
1417
+ ```
1418
+
1419
+ | Scheme | Header (default) | Format |
1420
+ |--------|------------------|--------|
1421
+ | `:github` | `X-Hub-Signature-256` | `sha256=<hex>` |
1422
+ | `:shopify` | `X-Shopify-Hmac-Sha256` | strict Base64 of the binary HMAC |
1423
+ | `:stripe` | `Stripe-Signature` | `t=<unix>,v1=<hex>[,v1=…]` — signs `"#{t}.#{body}"`, every `v1` tried, `tolerance:` rejects stale **and** future timestamps |
1424
+ | `:hex` / `:base64` | — (`header:` required) | plain hex / strict Base64 HMAC of the body |
1425
+
1426
+ **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).
1427
+
1428
+ **Notes**
1429
+ - 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.
1430
+ - 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.
1431
+ - 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.
1432
+ - 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`.
1433
+ - In tests: `skip_before_action :verify_webhook_signature!`, or sign payloads for real with `OpenSSL::HMAC`. After a pass, `webhook_verified?` is true.
1434
+
1435
+ ---
1436
+
1302
1437
  ## 🗂️ Module paths & namespacing
1303
1438
 
1304
1439
  Every concern is available under two paths:
@@ -1334,7 +1469,7 @@ Both forms reference the same module, so you can freely mix them.
1334
1469
  | Association-cascade soft delete / sentinel-aware unique indexes | [`paranoia`](https://github.com/rubysherpas/paranoia) or [`discard`](https://github.com/jhawthorn/discard) |
1335
1470
  | Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
1336
1471
  | Full-text search with ranking / stemming | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
1337
- | Audit trails / version history | [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) |
1472
+ | 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) |
1338
1473
 
1339
1474
  `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.
1340
1475
 
@@ -0,0 +1,300 @@
1
+ require "active_support/concern"
2
+ require "digest"
3
+ require "json"
4
+
5
+ module ConcernsOnRails
6
+ module Controllers
7
+ # Stripe-style `Idempotency-Key` support for mutating endpoints, with a
8
+ # store-agnostic, injectable backend. The first request with a key executes
9
+ # the action and caches the rendered response; a retry with the same key
10
+ # replays the cached response instead of re-running the action; a concurrent
11
+ # duplicate while the first is still in flight is halted with 409.
12
+ #
13
+ # class PaymentsController < ApplicationController
14
+ # include ConcernsOnRails::Controllers::Idempotentable
15
+ #
16
+ # self.idempotency_store = Rails.cache # must support #read / #write(expires_in:, unless_exist:) / #delete
17
+ #
18
+ # idempotent_actions :create, ttl: 24.hours, required: true
19
+ # end
20
+ #
21
+ # Lifecycle per key (scoped to controller#action, so the same client key on
22
+ # different endpoints never collides):
23
+ # * claim won -> action runs; 2xx-4xx responses are cached for `ttl:`;
24
+ # 5xx responses and raised exceptions release the claim so
25
+ # the client can retry.
26
+ # * done -> the cached status/body/content type is replayed with
27
+ # `X-Idempotency-Replayed: true`.
28
+ # * in flight -> 409 with code "idempotency_conflict" and `Retry-After`.
29
+ # * same key, different request payload -> 422 "idempotency_key_reuse"
30
+ # (override `idempotency_fingerprint` to customize payload matching).
31
+ #
32
+ # The claim is taken atomically via `write(..., unless_exist: true)`
33
+ # (memcached `add` / Redis `SET NX` through Rails.cache); a store without
34
+ # that atomicity is best-effort under concurrency. There is no in-process
35
+ # default store on purpose — configure one explicitly or the first keyed
36
+ # request raises ArgumentError. Note that responses rendered by
37
+ # `rescue_from` handlers bypass the around filter's success path and are
38
+ # never cached.
39
+ #
40
+ # IMPORTANT — callback ordering: declare halting filters (authentication,
41
+ # authorization, rate limiting) BEFORE including this module. A
42
+ # before_action that runs *inside* the around filter and halts (401/403)
43
+ # has its response cached and replayed for the full `ttl:` — the client
44
+ # cannot fix credentials and retry until it expires. Rails offers no
45
+ # reliable way for the around filter to detect a halted inner chain.
46
+ # Likewise set `lock_ttl:` above the worst-case duration of the slowest
47
+ # declared action — if the action outlasts the claim, a concurrent retry
48
+ # can win the expired key and execute the action a second time.
49
+ module Idempotentable
50
+ extend ActiveSupport::Concern
51
+
52
+ DEFAULT_HEADER = "Idempotency-Key".freeze
53
+ MAX_KEY_LENGTH = 255
54
+ IGNORED_FINGERPRINT_KEYS = %w[controller action format].freeze
55
+
56
+ included do
57
+ class_attribute :idempotency_rules, instance_accessor: false, default: []
58
+ class_attribute :idempotency_store, instance_accessor: false, default: nil
59
+ around_action :enforce_idempotency
60
+ end
61
+
62
+ module ClassMethods
63
+ # Declare idempotent actions. `ttl:` is the cached-response lifetime,
64
+ # `lock_ttl:` the in-flight claim lifetime (kept short so a crashed
65
+ # worker cannot wedge a key), `header:` the request header to read, and
66
+ # `required:` whether a missing key is a 400. Each call appends a rule;
67
+ # the first rule listing the current action wins.
68
+ def idempotent_actions(*actions, ttl: 86_400, lock_ttl: 60, header: DEFAULT_HEADER, required: false)
69
+ actions = actions.flatten.map(&:to_s)
70
+ validate_idempotent!(actions, ttl: ttl, lock_ttl: lock_ttl, header: header, required: required)
71
+
72
+ rule = { actions: actions, ttl: ttl.to_i, lock_ttl: lock_ttl.to_i, header: header.to_s, required: required }
73
+ self.idempotency_rules = idempotency_rules + [rule]
74
+ end
75
+
76
+ private
77
+
78
+ def validate_idempotent!(actions, ttl:, lock_ttl:, header:, required:)
79
+ prefix = "ConcernsOnRails::Controllers::Idempotentable"
80
+ raise ArgumentError, "#{prefix}: pass at least one action" if actions.empty?
81
+ raise ArgumentError, "#{prefix}: :ttl must be a positive duration" unless ttl.to_i.positive?
82
+ raise ArgumentError, "#{prefix}: :lock_ttl must be a positive duration" unless lock_ttl.to_i.positive?
83
+ raise ArgumentError, "#{prefix}: :header must be a non-blank String" if header.to_s.strip.empty?
84
+ raise ArgumentError, "#{prefix}: :required must be true or false" unless [true, false].include?(required)
85
+ end
86
+ end
87
+
88
+ # around_action entry point. Public so subclasses can override and specs
89
+ # can drive it directly with a block standing in for the action.
90
+ def enforce_idempotency(&)
91
+ rule = idempotency_rule_for_action
92
+ return yield unless rule
93
+
94
+ raw = read_idempotency_header(rule)
95
+ return idempotency_handle_missing_key(rule, &) if raw.nil?
96
+
97
+ key = raw.to_s.strip
98
+ unless valid_idempotency_key?(key)
99
+ return idempotency_error_response(message: "#{rule[:header]} is invalid (expected 1-#{MAX_KEY_LENGTH} characters).",
100
+ status: :bad_request, code: "idempotency_key_invalid")
101
+ end
102
+
103
+ @idempotency_key = key
104
+ run_with_idempotency(rule, key, &)
105
+ end
106
+
107
+ # The raw key sent for the matched rule (nil when absent). Handy for logging.
108
+ def idempotency_key
109
+ @idempotency_key
110
+ end
111
+
112
+ # Digest of the request payload, used to reject reusing one key for a
113
+ # different request. Public override point — e.g. for raw-body APIs:
114
+ # def idempotency_fingerprint = Digest::SHA256.hexdigest(request.raw_post)
115
+ # Override it for multipart endpoints too: an uploaded file stringifies
116
+ # with its object id, so retried uploads never match and 422 — digest
117
+ # stable parts instead (e.g. params[:file]&.original_filename).
118
+ def idempotency_fingerprint
119
+ raw = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
120
+ filtered = raw.reject { |k, _| IGNORED_FINGERPRINT_KEYS.include?(k.to_s) }
121
+ Digest::SHA256.hexdigest(JSON.generate(idempotency_deep_sort(filtered)))
122
+ end
123
+
124
+ # Public override point for how a cached response is replayed.
125
+ def replay_idempotent_response(record)
126
+ return unless respond_to?(:response) && response
127
+
128
+ emit_idempotency_replayed_header(true)
129
+ options = { body: record["body"], status: record["status"] }
130
+ options[:content_type] = record["content_type"] if record["content_type"]
131
+ render(options)
132
+ end
133
+
134
+ # Single funnel for all error outcomes. Uses Respondable's render_error
135
+ # when available, otherwise the same inline envelope as Throttleable.
136
+ def idempotency_error_response(message:, status:, code:)
137
+ return unless respond_to?(:response) && response
138
+
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
142
+ end
143
+
144
+ private
145
+
146
+ def idempotency_rule_for_action
147
+ action = respond_to?(:action_name) ? action_name.to_s : nil
148
+ return nil unless action
149
+
150
+ self.class.idempotency_rules.find { |rule| rule[:actions].include?(action) }
151
+ end
152
+
153
+ def idempotency_handle_missing_key(rule, &)
154
+ return yield unless rule[:required]
155
+
156
+ idempotency_error_response(message: "#{rule[:header]} header is required for this action.",
157
+ status: :bad_request, code: "idempotency_key_missing")
158
+ end
159
+
160
+ def valid_idempotency_key?(key)
161
+ # Control characters are rejected because the raw key is echoed in the
162
+ # X-Idempotency-Key response header — CR/LF would enable header
163
+ # injection on servers that don't validate header values (Rack 2).
164
+ !key.empty? && key.length <= MAX_KEY_LENGTH && !key.match?(/[[:cntrl:]]/)
165
+ end
166
+
167
+ def run_with_idempotency(rule, key, &)
168
+ store = idempotency_store!
169
+ cache_key = idempotency_cache_key(key)
170
+ fingerprint = idempotency_fingerprint
171
+ emit_idempotency_key_header(key)
172
+
173
+ claim = { "state" => "in_flight", "fingerprint" => fingerprint, "claimed_at" => Time.now.to_i }
174
+ if store.write(cache_key, claim, expires_in: rule[:lock_ttl], unless_exist: true)
175
+ idempotency_execute_and_store(store, cache_key, rule, fingerprint, &)
176
+ else
177
+ idempotency_resolve_existing(store, cache_key, rule, fingerprint)
178
+ end
179
+ end
180
+
181
+ def idempotency_execute_and_store(store, cache_key, rule, fingerprint)
182
+ emit_idempotency_replayed_header(false)
183
+ completed = false
184
+ begin
185
+ yield
186
+ completed = true
187
+ ensure
188
+ # Covers raise and throw alike, so a retry can re-execute.
189
+ store.delete(cache_key) unless completed
190
+ end
191
+
192
+ status = idempotency_response_status
193
+ return store.delete(cache_key) if status >= 500
194
+
195
+ record = { "state" => "done", "status" => status, "body" => idempotency_response_body,
196
+ "content_type" => idempotency_response_content_type, "fingerprint" => fingerprint }
197
+ store.write(cache_key, record, expires_in: rule[:ttl])
198
+ end
199
+
200
+ def idempotency_resolve_existing(store, cache_key, rule, fingerprint)
201
+ record = store.read(cache_key)
202
+
203
+ if record && record["fingerprint"] != fingerprint
204
+ return idempotency_error_response(message: "#{rule[:header]} was already used with a different request payload.",
205
+ status: :unprocessable_entity, code: "idempotency_key_reuse")
206
+ end
207
+
208
+ return replay_idempotent_response(record) if record && record["state"] == "done"
209
+
210
+ # In flight — or the claim expired between our failed write and this
211
+ # read (rare); answering 409 is the conservative, retry-safe choice.
212
+ idempotency_conflict_response(rule)
213
+ end
214
+
215
+ def idempotency_conflict_response(rule)
216
+ response.set_header("Retry-After", rule[:lock_ttl].to_s) if respond_to?(:response) && response
217
+ idempotency_error_response(message: "A request with this #{rule[:header]} is already in progress.",
218
+ status: :conflict, code: "idempotency_conflict")
219
+ end
220
+
221
+ def read_idempotency_header(rule)
222
+ return nil unless respond_to?(:request) && request.respond_to?(:headers) && request.headers
223
+
224
+ request.headers[rule[:header]]
225
+ end
226
+
227
+ def idempotency_cache_key(key)
228
+ # The user key is hashed so any validated key is safe in any backend
229
+ # (memcached limits key length and bans whitespace/control characters).
230
+ "idempotentable:#{idempotency_scope}:#{Digest::SHA256.hexdigest(key)}"
231
+ end
232
+
233
+ def idempotency_scope
234
+ controller = respond_to?(:controller_path) ? controller_path : self.class.name || "anonymous"
235
+ action = respond_to?(:action_name) ? action_name.to_s : ""
236
+ "#{controller}##{action}"
237
+ end
238
+
239
+ def idempotency_store!
240
+ store = self.class.idempotency_store
241
+ return store if store
242
+
243
+ raise ArgumentError,
244
+ "ConcernsOnRails::Controllers::Idempotentable: no store configured. " \
245
+ "Set `self.idempotency_store = Rails.cache` " \
246
+ "(must support #read, #write(expires_in:, unless_exist:) and #delete)."
247
+ end
248
+
249
+ def idempotency_response_status
250
+ return 200 unless respond_to?(:response) && response.respond_to?(:status)
251
+
252
+ response.status.to_i
253
+ end
254
+
255
+ def idempotency_response_body
256
+ return nil unless respond_to?(:response) && response.respond_to?(:body)
257
+
258
+ response.body
259
+ end
260
+
261
+ def idempotency_response_content_type
262
+ return nil unless respond_to?(:response) && response
263
+
264
+ # media_type (real Rails) excludes the charset; the harness only has content_type.
265
+ if response.respond_to?(:media_type) && response.media_type
266
+ response.media_type
267
+ elsif response.respond_to?(:content_type)
268
+ response.content_type
269
+ end
270
+ end
271
+
272
+ def emit_idempotency_key_header(key)
273
+ response.set_header("X-Idempotency-Key", key) if respond_to?(:response) && response
274
+ end
275
+
276
+ def emit_idempotency_replayed_header(replayed)
277
+ response.set_header("X-Idempotency-Replayed", replayed.to_s) if respond_to?(:response) && response
278
+ end
279
+
280
+ # Deterministic JSON regardless of param insertion order: hashes become
281
+ # sorted [key, value] pairs, arrays keep their (significant) order, and
282
+ # non-JSON-primitive leaves are stringified so nothing can raise.
283
+ def idempotency_deep_sort(value)
284
+ case value
285
+ when Hash then value.map { |k, v| [k.to_s, idempotency_deep_sort(v)] }.sort_by(&:first)
286
+ when Array then value.map { |v| idempotency_deep_sort(v) }
287
+ else idempotency_scalar(value)
288
+ end
289
+ end
290
+
291
+ def idempotency_scalar(value)
292
+ case value
293
+ when Float then value.finite? ? value : value.to_s # JSON.generate raises on NaN/Infinity
294
+ when nil, true, false, Integer, String then value
295
+ else value.to_s
296
+ end
297
+ end
298
+ end
299
+ end
300
+ end