concerns_on_rails 1.16.0 → 1.18.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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +144 -1
- data/lib/concerns_on_rails/controllers/cursor_paginatable.rb +501 -0
- data/lib/concerns_on_rails/controllers/webhook_verifiable.rb +323 -0
- data/lib/concerns_on_rails/legacy_aliases.rb +2 -0
- data/lib/concerns_on_rails/models/aliasable.rb +405 -0
- data/lib/concerns_on_rails/models/lockable.rb +317 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +16 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 30c62bfbc26afd05fe7ed2e991add6fa180f3e71316ec638a045932e89bf2ce1
|
|
4
|
+
data.tar.gz: af936258d441cf0206e120853f0a227967f663e577983628087defe2d60d7202
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 359c8240a7d15da31c2a3236a69cb75b1cce57dc5fb80869214c64c05b3b7051d2a17ee17e73d5e9aec3321a53ca8a5de7ada2e73fa80f1f4da50e41fbfc6398
|
|
7
|
+
data.tar.gz: 6b7388a71ceed19ee2517786f5bffdde6acb2e13506ce4ee00b983ff46293a9479aa91d01e2f6912e50a7eb1944f644534369215274fa2234fd889315e32e6f1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.18.0 (2026-06-12)
|
|
4
|
+
|
|
5
|
+
Two new concerns, designed and hardened through adversarial design- and code-review rounds (a shared-reflection registration strategy that produced invalid SQL was caught and replaced before implementation; a time-serialization path that silently lost sub-second precision likewise; a NULL page-boundary value that would have silently dropped rows now raises; a post-review pass fixed `has_many :through` aliasing, which crashed at macro time under lazy class loading and re-derived its `source:` from the alias name at query time). A follow-up enhancement round added bidirectional cursors, allow-listed order presets, and row-value predicates to CursorPaginatable, and `only:`/`except:`/`deprecated:`/`alias_foreign_key:` to Aliasable. 793 examples, 0 failures.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Models::Aliasable**: full association aliasing — `alias_association :writer, :author` (argument order mirrors `alias_method new, old`) gives read, write/assign, `build_`/`create_`/`create_!`/`reload_`/`reset_`, the `_ids` pair, and the query side (`joins`/`includes`/`preload`/`eager_load`/`where`-hash/`reflect_on_association`). One loaded cache under two names (`record.association(:alias)` IS the source's proxy) and only the source macro installs callbacks, so `dependent:`, counter caches, autosave and validations run exactly once. The query side registers a *renamed reflection copy* (registering the same object emits mismatched JOIN/WHERE table names — invalid SQL), with `class_name`/`foreign_key`/`foreign_type` (direct associations) or `source:` (`has_many`/`has_one :through` — resolved exactly when the through class is already loaded, anchored to the source association's own name when it is not) pinned so nothing re-derives from the alias name; the `_reflections` key form (String on <= 7.x, Symbol on newer) is probed at runtime. Aliases are inherited; re-declaring one with the same source in a subclass after redefining that source refreshes the reflection (descendant caches cleared), while repointing an existing alias at a different source raises. Collision validation sweeps every generated method name against associations, methods, columns, and virtual attributes — skipped gracefully when no database is reachable at load time. Aliases of aliases collapse to the terminal source; `has_and_belongs_to_many` is rejected (use `has_many :through`); the `belongs_to` FK attribute is not aliased by default (`alias_foreign_key: true` aliases the `<alias>_id`/`<alias>_type` pair via `alias_attribute`, collision-checked). Options: `only:`/`except:` narrow the generated method map by group (`:reader`/`:writer`/`:build`/`:reload`/`:ids`; narrowing re-declares prune their stale delegators); `deprecated:` (true or a String hint) warns through the new `ConcernsOnRails.deprecator` on every delegator call — the gradual-rename story. Zero new runtime dependencies.
|
|
9
|
+
- **Controllers::CursorPaginatable**: cursor/keyset pagination — the no-COUNT, concurrent-insert-stable complement to Paginatable. `cursor_paginate_by order: { created_at: :desc }, per_page: 25, max_per_page: 200` (+ per-call `order:`/`per_page:`); `cursor_paginated(scope)` returns the page (loaded Array, limit+1 has-more detection) and sets `X-Per-Page`, `X-Count` (this page — totals deliberately never computed), `X-Has-More`, `X-Next-Cursor`; `cursor_pagination_meta` memoizes for Respondable `meta:` composition. The primary key is always appended as a strict tiebreaker (duplicate values never skip/repeat rows; proven by a ties-walk spec) and the keyset WHERE is Arel OR-expansion, portable across adapters and mixed asc/desc. Cursors are opaque URL-safe Base64 tokens pinned to the table + column:direction list — malformed, tampered (non-scalar values, wrong arity), cross-model, or stale-config cursors raise `InvalidCursor`, auto-rescued to a 400 (`invalid_cursor`, `render_invalid_cursor` override point, delegates to Respondable) on any Rescuable controller. Boundary timestamps serialize at microsecond precision (`iso8601(6)`) and cast back through the model's attribute types so each adapter quotes natively. Ordering columns are chosen in code only, validated against the schema; `reorder` defeats `default_scope` ordering; composite/PK-less tables raise `ArgumentError`, as does a NULL ordering value on a page-boundary row (which would otherwise silently drop rows — SQL three-valued logic). Opt-in extras: `bidirectional: true` mints `X-Prev-Cursor`/`X-Has-Prev` (backward fetches walk the inverted ordering and flip back to canonical order; direction is pinned in the token, so prev tokens replayed at forward-only endpoints 400 and pre-bidirectional tokens stay valid forward cursors); `order_presets:`/`default_preset:`/`order_param:` give clients allow-listed named orderings (unknown names → 400 `invalid_order_preset`; switching presets mid-walk invalidates the cursor); `predicate: :auto` upgrades the keyset WHERE to a composite-index-friendly row-value tuple `(a, b, id) > (x, y, z)` on PostgreSQL/MySQL/SQLite under uniform directions, falling back to the portable OR-expansion (`:row` forces tuples and raises on mixed directions; `:or` forces the expansion). Zero new runtime dependencies (URL-safe Base64 via `pack("m0")`, as in WebhookVerifiable).
|
|
10
|
+
|
|
11
|
+
## 1.17.0 (2026-06-10)
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **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.
|
|
17
|
+
- **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.
|
|
18
|
+
|
|
3
19
|
## 1.16.0 (2026-06-10)
|
|
4
20
|
|
|
5
21
|
Two new concerns, hardened by an adversarial edge-case review. 574 examples, 0 failures.
|
data/README.md
CHANGED
|
@@ -44,8 +44,11 @@ Article.published.without_deleted.find("hello-world")
|
|
|
44
44
|
- [Maskable](#-maskable) — non-destructive display masking of sensitive fields
|
|
45
45
|
- [Monetizable](#-monetizable) — integer-cents money columns (BigDecimal)
|
|
46
46
|
- [Auditable](#-auditable) — single-column change history ("paper_trail-lite")
|
|
47
|
+
- [Lockable](#-lockable) — failed-attempt tracking + account lockout
|
|
48
|
+
- [Aliasable](#-aliasable) — full read/write/query aliases for associations
|
|
47
49
|
- **Controller concerns**
|
|
48
50
|
- [Paginatable](#-paginatable) — offset pagination with headers
|
|
51
|
+
- [CursorPaginatable](#-cursorpaginatable) — cursor (keyset) pagination with headers
|
|
49
52
|
- [Filterable](#-filterable) — declarative URL-param filters
|
|
50
53
|
- [Sortable (controller)](#-sortable-controller) — URL-param ordering with allow-list
|
|
51
54
|
- [Respondable](#-respondable) — standardized JSON envelopes
|
|
@@ -57,6 +60,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
57
60
|
- [Throttleable](#-throttleable) — rate limiting with 429 + `X-RateLimit-*` headers
|
|
58
61
|
- [Timezoneable](#-timezoneable) — per-request `Time.zone` from params / header / cookie
|
|
59
62
|
- [Idempotentable](#-idempotentable) — `Idempotency-Key` request replay (409 on concurrent duplicates)
|
|
63
|
+
- [WebhookVerifiable](#-webhookverifiable) — HMAC verification for inbound webhooks (Stripe/GitHub/Shopify)
|
|
60
64
|
- [Module paths & namespacing](#-module-paths--namespacing)
|
|
61
65
|
- [Development](#-development)
|
|
62
66
|
- [Contributing](#-contributing)
|
|
@@ -66,7 +70,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
66
70
|
|
|
67
71
|
## ✨ Why this gem?
|
|
68
72
|
|
|
69
|
-
- **
|
|
73
|
+
- **Twenty-one model concerns + fourteen controller concerns**, all production-ready
|
|
70
74
|
- **One include, one macro** — no boilerplate, no glue code
|
|
71
75
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
|
|
72
76
|
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
|
|
@@ -970,6 +974,71 @@ One entry is recorded **per changed field per save** (creates record `"from" =>
|
|
|
970
974
|
|
|
971
975
|
---
|
|
972
976
|
|
|
977
|
+
## 🔐 Lockable
|
|
978
|
+
|
|
979
|
+
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.
|
|
980
|
+
|
|
981
|
+
```ruby
|
|
982
|
+
class User < ApplicationRecord
|
|
983
|
+
include ConcernsOnRails::Lockable
|
|
984
|
+
|
|
985
|
+
lockable_by max_attempts: 5, unlock_in: 15.minutes
|
|
986
|
+
# lockable_by attempts: :failed_logins, locked_at: :locked_until_at,
|
|
987
|
+
# prefix: :account # => .account_locked / .account_unlocked
|
|
988
|
+
end
|
|
989
|
+
|
|
990
|
+
user.register_failed_attempt! # atomic SQL increment; locks at max_attempts
|
|
991
|
+
user.access_locked? # true while locked (lapses after unlock_in)
|
|
992
|
+
user.attempts_remaining # => 3 (for "3 attempts remaining" messaging)
|
|
993
|
+
user.reset_failed_attempts! # call on successful login
|
|
994
|
+
user.lock_access! # manual lock (hooks: before/after_lock)
|
|
995
|
+
user.unlock_access! # manual unlock (hooks: before/after_unlock)
|
|
996
|
+
User.locked / User.unlocked # expiry-aware scopes
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
**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).
|
|
1000
|
+
|
|
1001
|
+
**Notes**
|
|
1002
|
+
- 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.
|
|
1003
|
+
- 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).
|
|
1004
|
+
- `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.
|
|
1005
|
+
- Reach for Devise's `lockable` when you need unlock tokens, unlock emails, or per-strategy unlocks.
|
|
1006
|
+
|
|
1007
|
+
---
|
|
1008
|
+
|
|
1009
|
+
## 🪞 Aliasable
|
|
1010
|
+
|
|
1011
|
+
Alias an existing association under a second name with **full** semantics — read, write/assign, build/create, and the query side (`joins` / `includes` / `where`-hash) — not just a delegated reader. (`alias_attribute` covers columns only; Rails has no built-in association aliasing.)
|
|
1012
|
+
|
|
1013
|
+
```ruby
|
|
1014
|
+
class Book < ApplicationRecord
|
|
1015
|
+
include ConcernsOnRails::Aliasable
|
|
1016
|
+
|
|
1017
|
+
belongs_to :author
|
|
1018
|
+
has_many :chapters
|
|
1019
|
+
|
|
1020
|
+
alias_association :writer, :author # alias_method order: new, old
|
|
1021
|
+
alias_association :sections, :chapters
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
book.writer # same cached object as book.author
|
|
1025
|
+
book.writer = user # assigns through the original association
|
|
1026
|
+
book.build_writer(...) # build_ / create_ / create_! / reload_ (singular)
|
|
1027
|
+
book.sections << chapter # the same CollectionProxy as book.chapters
|
|
1028
|
+
book.section_ids # ids reader/writer (collection)
|
|
1029
|
+
Book.joins(:sections).where(sections: { title: "Intro" })
|
|
1030
|
+
```
|
|
1031
|
+
|
|
1032
|
+
**Options**: `alias_association new_name, source_name` — repeatable; declare it **after** the source association; re-declaring an existing alias with the **same** source (e.g. in a subclass that redefined the source) is allowed and refreshes it, while repointing an alias at a *different* source raises. Keyword options: `only:`/`except:` narrow the generated methods by group (`:reader`, `:writer`, `:build`, `:reload`, `:ids`); `deprecated: true` (or a String hint) makes every delegator warn through `ConcernsOnRails.deprecator` — the gradual-rename story; `alias_foreign_key: true` (`belongs_to` only) also aliases `<alias>_id` (and `<alias>_type` when polymorphic) via `alias_attribute`.
|
|
1033
|
+
|
|
1034
|
+
**Notes**
|
|
1035
|
+
- One loaded cache under two names: `record.association(:alias)` IS `record.association(:source)`, and only the source macro installs callbacks — `dependent:`, counter caches, autosave and validations run exactly once.
|
|
1036
|
+
- The where-hash key must match the name you joined under (stock-Rails rule): `joins(:sections).where(sections: {...})` works; `joins(:chapters).where(sections: {...})` does not.
|
|
1037
|
+
- The `belongs_to` foreign-key **attribute** is not aliased — pair with `alias_attribute :writer_id, :author_id` if you need it.
|
|
1038
|
+
- `has_and_belongs_to_many` cannot be aliased (use `has_many :through`). `has_many`/`has_one :through` **can** — the copy pins `source:` so it is not re-derived from the alias name; if your classes load lazily and the through model names the source differently (e.g. `belongs_to :author` behind `has_many :authors`), declare `source:` explicitly on the original association. Aliases are inherited by subclasses.
|
|
1039
|
+
|
|
1040
|
+
---
|
|
1041
|
+
|
|
973
1042
|
# 🎮 Controller Concerns
|
|
974
1043
|
|
|
975
1044
|
Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
|
|
@@ -1001,6 +1070,41 @@ end
|
|
|
1001
1070
|
|
|
1002
1071
|
---
|
|
1003
1072
|
|
|
1073
|
+
## 🧭 CursorPaginatable
|
|
1074
|
+
|
|
1075
|
+
Cursor (keyset) pagination — the constant-time complement to Paginatable: **no COUNT query**, stable under concurrent inserts, ideal for infinite scroll and sync feeds.
|
|
1076
|
+
|
|
1077
|
+
```ruby
|
|
1078
|
+
class ArticlesController < ApplicationController
|
|
1079
|
+
include ConcernsOnRails::Controllers::CursorPaginatable
|
|
1080
|
+
|
|
1081
|
+
cursor_paginate_by order: { created_at: :desc }, per_page: 25, max_per_page: 200
|
|
1082
|
+
|
|
1083
|
+
def index
|
|
1084
|
+
render json: cursor_paginated(Article.all) # bad cursors are rescued to a 400 automatically
|
|
1085
|
+
end
|
|
1086
|
+
end
|
|
1087
|
+
```
|
|
1088
|
+
|
|
1089
|
+
**URL params**
|
|
1090
|
+
|
|
1091
|
+
| Param | Default | Notes |
|
|
1092
|
+
|--------------|---------|----------------------------------------------------------|
|
|
1093
|
+
| `?cursor=` | — | The opaque token from `X-Next-Cursor` (omit for page 1) |
|
|
1094
|
+
| `?per_page=` | `25` | Capped at `max_per_page` (default 200; `0` disables the cap) |
|
|
1095
|
+
| `?order=` | first preset | With `order_presets:` only — selects a named ordering from the allow-list (unknown names → 400 `invalid_order_preset`) |
|
|
1096
|
+
|
|
1097
|
+
**Response headers**: `X-Per-Page`, `X-Count` (rows on **this** page — totals are deliberately not computed), `X-Has-More`, `X-Next-Cursor` (only while more pages exist). With `bidirectional: true`: also `X-Has-Prev`, `X-Prev-Cursor`.
|
|
1098
|
+
|
|
1099
|
+
**Notes**
|
|
1100
|
+
- The primary key is always appended as a tiebreaker, so duplicate values never skip or repeat rows; ordering columns are chosen **in code** (never from params) and should be `NOT NULL` (a NULL boundary value raises rather than silently dropping rows).
|
|
1101
|
+
- Cursors are opaque, table/order-pinned tokens — a malformed, cross-endpoint, or stale-config cursor renders a 400 (`invalid_cursor`; override `render_invalid_cursor` to customize, delegates to Respondable's `render_error` when present). They are **not signed**: a client can mint different boundary values, but values are cast through the model's attribute types and bound by Arel (no injection) and the relation's own scoping still applies — treat a cursor as a page position, never an authorization boundary.
|
|
1102
|
+
- `cursor_paginated` uses `reorder` (replaces any `default_scope` ORDER BY) and returns a loaded Array. Don't wrap it with the controller Sortable's `sorted` — pass `order:` per call instead.
|
|
1103
|
+
- Forward-only by default — `bidirectional: true` (macro or per call) adds prev cursors and `X-Has-Prev`/`X-Prev-Cursor`; direction is pinned in the token, so prev tokens replayed on forward-only endpoints 400 and old direction-less tokens stay valid. `order_presets: { newest: {...}, top: {...} }` (+ `default_preset:`, `order_param:`) lets clients pick a **named** ordering from an allow-list. `predicate: :auto` upgrades the keyset WHERE to a row-value tuple `(a, b, id) > (x, y, z)` on PostgreSQL/MySQL/SQLite when directions are uniform — composite-index friendly — falling back to the portable OR-expansion (`:row`/`:or` force a strategy).
|
|
1104
|
+
- Use Paginatable when you need page numbers and totals.
|
|
1105
|
+
|
|
1106
|
+
---
|
|
1107
|
+
|
|
1004
1108
|
## 🔎 Filterable
|
|
1005
1109
|
|
|
1006
1110
|
Declarative URL-param filtering with three modes per filter.
|
|
@@ -1361,6 +1465,45 @@ Per-key lifecycle: claim atomically (`write unless_exist`, TTL `lock_ttl:`) →
|
|
|
1361
1465
|
|
|
1362
1466
|
---
|
|
1363
1467
|
|
|
1468
|
+
## 🪝 WebhookVerifiable
|
|
1469
|
+
|
|
1470
|
+
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.
|
|
1471
|
+
|
|
1472
|
+
```ruby
|
|
1473
|
+
class WebhooksController < ApplicationController
|
|
1474
|
+
include ConcernsOnRails::Controllers::WebhookVerifiable # declare BEFORE Idempotentable
|
|
1475
|
+
|
|
1476
|
+
verify_webhook :stripe, secret: -> { ENV["STRIPE_WEBHOOK_SECRET"] }, scheme: :stripe
|
|
1477
|
+
verify_webhook :github, secret: -> { ENV["GITHUB_WEBHOOK_SECRET"] }, scheme: :github
|
|
1478
|
+
verify_webhook :shopify, secret: [ENV["NEW_SECRET"], ENV["OLD_SECRET"]], scheme: :shopify # rotation
|
|
1479
|
+
verify_webhook :custom, secret: "s3cr3t", scheme: :hex, header: "X-Acme-Signature"
|
|
1480
|
+
# verify_webhook secret: ... # no actions = catch-all (declare specific rules first)
|
|
1481
|
+
|
|
1482
|
+
def stripe
|
|
1483
|
+
event = JSON.parse(request.raw_post) # parse the raw body — it is what was signed
|
|
1484
|
+
# ...
|
|
1485
|
+
end
|
|
1486
|
+
end
|
|
1487
|
+
```
|
|
1488
|
+
|
|
1489
|
+
| Scheme | Header (default) | Format |
|
|
1490
|
+
|--------|------------------|--------|
|
|
1491
|
+
| `:github` | `X-Hub-Signature-256` | `sha256=<hex>` |
|
|
1492
|
+
| `:shopify` | `X-Shopify-Hmac-Sha256` | strict Base64 of the binary HMAC |
|
|
1493
|
+
| `:stripe` | `Stripe-Signature` | `t=<unix>,v1=<hex>[,v1=…]` — signs `"#{t}.#{body}"`, every `v1` tried, `tolerance:` rejects stale **and** future timestamps |
|
|
1494
|
+
| `:hex` / `:base64` | — (`header:` required) | plain hex / strict Base64 HMAC of the body |
|
|
1495
|
+
|
|
1496
|
+
**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).
|
|
1497
|
+
|
|
1498
|
+
**Notes**
|
|
1499
|
+
- 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.
|
|
1500
|
+
- 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.
|
|
1501
|
+
- 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.
|
|
1502
|
+
- 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`.
|
|
1503
|
+
- In tests: `skip_before_action :verify_webhook_signature!`, or sign payloads for real with `OpenSSL::HMAC`. After a pass, `webhook_verified?` is true.
|
|
1504
|
+
|
|
1505
|
+
---
|
|
1506
|
+
|
|
1364
1507
|
## 🗂️ Module paths & namespacing
|
|
1365
1508
|
|
|
1366
1509
|
Every concern is available under two paths:
|