permittable 0.15.1 → 0.16.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: 2284fdea0043c6364608ab68b43de16bbea66462f12a4f2e6ee5bdf47087fbb6
4
- data.tar.gz: 8dd729f396cee1ba015179bd3ee8522754bc6312f4816810ed060134da8456d7
3
+ metadata.gz: '008b53fb20bca2df208a1bb8a09f942207790d4471b3e7346745df1806858184'
4
+ data.tar.gz: 2a3e3b24d9339ec2336f31690916bc1c25ef8206de97d94243d11497525d709b
5
5
  SHA512:
6
- metadata.gz: 239926696fff412e35a4ef9bf0f392f92006c90a9ff8d69779a575eb71420ff441a433123a23c7d9e459d8eaf62d74bc9371aa1649bb739b4d89db60831d41ad
7
- data.tar.gz: 04b5b1fe3b2993056a650f2f293a9a95e8c46f5c8bc5eb609929a0e93ae9e81591cc6434822fce5dcbfd06eb9ae4b17081165fdc817c91495abc59badba12339
6
+ metadata.gz: 424948f6d0d1f8115985432a55b0d6e64b4955a4142d0f98b51407fe718bb642217ae3b89303e07d80dd56f475a404f3628bb62e5941a21601c7d6283d07d2da
7
+ data.tar.gz: 43aa1e3832649fc76fb382dfd0719ec9012ea4adf50e67224af9a939b6631ad7b2837e4c25c52a38b24d03973c40fa367f8dadf388ef5d7905b6f88da76e9ae4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.16.0 (2026-11-15)
4
+ <!-- title: date and boolean strings held to what the parser read, an audit that stops counting skipped contracts, and 422 rendered as an Integer -->
5
+
6
+ Four changes to what a request, an audit, an export or a spec is held to. A `:boolean` refuses a number that only equals 1 or 0, and a `:date` or `:datetime` String refuses any part the parser did not read (#181). `permittable:audit` reports an action whose `enforce_params_contract` callback was skipped as `no contract` (#184). The gem passes the Integer 422 or 400 to `render`, so Rails 7.1 with Rack 3.1 stops printing a deprecation warning (#183). `Permittable::OpenAPI.document` exports a literal `permit_params "*"` action, and `permittable:generate` drafts carry more TODO hints and can no longer break a comment across lines (#182).
7
+
8
+ Minor rather than patch: each entry can make a request, a boot, an audit gate or a spec that passes today fail. Before upgrading, read the entries that match what you use. A request can now be refused: a `:boolean` sent `1.0`, `0.0`, `BigDecimal("1")`, `Rational(1, 1)` or `Complex(1, 0)`, a `:date` or `:datetime` String with unread text (`"2026-01-01xyz"`, `"2026-01-15\n2099-12-31"`), and the localised or invisible-character forms that 0.15.1 read correctly (`"5. September 2026 um 10:00:00 MESZ"`, `"sam. 05 sept. 2026 10:00:00 CEST"`, `"2026-09-05‎"` with a left-to-right mark), all as `invalid_type` (#181). A boot can now fail: a `default:`, `example:` or `in:` on a `:boolean` field that holds such a number, and a `:date` or `:datetime` `example:` with unread text (#181). An audit gate can now fail: `permittable:audit[strict]` counts an `enforce: true` or monitor-mode action whose callback was skipped (#184). A spec can now fail: one that stubs `render` and expects `status: :unprocessable_entity` from the gem's own envelope now sees `422` (#183). No string that is still accepted is read as a different instant than on 0.15.1.
9
+
10
+ ### Fixed
11
+ - **A `:boolean` refuses a number that only equals 1 or 0.** `true`, `false`, `"true"`, `"false"`, `"1"`, `"0"`, `1` and `0` are accepted as before. `1.0`, `0.0`, `BigDecimal("1")`, `Rational(1, 1)` and `Complex(1, 0)` were accepted on 0.15.1, so a JSON `1.0` was read as `true`; they are now `invalid_type`. A `default:`, `example:` or `in:` on a `:boolean` field that holds one raises at class load (`:default for field :b violates its own contract (invalid_type)`); write it as `true`, `false`, `1` or `0`. (#181)
12
+ - **A `:date` or `:datetime` String with text the parser did not read is refused.** 0.15.1 accepted `"2026-01-01xyz"`, `"2026-01-15\n2099-12-31"`, `"2026-01-01 <script>"`, a NUL or zero-width character after the date, two conflicting offsets and `"2026-09-05 10:00 EST xyz"`; each is now `invalid_type`, and an authored `example:` in such a form raises at class load (`default:` and `in:` already did). Still read, as the same instant as 0.15.1: a browser `Date#toString` remark in brackets in any language (`"Sat Sep 05 2026 11:00:00 GMT+0300 (Москва, стандартное время)"`), a numeric offset followed by a zone abbreviation as a label, as Go's `time.String()` prints it (`"2026-09-05 10:00:00 +0800 CST"` is `02:00Z`), an RFC 9557 suffix (`"2026-09-05T10:00+01:00[Europe/Paris]"`), ActiveSupport `TimeWithZone#inspect` when the abbreviation and the offset agree, and one leading byte-order mark. Some other trailing junk is still read as on 0.15.1 (`"2026-09-05 22:00 am"`, a trailing `,` or `.`). (#181)
13
+ - **Known limit: strings 0.15.1 read correctly are now refused.** Localised joiner and weekday words (`"5. September 2026 um 10:00:00 MESZ"`, `"Sa 5. Sep 10:00:00 CEST 2026"`, `"sam. 05 sept. 2026 10:00:00 CEST"`), a left-to-right mark or other invisible character anywhere, and a byte-order mark anywhere but once at the start. Send ISO 8601. (#181)
14
+ - **A date String that 0.15.1 read as the wrong instant is refused.** A zone abbreviation followed by a different numeric offset (`"Sat, 05 Sep 2026 18:00:00 CST +08:00"` was `00:00Z` the next day), a spelled-out zone name with no brackets (`"... 10:00:00 AM Central European Summer Time"` was `16:00Z`) and `"EST5EDT"`. (#181)
15
+ - **Cost.** A plain ISO date or date-time costs the same as on 0.15.1. Other accepted shapes cost about 0.4 to 0.6 ms per value, three to four times 0.15.1, and a string with more than 16 parts is refused before any reparse. (#181)
16
+ - **`permittable:audit` no longer reports a skipped contract as covered.** An action whose `before_action` was removed with `skip_before_action :enforce_params_contract` (bare, `only:`, `except:`, `if:` or `unless:`; `ActionController::Base` and Metal; a subclass of a skipping parent) was covered in the audit, while a real request with an undeclared key got 200. An `enforce: true` or monitor-mode rule on such an action is now `no contract`, per action for `only:` and `except:`, with the note "request not checked before the action: skip_before_action :enforce_params_contract took its before_action off this action (checked only if the action reads permitted_params)", and `permittable:audit[strict]` counts it when the verb takes a body. A skip with `if:` or `unless:` is reported for every action it could reach, as "request may not be checked before the action: skip_before_action :enforce_params_contract is conditional (if:/unless:) and the audit cannot evaluate the condition (checked only if the action reads permitted_params)". This holds even when the action reads `permitted_params` and the request is validated there; to have such an action counted as covered, declare its rule lazily (neither `enforce: true` nor monitor mode, own or app-wide). Lazy rules are unchanged, and a `before_action :enforce_params_contract` declared again after the skip is covered again. The audit still cannot see a conditional skip made with `skip_callback`, a host's own `before_action :enforce_params_contract, only:` after a skip, or a `raise: false` skip on an ancestor that never included Permittable; see the README audit section. The matchers (`accept_params`, `reject_params`) do not see a skipped callback. (#184)
17
+ - **The status the gem renders is an Integer.** On Rails 7.1 with Rack 3.1 or later, every 422 the gem rendered printed `warning: Status code :unprocessable_entity is deprecated and will be removed in a future version of Rack. Please use :unprocessable_content instead.` The gem now hands `render`, and a bare Metal controller's `status=`, the Integer 422 or 400; the response status line is unchanged on every Rails. `InvalidParameters#status` and the `status:` a host's `render_error` receives stay Symbols, so a `render_error` that forwards that Symbol to `render` still gets the warning on Rails 7.1 and can pass the Integer itself. A status that is neither a Symbol, a String nor an Integer is passed through; in the problem+json format it raised `NoMethodError` and rendered a 500 on 0.15.1, and now renders. (#183)
18
+ - **`Permittable::OpenAPI.document` exports a literal `permit_params "*"` action.** It raised `NoMethodError` for a controller that declared one. (#182)
19
+ - **A generated `# TODO` comment can no longer carry Ruby.** A column name containing a line break ended the comment, and the rest of the note became a line of Ruby in the drafted contract; the json-array and binary-column TODOs of 0.15.1 had this. Every drafted line now has its line breaks collapsed to a space. (#182)
20
+
21
+ ### Added
22
+ - **`permittable:generate` drafts carry new TODO hints** for a `:decimal` column's precision and scale (the contract does not bound precision), a column named like a Rails request key (`format`, `controller`, `action`, `authenticity_token`), and a json column that holds arrays. (#182)
23
+ - **README notes:** `permitted_params` is a `HashWithIndifferentAccess` in every mode; the cost of very many unknown keys under `unknown: :error`; a standalone `Contract` is fed the raw payload; a literal `"*"` action. (#182)
24
+
25
+ ### Changed
26
+ - **`skip_before_action` is overridden on controllers that register the gem's callback**, to record a skip of `enforce_params_contract`; other skips are passed through untouched. `Permittable::Audit.inert?(controller, rule)` (undocumented) is gone. `Audit::Entry#inert` holds the reason (`:no_callbacks`, `:skipped` or `:conditional`) where it held `true`; `Entry#inert?` still returns true or false. (#184)
27
+
3
28
  ## 0.15.1 (2026-11-12)
4
29
  <!-- title: a date String's hour and glued offset read or refused, and the exported body and descriptors list the routing keys, a renamed CSRF token and a route's format default -->
5
30
 
data/README.md CHANGED
@@ -536,7 +536,7 @@ request params
536
536
 
537
537
  Validation is **lazy by default**: it runs on the first `permitted_params` call, so an action that never reads params never pays for it. Pass `enforce: true` to run it in a `before_action` instead, rejecting bad requests before the action body executes. Results are **memoized per action**.
538
538
 
539
- In [monitor mode](#monitor-mode-roll-out-without-rejecting) the same flow runs, but a violation is reported instead of raised and the request proceeds with the raw params passed through.
539
+ In [monitor mode](#monitor-mode-roll-out-without-rejecting) the same flow runs, but a violation is reported instead of raised and the request proceeds with the raw params passed through. `permitted_params` is a `HashWithIndifferentAccess` in every mode, never an `ActionController::Parameters`, so `.permitted?` and `.require` are not available on it and `Model.create!(permitted_params)` does not raise `ForbiddenAttributesError`.
540
540
 
541
541
  A bare `ActionController::Metal` controller has no callbacks and no `rescue_from` until it includes them. `include AbstractController::Callbacks` and then `include ActionController::Rescue` (the order `ActionController::Base` uses, so a rejection also covers the `before_action`), before or after `include Permittable`. The rendering modules (`AbstractController::Rendering`, `ActionController::Rendering`, `ActionController::Renderers::All`) are optional: without `ActionController::Rendering` the gem writes the error response itself, with the same status, content type and JSON body. An `enforce: true` rule, or a rule declaring `mode: :monitor`, raises `ArgumentError` at class load when `AbstractController::Callbacks` is missing. The class-load check reads only the rule's own `mode:`, not the app-wide `Permittable.mode`, which an initializer usually sets after the controller loads. A rule with no `mode:` under an app-wide `Permittable.mode = :monitor` therefore loads, but its `before_action` is not there to run it: `permittable:audit` reports those actions as `no contract` (`contract cannot run`) instead of as monitored. An `enforce: true` rule that is not in monitor mode still raises at class load when `ActionController::Rescue` is missing or was included before `AbstractController::Callbacks`. Both modules have to be in by the time of `permit_params`. A check registered at that first `permit_params` runs after any callback declared between the include and it (see [Per-request locales](#custom-error-messages-message)), and with `include Permittable` first, a `skip_before_action :enforce_params_contract` goes after the first `permit_params`, since the callback does not exist before it. On a bare `ActionController::Metal` controller that includes `ActionController::Rescue` after `include Permittable`, the gem registers its handler at the first `permit_params` instead, so there it is a catch-all declared after that `permit_params` that answers first, and a re-declaration goes after it.
542
542
 
@@ -570,6 +570,8 @@ class ReportsController < ApiController
570
570
  end
571
571
  ```
572
572
 
573
+ A literal `"*"` in `permit_params` is only an action name: it matches an action called `"*"` and nothing else, and a rule with no actions is the catch-all. The `"*"` key is the OpenAPI export's catch-all marker for plain-Ruby hosts only.
574
+
573
575
  Rules accumulate by **reassignment, never mutation**, so subclasses inherit copy-on-write and can never corrupt a parent's contract.
574
576
 
575
577
  ### The field DSL
@@ -694,13 +696,13 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
694
696
  | `:integer` | `Integer`; whole `Float`s (`4.0`); canonical base-10 numeric strings (`"-12"`, `"007"`) | `"4.5"`, `"abc"`, `"1_8"`, `" 99 "`, `4.5`, NaN/Infinity |
695
697
  | `:float` | a real `Numeric`; a canonical numeric string (`"1.5"`, `".5"`, `"-2e3"`) | `"abc"`, `"1_8.5"`, `" 1.5 "`, and any number outside a Float's range, as a `Numeric` or a string alike (`BigDecimal("1e-400")`, `"1e400"`), a `Complex` (even `Complex(1, 0)`), a `Numeric` that cannot be converted |
696
698
  | `:decimal` | a real `Numeric`, or a canonical numeric string → `BigDecimal`; a `Rational` whose decimal expansion is exact (`Rational(1, 2)`, `3r`) | Unparseable strings, `"1_000"`, `" 1.5 "`, a `Complex`, a `Rational` no decimal holds exactly (`Rational(1, 3)`), a `Numeric` that cannot be converted |
697
- | `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
699
+ | `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, the Integers `1`/`0` | `"yes"`, `"on"`, `2`, and a number that merely equals 1 or 0 (`1.0`, which is how JSON can send it) |
698
700
  | `:date` | `Date`; a `Time`, `DateTime` or `ActiveSupport::TimeWithZone` that is exactly midnight UTC, as that `Date`; a string naming a **complete** date, in any format `Date.parse` understands (`"2026-09-05"`, `"2026/09/05"`, `"Sep 5, 2026"`); a string with a time or zone only if it is exactly midnight UTC (`"2026-09-05T00:00:00Z"`) | Unparseable strings, **incomplete** ones (`"09/2026"`, `"5th"`, `"Sept"`), and any other instant, as an object or a string — one with a time of day, or a midnight in another zone |
699
701
  | `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date`; a string naming a complete date, with or without a time (an ISO date with an offset or `Z` and no time, `"2026-09-05-05:00"`, is midnight at that offset) | Unparseable strings, any string without a complete date (`"10:30"`), any other date followed by a bare offset and no time of day (`"Sep 5 2026 -05:00"`), an offset or an hour the parser would drop (`"12:00 2026-09-05 -05:00"`, `"2026-09-05 10 -05:00"`, `"2026-09-05T10"`), any word after a date that is not a zone Ruby knows, a day name or an era (`"2026-09-05 XYZ"`, `"2026-09-05 noon"`), and an ISO date followed by `T` and no time (`"2026-09-05T"`) |
700
702
  | `:json` | Any `Hash` — passed through uncast, see [free-form hashes](#free-form-hashes-json) | Arrays, scalars |
701
703
  | `:file` | An upload object (`ActionDispatch::Http::UploadedFile`, `Rack::Test::UploadedFile`), passed through uncast | A `String` (including a form's file field sent as text), numbers, hashes, arrays |
702
704
 
703
- **Dates are parsed, never guessed.** `Date.parse` fills in what a string omits *from today* — `"09/2026"` becomes the 1st, `"5th"` becomes this month of this year — so the same request would mean different things on different days. A `:date` or `:datetime` string must therefore name all three of year, month and day; which **format** it names them in is `Date.parse`'s business, so every complete format it understands still works. A `:datetime` may omit the *time* part, which reads as midnight UTC. An ISO date with an offset or `Z` and no time is midnight at that offset (`"2026-09-05 -05:00"` is `2026-09-05T05:00:00Z`; a `:date` takes it only where that is midnight UTC, as in `"2026-09-05+00:00"`); any other date followed by a bare offset and no time of day is refused, since Ruby's parser reads the offset as a time of day or drops it, and so is an ISO date followed by `T` and no time. A zone *name* after a date is honoured where Ruby knows it, with or without a time (`"2026-09-05 EST"` is `2026-09-05T05:00:00Z`, so a `:date` refuses it, and `"Sat Sep 5 10:00:00 2026 EST"` is `15:00:00Z`), and refused where it does not. So is **any other word** after a complete date that is not a day name or an era (`"2026-09-05 XYZ"`, `"2026-09-05 noon"`, `"2026-09-05 foo"`): all it can be is a zone, and a zone Ruby cannot honour is never guessed at. An offset glued to a zone name with no time is midnight at that offset where Ruby honours it (`"2026-09-05 GMT+5"` is `2026-09-04T19:00:00Z`) and refused where it does not (`"2026-09-05 UTC+99"`, `"2026-09-05 EST+5"`). An offset after the year of git's and ctime's format is read (`"Sat Sep 5 10:00:00 2026 -0500"` is `15:00:00Z`). An hour or offset the parser would drop or misread (`"2026-09-05T10"`, `"2026-09-05 10 -05:00"`, `"2026-09-05 10 A.D."`, `"2026-09-05 10 a"`, `"12:00 2026-09-05 -05:00"`) is refused rather than read as some other instant.
705
+ **Dates are parsed, never guessed.** `Date.parse` fills in what a string omits *from today* — `"09/2026"` becomes the 1st, `"5th"` becomes this month of this year — so the same request would mean different things on different days. A `:date` or `:datetime` string must therefore name all three of year, month and day; which **format** it names them in is `Date.parse`'s business, so every complete format it understands still works. A `:datetime` may omit the *time* part, which reads as midnight UTC. An ISO date with an offset or `Z` and no time is midnight at that offset (`"2026-09-05 -05:00"` is `2026-09-05T05:00:00Z`; a `:date` takes it only where that is midnight UTC, as in `"2026-09-05+00:00"`); any other date followed by a bare offset and no time of day is refused, since Ruby's parser reads the offset as a time of day or drops it, and so is an ISO date followed by `T` and no time. A zone *name* after a date is honoured where Ruby knows it, with or without a time (`"2026-09-05 EST"` is `2026-09-05T05:00:00Z`, so a `:date` refuses it, and `"Sat Sep 5 10:00:00 2026 EST"` is `15:00:00Z`), and refused where it does not. So is **any other word** after a complete date that is not a day name or an era (`"2026-09-05 XYZ"`, `"2026-09-05 noon"`, `"2026-09-05 foo"`): all it can be is a zone, and a zone Ruby cannot honour is never guessed at. An offset glued to a zone name with no time is midnight at that offset where Ruby honours it (`"2026-09-05 GMT+5"` is `2026-09-04T19:00:00Z`) and refused where it does not (`"2026-09-05 UTC+99"`, `"2026-09-05 EST+5"`). An offset after the year of git's and ctime's format is read (`"Sat Sep 5 10:00:00 2026 -0500"` is `15:00:00Z`). An hour or offset the parser would drop or misread (`"2026-09-05T10"`, `"2026-09-05 10 -05:00"`, `"2026-09-05 10 A.D."`, `"2026-09-05 10 a"`, `"12:00 2026-09-05 -05:00"`) is refused rather than read as some other instant. Likewise any part of the string the parser read nothing from is refused, not dropped: junk after, before or between dates (`"2026-01-01xyz"`, `"2026-01-15\n2099-12-31"`, `"2026-01-01 <script>"`), and control or zero-width characters (bar one leading byte-order mark, which a CSV or text file saved as UTF-8 starts with, and which is dropped). What is let through repeats something that was read: a remark in brackets (`"(Central Daylight Time)"`, `"(Москва, стандартное время)"`, in any language, with commas, `&` or a nested bracket but no digits), an RFC 9557 suffix (`"[Europe/London]"`, `"[GMT+01:00]"`, the offset governs), Go's `m=+0.1`, and an offset or zone name that agrees with the one read (`"EDT -04:00"`), and a zone abbreviation after a numeric offset, which only labels it (`"2026-09-05 10:00:00 +0800 CST"` is `02:00:00Z`, as Go and `date` print it). Two zones in the other order (`"EDT -0700"`, `"CST +08:00"`: the abbreviation is what is read) or two different offsets are refused.
704
706
 
705
707
  **Strings in other encodings are inspected, never converted.** A String whose bytes are not valid in its **own** encoding (`"caf\xC3"` in UTF-8, a lone UTF-16 surrogate) is `invalid_type` for every scalar type, before `normalize:` or `format:` sees it. Any other String keeps its encoding: a `:string` value is handed back exactly as it arrived, so a controller using Rails' `skip_parameter_encoding` or `param_encoding` gets its binary or Shift_JIS text unchanged. The number, boolean and date types parse a UTF-8 **copy** of the text, so UTF-16 `"12"` casts to `12` for an `:integer`; when the text has no UTF-8 reading (a byte Windows-1252 leaves undefined) that is `invalid_type`. `normalize:` and `format:` work on the String in its own encoding. Where a normalizer cannot handle that encoding (`:squish` on UTF-16), the value is left as it is; where a `format:` pattern cannot be applied to it (a non-ASCII pattern against UTF-16 or binary bytes), that is a `format` violation. `in:` compares Strings as Ruby does, encoding included. A `:json` field's contents are not examined.
706
708
 
@@ -810,7 +812,7 @@ rescue_from Permittable::InvalidParameters, with: :render_invalid_parameters
810
812
 
811
813
  Paths are fully qualified: `user.address.zip`, `line_items[1].sku`.
812
814
 
813
- **`details` is complete; `message` is prose.** The `details` array names **every** offender, however many there are — it is the machine-readable channel and nothing is dropped from it. The `message` string is a sentence for a person, and it also lands in your logs and in every exception tracker, so it is bounded: at most ten offenders, each truncated past 120 characters, then a count of the rest (`…, and 49990 more`). Before that bound, a request carrying 50,000 undeclared keys against `unknown: :error` produced a **1 MB** exception message and a 1 MB log line. The 422 body still carries the complete `details`, so it stays proportional to the number of violations; the field bounds are what keep that number down.
815
+ **`details` is complete; `message` is prose.** The `details` array names **every** offender, however many there are — it is the machine-readable channel and nothing is dropped from it. The `message` string is a sentence for a person, and it also lands in your logs and in every exception tracker, so it is bounded: at most ten offenders, each truncated past 120 characters, then a count of the rest (`…, and 49990 more`). Before that bound, a request carrying 50,000 undeclared keys against `unknown: :error` produced a **1 MB** exception message and a 1 MB log line. The 422 body still carries the complete `details`, so it stays proportional to the number of violations; the field bounds are what keep that number down. The check itself lists one violation per undeclared key, so its cost grows with the key count (tens of seconds at a few hundred thousand keys, growing linearly); cap the request body size or key count in front of the app.
814
816
 
815
817
  **Status codes.** A bad root key renders **400** — the request is malformed; the envelope you asked for isn't there, or isn't an object. Field-level violations render **422** — well-formed, semantically wrong. The two root failures are told apart by their code: `missing` when the key really is absent (`{}`, `{"user": null}`, `{"user": ""}`), `invalid_type` when the client sent it with the wrong shape.
816
818
 
@@ -1303,6 +1305,8 @@ Three things it tells you that nothing else does:
1303
1305
  - **Which contracts aren't enforcing yet.** The audit runs inside the app, so unlike the exported OpenAPI it resolves the **effective** mode — a rule's own `mode:` first, then your app-wide `Permittable.mode`. This is the [monitor-mode](#monitor-mode-roll-out-without-rejecting) rollout dashboard.
1304
1306
  - **Which contracts have gone stale.** A contract declared for an action no route reaches, or for a routed action Rails would 404, is a renamed or deleted action that left its contract behind. A contract on a shared base controller is stale only when no controller that inherits it reaches the action, and it is listed once, under the class that declares it.
1305
1307
 
1308
+ A contract that validates from the `before_action` (an `enforce: true` rule, or one in monitor mode) but whose callback was removed with a plain `skip_before_action :enforce_params_contract` reads `no contract — request not checked before the action: ...`, and counts against `[strict]` if it takes a body. `enforce: true` promises the check runs before the action whatever the action does, and the skip took that away. The note says the request is checked only if the action reads `permitted_params`: one that does is still validated there (rejected, or a monitor event), so the row can be a gap that is not one in practice. The audit does not read the action's code. To have such an action count as covered, declare its rule as a lazy one, neither `enforce: true` nor monitor mode (its own `mode:` or an app-wide `Permittable.mode = :monitor`, which makes every rule a monitor one): it validates when the action reads `permitted_params`, skip or not, and the audit reads it as covered. A subclass that skips what its parent declared is reported the same way, and so is a child's own `permit_params` under a parent that skipped it, since the callback is registered once per class tree and is not put back. A skip with `only:` or `except:` is read per action, the way Rails compares them (Symbols or Strings, one name or an Array): the actions it names read as above, the ones it spares stay covered. Skips in a row add up, and a subclass's skip never reaches its parent. A skip with `if:` or `unless:` cannot be evaluated without running your code, so every action it could reach reads `no contract — request may not be checked before the action: ... is conditional`, whether or not the condition holds at request time; an `only:` skip that also has `if:` is reported as skipped for the actions it names. Declaring `before_action :enforce_params_contract` again after a skip puts the check back, and the audit reads it as covered. The audit learns of a skip from the `skip_before_action` call itself (the gem wraps it on the controller once it registers its callback), so three things stay invisible: a skip made with `raise: false` on an ancestor that never included `Permittable` (it has nothing to skip itself, Rails rewrites the callback of the subclass that registered it, and nothing is recorded), a conditional skip made with `skip_callback(:process_action, :before, :enforce_params_contract, if: ...)` directly, and a `before_action :enforce_params_contract, only: ...` the app declares itself. All leave the action reported as covered; a skip made with `skip_callback` and no condition removes the callback and is reported. Check those with a request spec.
1309
+
1306
1310
  A route that lists several verbs is audited once per verb. A `match ... via: :all` route is expanded into exactly GET, POST, PUT, PATCH and DELETE, and listed once for each. Rails sends a request to the first route that matches its path and verb, and the audit lists what Rails dispatches. With `get "hooks", to: "webhooks#index"` before `match "hooks", to: "webhooks#receive", via: :all`, `GET /hooks` is listed under `index` only, and `receive` gets the other four verbs. Turn the two around and the `get` route is never reached, so it is not listed, and a contract on `index` shows as stale. The same rule removes a route declared twice, and the OpenAPI export applies it too. Only an unconstrained route hides the routes behind it, and only on the identical path. A route with a constraint can pass a request on: a segment requirement (`id: /\d+/`), a subdomain or host, or a constraint object or lambda. So the routes after it stay listed, even when the constraint happens to admit every request. With `constraints(AdminConstraint) { post "settings", ... }` ahead of `post "settings", to: "settings#update"`, every non-admin `POST` reaches `settings#update`, so its row stays and `[strict]` still counts it. The audit errs towards an extra row rather than a hidden route. Paths that differ at all, even only in a variable's name (`users/:id` and `users/:slug`), are both listed. `resources` routes all seven actions whether or not they exist. A route that Rails would 404 reads `action not found`: no method (inherited ones count), no `action_missing`, and no template to render implicitly. It is not counted against `[strict]` or as coverage. It stays in the table rather than disappearing. The template check uses the class-level view paths and the default lookup details. So a template that is only found at request time reads `action not found`, for example one behind a `prepend_view_path` in a `before_action`, or one that exists only as a variant.
1307
1311
 
1308
1312
  A catch-all 404 route (`match "*path", to: "application#not_found", via: :all`) shows its POST, PUT and PATCH rows as accepting a body. They do accept one: every stray body reaches the controller. Route only GET to the controller (Rails answers HEAD from it). Send the other verbs to a plain Rack endpoint, which never parses the body and which the audit does not list:
@@ -1430,7 +1434,7 @@ Both work on a controller class, a controller instance, or a [standalone `Contra
1430
1434
 
1431
1435
  The negated forms follow the same rules as `not_to permit_param`: `not_to accept_params(...)` passes only when the contract rejects the payload, and `not_to reject_params(...)` only when it accepts it. Neither takes a qualifier — `not_to accept_params(...).returning(x)` would pass both on a rejection and on an acceptance returning anything else, so it raises and names the positive form — and both fail, rather than passing, when no rule covers the action or the subject declares no contracts.
1432
1436
 
1433
- The matchers run **without a request**, so they know the controller's settings but not its route. The CSRF parameter name set at class level with `request_forgery_protection_token` is exempt from `unknown: :error` as it is at runtime, but a per-instance override is not seen, and neither are a route's **path parameters**: a payload `id` on a member action under `unknown: :error` is reported `unknown`, although a real `PATCH /users/1` carries it in the URL and is accepted. Leave path parameters out of the payload you pass to `accept_params` / `reject_params`. On a controller the payload goes through the same normalization a request gets, so a `nil` array element is dropped as Rails drops it (unless the app turned `perform_deep_munge` off), and a Symbol value (`status: :active`) reads as the String a request carries; a standalone `Contract` gets the payload as given, so a Symbol value there is still `invalid_type`. A failure message that echoes params (the returned hash, a `returning` expectation) redacts them like a request log: the names of `sensitive:` fields, plus the app's `config.filter_parameters` when a Rails application is booted. Matching is by substring, like Rails, so `sensitive: true` on `pin` also redacts `shipping`; when a `returning` mismatch is hidden by that, the message adds `differs at: pin, shipping`, names only. Under a `root:` contract the hash is filtered as the request logs it, under its root, so a scoped filter such as `"payment.number"` applies; the note stops at a redacted key and never names anything below it.
1437
+ The matchers run **without a request**, so they know the controller's settings but not its route. The CSRF parameter name set at class level with `request_forgery_protection_token` is exempt from `unknown: :error` as it is at runtime, but a per-instance override is not seen, and neither are a route's **path parameters**: a payload `id` on a member action under `unknown: :error` is reported `unknown`, although a real `PATCH /users/1` carries it in the URL and is accepted. Leave path parameters out of the payload you pass to `accept_params` / `reject_params`. On a controller the payload goes through the same normalization a request gets, so a `nil` array element is dropped as Rails drops it (unless the app turned `perform_deep_munge` off), and a Symbol value (`status: :active`) reads as the String a request carries; a standalone `Contract` gets the raw payload as given, with no deep munge and no Symbol-to-String conversion of values (a Symbol value there is still `invalid_type`), because `Contract#call` is fed by app code, not by a request. A failure message that echoes params (the returned hash, a `returning` expectation) redacts them like a request log: the names of `sensitive:` fields, plus the app's `config.filter_parameters` when a Rails application is booted. Matching is by substring, like Rails, so `sensitive: true` on `pin` also redacts `shipping`; when a `returning` mismatch is hidden by that, the message adds `differs at: pin, shipping`, names only. Under a `root:` contract the hash is filtered as the request logs it, under its root, so a scoped filter such as `"payment.number"` applies; the note stops at a redacted key and never names anything below it. They do not see a skipped `enforce_params_contract` callback either: `reject_params` still passes for an action whose real request is not checked before the action runs, because its callback was removed with `skip_before_action`. `permittable:audit` is what reports that.
1434
1438
 
1435
1439
  ### Standalone contracts (no controller)
1436
1440
 
@@ -1487,7 +1491,7 @@ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
1487
1491
 
1488
1492
  Every operation references shared components for the [error envelope](#violations-and-error-responses): a `422` response always, plus a `400` when the contract declares a `root:`. So consumers get typed *errors*, not just typed inputs.
1489
1493
 
1490
- **What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a construct with no faithful ECMA-262 spelling is exported as `x-permittable-pattern` rather than a mistranslated `pattern`: a Ruby-only escape or flag, one ECMA-262 reads differently or refuses to compile at all (`{,3}`, `&&` in a class, a backreference, the word boundary `\b`), or one that cannot be carried once folded into the rest of its own character class (`\S` beside a member other than `\s`) — but **not** a hyphen right after a completed range (`[a-c-e]`), which both dialects read the same way and export unchanged. This also covers a regexp anchored with `^`/`$`, which in Ruby anchor a **line** and in ECMA-262 anchor the whole string, so `/^\d{5}$/` accepts `"evil\n12345"` at runtime and publishing that source would promise a stricter rule than the server enforces (use `\A`/`\z`, which translate exactly); `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`, and a rule's `finalize` block `x-permittable-finalize` (on the rule's own object, inside the `root:` wrapper when there is one, and on the operation too, so the marker survives an operation that has no request body); actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; operations with no matching route — or whose path-and-verb slot another controller already claimed, which one document cannot represent twice, or whose route an earlier one shadows (below) — land in `x-permittable-controllers` instead of being dropped. A templated path segment is declared as a path `parameter` of type `string` unless the contract says more, because the route set doesn't say what an `:id` is and the exporter won't invent it: a rootless contract's *scalar* field of the same name is that parameter's schema (see [Unknown parameters](#unknown-parameters) for what that does to the request body), minus any `default:` and `nullable:`, since the URL always supplies a non-null value. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
1494
+ **What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a construct with no faithful ECMA-262 spelling is exported as `x-permittable-pattern` rather than a mistranslated `pattern`: a Ruby-only escape or flag, one ECMA-262 reads differently or refuses to compile at all (`{,3}`, `&&` in a class, a backreference, the word boundary `\b`), or one that cannot be carried once folded into the rest of its own character class (`\S` beside a member other than `\s`) — but **not** a hyphen right after a completed range (`[a-c-e]`), which both dialects read the same way and export unchanged. This also covers a regexp anchored with `^`/`$`, which in Ruby anchor a **line** and in ECMA-262 anchor the whole string, so `/^\d{5}$/` accepts `"evil\n12345"` and `"12345\n"` at runtime (the trailing newline too, not only in the export), and publishing that source would promise a stricter rule than the server enforces (use `\A`/`\z`, which translate exactly); `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`, and a rule's `finalize` block `x-permittable-finalize` (on the rule's own object, inside the `root:` wrapper when there is one, and on the operation too, so the marker survives an operation that has no request body); actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; operations with no matching route — or whose path-and-verb slot another controller already claimed, which one document cannot represent twice, or whose route an earlier one shadows (below) — land in `x-permittable-controllers` instead of being dropped. A templated path segment is declared as a path `parameter` of type `string` unless the contract says more, because the route set doesn't say what an `:id` is and the exporter won't invent it: a rootless contract's *scalar* field of the same name is that parameter's schema (see [Unknown parameters](#unknown-parameters) for what that does to the request body), minus any `default:` and `nullable:`, since the URL always supplies a non-null value. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
1491
1495
 
1492
1496
  **Mounted engines are documented under their mount point.** The task follows each `mount Billing::Engine => "/billing"` into the engine's routes, so an engine's operations land in `paths` as `/billing/...` (a dynamic mount point becomes a `{tenant}` template). A Rack mount that is not an engine is skipped. See [Auditing coverage](#auditing-coverage-permittableaudit) for the details and the one limit on shared parameter names.
1493
1497
 
@@ -1521,6 +1525,11 @@ Where they legitimately differ, the spec names the reason and asserts the **dire
1521
1525
  - **`null` as absence.** The runtime reads `{"age": null}` as `{}` ([absence](#absence-defaults-and-partial-updates)); JSON Schema cannot express that, so `type: integer` rejects a null the server would accept and ignore. A [`nullable:`](#explicit-nulls-nullable) field is not this case — there the null is a value, the exported `type` widens to say so, and the two agree.
1522
1526
  - **Padding that normalizes away.** `normalize:` runs before the checks, so under `normalize: :squish` and `length: 3..10` the server accepts `" abcdefghij "` — ten characters once squished — while the docs reject its fourteen.
1523
1527
 
1528
+ Two more go the same way but sit outside that spec's JSON-only harness (it has no multipart or Rails-parsing step), so they are documented here and not asserted there:
1529
+
1530
+ - **Rails' `rows[][name]` naming.** The server accepts Rails' unindexed form naming in a multipart body; the export describes only the indexed `rows[0][name]` form.
1531
+ - **A dropped array element.** Rails' parameter parsing removes a `null` element before the contract runs, and the contract drops a blank one from a non-string array (`""` in an `:integer` array), while the schema's `items` refuses both. A standalone `Contract` still refuses `["a", null]`.
1532
+
1524
1533
  Seven cases go the other way, and are worth knowing before you hand the document to a client. Each rule stays visible on its own field, and the spec asserts that as well as the direction:
1525
1534
 
1526
1535
  - **Bounds JSON Schema has no keyword for.** A `:json` field's `max_depth:` is enforced by the server but cannot be written as a JSON Schema keyword, so the published document is **looser** there and an over-nested payload still earns a 422. The bound is not dropped — it is exported as `x-permittable-max-depth` — so a generator or linter that wants it can read it.
@@ -43,14 +43,24 @@ module Permittable
43
43
  outside_root: "Ignored as outside the app root (set Permittable.audit_ignore_outside_root = false to audit them):"
44
44
  }.freeze
45
45
 
46
+ # What an entry's `inert` reason reads as in the table.
47
+ INERT_NOTES = {
48
+ no_callbacks: "contract cannot run: controller lacks AbstractController::Callbacks (no before_action)",
49
+ skipped: "request not checked before the action: skip_before_action :enforce_params_contract took its " \
50
+ "before_action off this action (checked only if the action reads permitted_params)",
51
+ conditional: "request may not be checked before the action: skip_before_action :enforce_params_contract is " \
52
+ "conditional (if:/unless:) and the audit cannot evaluate the condition " \
53
+ "(checked only if the action reads permitted_params)"
54
+ }.freeze
55
+
46
56
  # One routed action, paired with the rule a request for it would resolve
47
57
  # through (nil when nothing covers it — including when the controller
48
58
  # never included the concern, which is exactly the case worth finding).
49
59
  #
50
60
  # `missing_action` marks a route to an action the controller does not
51
61
  # define — see Audit.missing_action?. `inert` marks one whose rule exists
52
- # but cannot run, so `rule` is nil and the action is uncovered — see
53
- # Audit.inert?. `route` identifies the route the
62
+ # but cannot run, so `rule` is nil and the action is uncovered: the reason,
63
+ # :no_callbacks, :skipped or :conditional — see Audit.inert_reason. `route` identifies the route the
54
64
  # path came from (nil for a hand-built descriptor, which is then its own
55
65
  # route); see `summary`. `ignored` is why the entry is left out of the
56
66
  # counts — a key of IGNORE_REASONS — or nil when it is audited.
@@ -69,7 +79,7 @@ module Permittable
69
79
  end
70
80
 
71
81
  def inert?
72
- inert == true
82
+ !inert.nil?
73
83
  end
74
84
 
75
85
  # The mode this action would actually run in, rule-level declaration
@@ -118,32 +128,74 @@ module Permittable
118
128
  routes.select { |route| route[:controller].to_s == key }.map do |route|
119
129
  action = route[:action].to_s
120
130
  rule = rule_for(controller, action)
121
- inert = inert?(controller, rule)
131
+ inert = inert_reason(controller, rule, action)
122
132
  Entry.new(controller: key, action: action, verb: route[:verb], path: route[:path],
123
133
  rule: (rule unless inert), missing_action: missing[action], route: route[:route],
124
134
  ignored: configured_ignore(key, action, ignore) || outside, inert: inert)
125
135
  end
126
136
  end
127
137
 
128
- # Whether `rule` is declared on `controller` but can never run there: the
129
- # gem never registered its before_action on it — a bare ActionController::
130
- # Metal controller without AbstractController::Callbacks at its
131
- # `permit_params`, which asks the flag the gem sets when it registers the
132
- # callback (a callbacks module included afterwards does not undo that) —
133
- # while the rule validates from that callback: an `enforce: true` rule, or
134
- # one in monitor mode by its own mode: or the app-wide Permittable.mode.
135
- # permit_params raises for this at class load, but reads only the rule's own
136
- # mode, since the app-wide one is often set after the class loads; the
137
- # effective mode is judged here. Such an action is audited as uncovered
138
- # rather than as the monitored one it claims to be.
139
- def inert?(controller, rule)
140
- return false unless rule && defined?(::ActionController::Metal) && controller.is_a?(Class) &&
141
- controller < ::ActionController::Metal
142
-
143
- (rule[:enforce] || (rule[:mode] || Permittable.mode) == :monitor) &&
138
+ # Why `rule` is declared on `controller` but may not run for `action`, or
139
+ # nil when it does: the gem's before_action is not in the controller's
140
+ # callback chain, or is skipped for that action, while the rule validates
141
+ # from that callback (an `enforce: true` rule, or one in monitor mode by
142
+ # its own mode: or the app-wide Permittable.mode). One of
143
+ # - :no_callbacks: a bare ActionController::Metal controller without
144
+ # AbstractController::Callbacks at its `permit_params`, where the gem
145
+ # never registered it (a callbacks module included afterwards does not
146
+ # undo that);
147
+ # - :skipped: `skip_before_action :enforce_params_contract` took it out of
148
+ # this class's chain and its subclasses' (permit_params does not register
149
+ # it a second time), or its only:/except: name this action;
150
+ # - :conditional: a skip with if:/unless: that could reach this action,
151
+ # whose condition the audit does not evaluate, since that is host code.
152
+ # permit_params raises for the first at class load, but reads only the
153
+ # rule's own mode, since the app-wide one is often set after the class
154
+ # loads; the effective mode is judged here. Such an action is audited as
155
+ # uncovered rather than as the monitored or enforced one it claims to be.
156
+ #
157
+ # The conditions of a skip are read from the record Permittable::SkipRecorder
158
+ # keeps of the `skip_before_action` call, and apply only while the callback
159
+ # in the chain is the one that call left. A skip made with `skip_callback`
160
+ # directly, or a `before_action :enforce_params_contract` of the host's own
161
+ # with conditions, is not seen unless it removed the callback outright.
162
+ def inert_reason(controller, rule, action)
163
+ return unless rule && (rule[:enforce] || (rule[:mode] || Permittable.mode) == :monitor)
164
+ return :no_callbacks if never_registered?(controller)
165
+ return unless controller.respond_to?(:_process_action_callbacks)
166
+
167
+ callback = controller._process_action_callbacks.find { |c| c.kind == :before && c.filter == :enforce_params_contract }
168
+ callback ? skip_reason(controller, callback, action) : :skipped
169
+ end
170
+
171
+ def skip_reason(controller, callback, action)
172
+ held = controller.permittable_skips if controller.respond_to?(:permittable_skips)
173
+ return unless held && held[:callback].equal?(callback)
174
+
175
+ effects = held[:skips].map { |skip| skip_effect(skip, action) }
176
+ effects.include?(:skipped) ? :skipped : effects.compact.first
177
+ end
178
+
179
+ # What one skip does to `action`, as Rails composes it: each skip adds its
180
+ # only: and except: to the callback as conditions that must all hold, so
181
+ # the action is skipped when it is in only: or outside except:. An if: or
182
+ # unless: makes it :conditional wherever only:/except: do not skip it.
183
+ def skip_effect(skip, action)
184
+ named = skip[:only]&.include?(action) || (skip[:except] && !skip[:except].include?(action))
185
+ return :skipped if named || (skip[:only].nil? && skip[:except].nil? && !skip[:conditional])
186
+
187
+ :conditional if skip[:conditional]
188
+ end
189
+
190
+ def never_registered?(controller)
191
+ defined?(::ActionController::Metal) && controller.is_a?(Class) && controller < ::ActionController::Metal &&
144
192
  !(controller.respond_to?(:permittable_callback_registered) && controller.permittable_callback_registered)
145
193
  end
146
194
 
195
+ # module_function made public singleton copies of these; they are this
196
+ # module's own steps, not API.
197
+ private_class_method :inert_reason, :skip_reason, :skip_effect, :never_registered?
198
+
147
199
  # Why `controller` — or one of its actions — is left out of the audit and
148
200
  # of permittable:generate: :configured (Permittable.audit_ignore names the
149
201
  # controller, or "controller#action"), :outside_root, or nil. The
@@ -490,8 +542,7 @@ module Permittable
490
542
  def inert_note(entry)
491
543
  return unless entry.inert?
492
544
 
493
- ["contract cannot run: controller lacks AbstractController::Callbacks (no before_action)",
494
- ("ACCEPTS A BODY" if entry.body?)].compact.join(" — ")
545
+ [INERT_NOTES.fetch(entry.inert), ("ACCEPTS A BODY" if entry.body?)].compact.join(" — ")
495
546
  end
496
547
 
497
548
  # The one wording for a route Rails would 404, covered or not.
@@ -72,6 +72,9 @@ module Permittable
72
72
  # response_body=. `render json:` calls `to_json` on a Hash, so the bytes
73
73
  # are the same. A controller with `render` always takes the first path.
74
74
  def render_json(controller, payload, status:, content_type: nil)
75
+ # The number, not the symbol: Rack >= 3.1 deprecates :unprocessable_entity
76
+ # (:unprocessable_content there) and Rails 7.1 passes it to Rack as is.
77
+ status = status_code(status) || status
75
78
  if !controller.respond_to?(:render) && controller.respond_to?(:response_body=)
76
79
  controller.status = status
77
80
  controller.content_type = content_type if content_type
@@ -192,6 +195,8 @@ module Permittable
192
195
  def status_code(status)
193
196
  return status if status.is_a?(Integer)
194
197
 
198
+ return nil unless status.respond_to?(:to_sym)
199
+
195
200
  symbol = status.to_sym
196
201
  STATUS_CODES[symbol] ||
197
202
  (defined?(Rack::Utils) && Rack::Utils::SYMBOL_TO_STATUS_CODE[symbol])
@@ -43,7 +43,8 @@ module Permittable
43
43
  # lists the column. They ride on the column, set by in_rule, so the scan
44
44
  # path hands them to column_line without its own methods changing.
45
45
  DraftColumn = Struct.new(:name, :type, :null, :default, :default_function, :enum, :enum_integers, :sensitive,
46
- :unread, :rule, :listed, :assigned, :array, :array_default, keyword_init: true) do
46
+ :unread, :rule, :listed, :assigned, :array, :array_default, :precision, :scale,
47
+ keyword_init: true) do
47
48
  def in_rule(rule, listed: false)
48
49
  self.class.new(**to_h, rule: rule, listed: listed)
49
50
  end
@@ -556,6 +557,8 @@ module Permittable
556
557
  default_function: column.respond_to?(:default_function) && column.default_function,
557
558
  array: column.respond_to?(:array?) && column.array? == true,
558
559
  array_default: array_default?(column.default),
560
+ precision: column.respond_to?(:precision) && column.precision,
561
+ scale: column.respond_to?(:scale) && column.scale,
559
562
  sensitive: sensitive_role(model, column.name), assigned: assigned?(model, column),
560
563
  **model_facts(model, column))
561
564
  end
@@ -1159,9 +1162,30 @@ module Permittable
1159
1162
  todos << "TODO: #{column.name} #{SCANNED_SENSITIVE.fetch(column.sensitive)}" if column.sensitive
1160
1163
  todos << enum_todo(column) if column.enum_integers
1161
1164
  todos << unread_todo(column) if column.unread
1165
+ todos.concat(hint_todos(column))
1166
+ end
1167
+
1168
+ # Hints about what a contract cannot say for a column, so a reviewer
1169
+ # sees them before the draft is enforced. Comments only.
1170
+ def hint_todos(column)
1171
+ todos = []
1172
+ if column.type == :decimal && column.precision
1173
+ todos << "TODO: decimal(#{column.precision},#{column.scale || 0}): the contract does not bound precision; " \
1174
+ "a Postgres app may 500 on overflow"
1175
+ end
1176
+ if RAILS_REQUEST_KEYS.include?(column.name.to_s)
1177
+ todos << "TODO: #{column.name} is also a Rails request key — if this contract is rootless (root: false) " \
1178
+ "the router's value collides with it"
1179
+ end
1180
+ if JSON_TYPES.include?(column.type)
1181
+ todos << "TODO: #{column.name} (#{column.type}): a :json field accepts an object only, not an array"
1182
+ end
1162
1183
  todos
1163
1184
  end
1164
1185
 
1186
+ RAILS_REQUEST_KEYS = %w[controller action format authenticity_token].freeze
1187
+ JSON_TYPES = %i[json jsonb].freeze
1188
+
1165
1189
  SCANNED_SENSITIVE = {
1166
1190
  locking: "is the optimistic-locking column — kept because the permit call lists it: an edit form that " \
1167
1191
  "round-trips it is how Rails detects a stale update; never give it a default:",
@@ -1320,9 +1344,22 @@ module Permittable
1320
1344
  return nil if rules.empty?
1321
1345
 
1322
1346
  bodies = rules.map do |actions, body|
1323
- "#{signature(root: root, model: model, actions: actions)}\n#{body.map { |line| " #{line}\n" }.join}end\n"
1347
+ "#{one_line(signature(root: root, model: model, actions: actions))}\n" \
1348
+ "#{body.map { |line| " #{one_line(line)}\n" }.join}end\n"
1324
1349
  end
1325
1350
  HEADER + bodies.join("\n#{UPDATE_NOTE}")
1326
1351
  end
1352
+
1353
+ # Every drafted line passes through here, the one place text from the
1354
+ # database or the app (a column name in a TODO) meets a comment. A line
1355
+ # break in it would end that comment and leave the rest as live Ruby for
1356
+ # whoever pastes the draft. Ruby's lexer ends a comment at "\n" only (a
1357
+ # lone "\r", "\v", "\f", U+0085, U+2028/9 and NUL stay inside it), but an
1358
+ # editor or terminal shows "\r" as a break too, so it goes as well. A name
1359
+ # inside a quoted literal is already escaped by `inspect`, with no raw
1360
+ # break left.
1361
+ def one_line(line)
1362
+ line.gsub(/\r\n|[\r\n]/, " ")
1363
+ end
1327
1364
  end
1328
1365
  end
@@ -402,11 +402,10 @@ module Permittable
402
402
  end
403
403
 
404
404
  def operation_for(controller, action)
405
- rule = if action == "*"
406
- controller.permittable_contracts.reverse_each.find { |r| r[:actions].empty? }
407
- else
408
- controller.permit_rule_for(action)
409
- end
405
+ # "*" is the catch-all's marker on a plain host; a rule that names "*"
406
+ # literally is an ordinary action and is found through permit_rule_for.
407
+ rule = (action == "*" && controller.permittable_contracts.reverse_each.find { |r| r[:actions].empty? }) ||
408
+ controller.permit_rule_for(action)
410
409
  operation = {}
411
410
  key = controller_key(controller)
412
411
  operation["operationId"] = "#{key.tr('/', '_')}_#{action}" if key && action != "*"
@@ -419,7 +418,7 @@ module Permittable
419
418
  # the rule's own declaration is contract data — the app-wide
420
419
  # Permittable.mode is runtime configuration the export can't see.
421
420
  operation["x-permittable-mode"] = "monitor" if rule[:mode] == :monitor
422
- operation["x-permittable-catch-all"] = true if action == "*"
421
+ operation["x-permittable-catch-all"] = true if action == "*" && rule[:actions].empty?
423
422
  # `finalize` is flagged on the rule's object schema too (see
424
423
  # JsonSchema.rule), but that sits inside the requestBody, and an
425
424
  # operation can end up with no body (every field a path variable) while
@@ -669,6 +669,10 @@ module Permittable
669
669
  "expected #{subject_name} not to #{verb} those params#{action_label}, but it did"
670
670
  end
671
671
 
672
+ # The root a scoped filter_parameters entry needs comes from the subject,
673
+ # which a matcher only learns in matches?. Before any run there is none to
674
+ # resolve, so a description read then prints through the shallow filter
675
+ # (RSpec builds a one-liner's name after the run, so it is not affected).
672
676
  def description
673
677
  label = @expect_accepted ? "accept those params" : "reject those params"
674
678
  label += action_label
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.15.1".freeze
2
+ VERSION = "0.16.0".freeze
3
3
  end
data/lib/permittable.rb CHANGED
@@ -642,8 +642,56 @@ module Permittable
642
642
  end
643
643
  end
644
644
 
645
+ # Remembers what `skip_before_action :enforce_params_contract` took out of
646
+ # the callback chain, for Audit. A skip with only:/except:/if:/unless: leaves
647
+ # the callback in the chain behind conditions Rails keeps private, so the
648
+ # only public place to read them is the call itself. Prepended to the
649
+ # controller's singleton class when the gem registers its callback, so it
650
+ # wins whatever the include order of the callbacks module and this one.
651
+ # It calls super with the arguments it was given and returns what super
652
+ # returns; it only looks.
653
+ module SkipRecorder
654
+ ruby2_keywords def skip_before_action(*args, &)
655
+ # A copy: Rails deletes only:/except: from a positional Hash and adds :if to it.
656
+ options = args.last.is_a?(Hash) ? args.last.dup : {}
657
+ return super unless args.any? { |name| name.to_s == "enforce_params_contract" }
658
+
659
+ # Rails rebuilds the callback of every class already below this one too,
660
+ # each with its own new object, so each gets the record.
661
+ targets = [self] + descendants
662
+ before = targets.map(&:permittable_hook_callback)
663
+ result = super
664
+ targets.zip(before) { |target, was| target.permittable_record_skip(was, options) }
665
+ result
666
+ end
667
+
668
+ def permittable_hook_callback
669
+ _process_action_callbacks.find { |callback| callback.kind == :before && callback.filter == :enforce_params_contract }
670
+ end
671
+
672
+ # One state per class, replaced rather than changed, so a subclass's skip
673
+ # never reaches its parent. It holds the callback now in the chain and
674
+ # every skip that produced it: a skip is carried forward from the state
675
+ # only when it was made on that very callback, so a callback declared
676
+ # again afterwards (a new object) starts clean. only:/except: are kept as
677
+ # Rails compares them, action names as Strings.
678
+ def permittable_record_skip(before, options)
679
+ after = permittable_hook_callback
680
+ return if before.nil? || before.equal?(after)
681
+
682
+ held = permittable_skips
683
+ skips = held && held[:callback].equal?(before) ? held[:skips] : []
684
+ skip = { only: (Array(options[:only]).to_set(&:to_s) if options[:only]),
685
+ except: (Array(options[:except]).to_set(&:to_s) if options[:except]),
686
+ conditional: options.key?(:if) || options.key?(:unless) }.freeze
687
+ self.permittable_skips = { callback: after, skips: (skips + [skip]).freeze }.freeze
688
+ end
689
+ end
690
+
645
691
  included do
646
692
  class_attribute :permittable_contracts, instance_accessor: false, default: []
693
+ # The skips of enforce_params_contract SkipRecorder saw (see Audit).
694
+ class_attribute :permittable_skips, instance_accessor: false, default: nil
647
695
  # Whether the gem itself ever registered its rescue_from / before_action
648
696
  # for this class (a subclass inherits the answer). Asking the callback
649
697
  # chain instead would bring back a check the app took out with
@@ -1006,9 +1054,11 @@ module Permittable
1006
1054
  result.exponent.abs > [DECIMAL_DIGIT_ALLOWANCE, source.length].max
1007
1055
  end
1008
1056
 
1057
+ # eql?, not ==: 1.0, BigDecimal("1"), Rational(1, 1) and Complex(1, 0) are
1058
+ # == 1, and a JSON `1.0` arrives as a Float, but only the Integer is listed.
1009
1059
  def cast_boolean(value)
1010
- return [:ok, true] if TRUE_VALUES.include?(value)
1011
- return [:ok, false] if FALSE_VALUES.include?(value)
1060
+ return [:ok, true] if TRUE_VALUES.any? { |true_value| true_value.eql?(value) }
1061
+ return [:ok, false] if FALSE_VALUES.any? { |false_value| false_value.eql?(value) }
1012
1062
 
1013
1063
  [:error, "invalid_type"]
1014
1064
  end
@@ -1033,6 +1083,7 @@ module Permittable
1033
1083
  date ? [:ok, date] : [:error, "invalid_type"]
1034
1084
  when Date then [:ok, value]
1035
1085
  when String
1086
+ value = value.delete_prefix(BOM) if value.start_with?(BOM)
1036
1087
  # A bare ISO date ("2026-09-05", the common case) carries no zone, T
1037
1088
  # or offset, so it skips every check for one.
1038
1089
  plain = PLAIN_ISO_DATE.match?(value)
@@ -1048,6 +1099,7 @@ module Permittable
1048
1099
  if !plain && (dropped = dropped_tail(value, found))
1049
1100
  return date_at_midnight(*dropped)
1050
1101
  end
1102
+ return [:error, "invalid_type"] if !plain && unread_text?(value, found)
1051
1103
 
1052
1104
  # Built from the components rather than re-running Date.parse, which
1053
1105
  # would parse the same string a second time — and Date.parse is the
@@ -1143,6 +1195,7 @@ module Permittable
1143
1195
  when Time, DateTime then [:ok, value.to_time.getutc]
1144
1196
  when Date then [:ok, Time.utc(value.year, value.month, value.day)]
1145
1197
  when String
1198
+ value = value.delete_prefix(BOM) if value.start_with?(BOM)
1146
1199
  # Same rule as :date — the DATE part must be named in full, or it is
1147
1200
  # taken from today ("10:30" meant today at 10:30). An absent TIME part
1148
1201
  # is fine and means midnight, which is the documented reading of a
@@ -1177,6 +1230,7 @@ module Permittable
1177
1230
 
1178
1231
  dropped = dropped_tail(value, found)
1179
1232
  return dropped if dropped
1233
+ return [:error, "invalid_type"] if unread_text?(value, found)
1180
1234
 
1181
1235
  [:ok, DateTime.parse(value, true, Date::GREGORIAN).to_time.utc]
1182
1236
  rescue ArgumentError, RangeError
@@ -1286,6 +1340,8 @@ module Permittable
1286
1340
  # without it names the same date (and time). Only a String that ends in a
1287
1341
  # signed number or a word is parsed again, so a plain "2026-09-05" is not.
1288
1342
  def dropped_tail(value, found)
1343
+ # "at" joins a date to its time, and Ruby reads it as a zone (or drops it) when none follows.
1344
+ return [:error, "invalid_type"] if /\sat\s*\z/i.match?(value)
1289
1345
  return [:error, "invalid_type"] if !found.key?(:hour) && ISO_DATE_T_START.match?(value)
1290
1346
  return if found.key?(:zone)
1291
1347
 
@@ -1351,17 +1407,87 @@ module Permittable
1351
1407
  def instant_in_zone(date, text)
1352
1408
  zone = Date._parse("00:00 #{text}")
1353
1409
  return [:error, "invalid_type"] unless zone.key?(:zone) && honoured_zone?(zone)
1410
+ # Ruby reads a zone name and drops what follows it ("EST xyz" is EST).
1411
+ return [:error, "invalid_type"] unless zone[:zone].casecmp?(text.split.join(" "))
1412
+ return [:error, "invalid_type"] if unread_text?(date, Date._parse(date))
1354
1413
 
1355
1414
  [:ok, DateTime.parse(date, true, Date::GREGORIAN).to_time.utc - zone[:offset]]
1356
1415
  end
1357
1416
 
1417
+ # Text Date._parse read nothing from, which the parse then leaves out of
1418
+ # the result without a word: "2026-01-01xyz", a second date after the
1419
+ # first ("2026-01-15\n2099-12-31"), "<script>", and any control or
1420
+ # zero-width character (the parser takes it for a space). Found by
1421
+ # leaving each word and number out in turn: if the parse is unchanged,
1422
+ # that part of the String meant nothing to it.
1423
+ #
1424
+ # A part may stay unread only when it says nothing new: the T of an ISO
1425
+ # date-time, an ordinal or era word, "at", a lone letter after a date, an am
1426
+ # or pm the hour already says, or an offset or zone name that repeats the
1427
+ # one read ("EDT -04:00", "+0000 UTC"; "-0400 PST" disagrees, so is
1428
+ # refused). Remarks are set aside: a bracketed name ("(Central Daylight
1429
+ # Time)", "(heure d'été de l'Est)"), an RFC 9557 zone id
1430
+ # ("[Europe/London]", "[u-ca=japanese]": the offset governs) and Go's "m=+0.1".
1431
+ def unread_text?(value, found)
1432
+ return false if ISO_DATE_TIME.match?(value) || PLAIN_ISO_DATE.match?(value) # all of it is read; skips the parses below
1433
+ return true if value.match?(INVISIBLE)
1434
+
1435
+ text = value.gsub(REMARK, " ")
1436
+ # A remark the parser read (a "(Sat)") is part of the parse: look at it too.
1437
+ text = value if text != value && Date._parse(text) != found
1438
+ # "(GMT+02:00)" after "GMT+0200": a remark that repeats the offset read.
1439
+ text = text.gsub(OFFSET_REMARK) do
1440
+ Date._parse("00:00 GMT#{Regexp.last_match(1)}")[:offset] == found[:offset] ? " " : Regexp.last_match(0)
1441
+ end
1442
+ text = text.gsub(%r{[^-+',./:@[:alnum:]\[\]]+}, " ")
1443
+ return true if text.scan(PART).size > MAX_PARTS # each part below costs a parse
1444
+
1445
+ text.scan(PART) do
1446
+ part = Regexp.last_match
1447
+ next if part[0].match?(part.pre_match.match?(/\d\z/) ? GLUED_NOISE : LONE_WORD)
1448
+ next if part[0].casecmp?("at") && found.key?(:hour) && part.post_match.match?(/\d/) # joins a date to its time
1449
+ # "10:30 am", "12 pm" and "22:00 pm" are what the hour already says.
1450
+ next if { "am" => found[:hour].to_i < 12, "pm" => found[:hour].to_i >= 12 }[part[0].downcase]
1451
+ next unless Date._parse("#{part.pre_match} #{part.post_match}") == found # the parse needed this part
1452
+
1453
+ # What it left unread may still repeat the zone read, or label it.
1454
+ zone = Date._parse("00:00 #{part[0]}")[:offset] if found[:offset]
1455
+ next if zone && (zone == found[:offset] || offset_label?(found, part))
1456
+
1457
+ return true
1458
+ end
1459
+ false
1460
+ end
1461
+
1462
+ # "+0800 CST": a zone abbreviation right after the numeric offset read is a
1463
+ # label for it (Go and `date` print one), even where Ruby's table maps it to
1464
+ # another offset. The other order ("CST +08:00") reads the abbreviation, so no.
1465
+ def offset_label?(found, part)
1466
+ found[:zone].match?(/\A[+-]/) && part[0].match?(/\A[[:alpha:]]+\z/) && part.pre_match.rstrip.end_with?(found[:zone])
1467
+ end
1468
+
1358
1469
  def same_time?(rest, found)
1359
1470
  TIME_PARTS.all? { |part| rest[part] == found[part] }
1360
1471
  end
1361
1472
 
1362
1473
  TIME_PARTS = %i[hour min sec sec_fraction].freeze
1363
1474
  GLUED_OFFSET = /\A[+-](?:\d{1,2}|\d{2}[0-5]\d)\z/
1364
- private_constant :TIME_PARTS, :GLUED_OFFSET
1475
+ ISO_DATE_TIME = /\A\d{4}-?\d\d-?\d\d[Tt ]\d\d:?\d\d(?::?\d\d(?:[.,]\d+)?)?(?:[Zz]| ?[+-]\d\d(?::?\d\d)?)?\z/
1476
+ # One leading U+FEFF is a UTF-8 signature (a CSV or text file saved with one), not text.
1477
+ BOM = "\uFEFF".freeze
1478
+ INVISIBLE = /[\p{Cc}\p{Cf}&&[^\s]]/
1479
+ REMARK_TEXT = /[\p{L}\p{M} .'’,&-]/
1480
+ REMARK = %r{\((?:#{REMARK_TEXT}|\(#{REMARK_TEXT}*\))*\)|(?:\[!?[\w/+=:-]+\])+\z|\sm=[+-]\d+(?:\.\d+)?\z}
1481
+ # No date format in use has more parts than this: the longest read in the
1482
+ # specs and corpora has 8, e.g. "September 5, 2026 at 10:00:00 AM GMT+2".
1483
+ MAX_PARTS = 16
1484
+ PART = /[[:alpha:]]+|[+-]?\d[^[:alpha:]\s]*/
1485
+ GLUED_NOISE = /\A(?:t|st|nd|rd|th)\z/i
1486
+ # No /i: Onigmo then lets [[:alpha:]] match a ligature's folding ("st", "ss", "ff") once it has met non-ASCII text.
1487
+ LONE_WORD = /\A(?:[[:alpha:]]|[Aa][Dd]|[Cc][Ee])\z/
1488
+ OFFSET_REMARK = /\((?:GMT|UTC)([+-]\d{1,2}(?::?\d{2})?)\)/
1489
+ private_constant :TIME_PARTS, :GLUED_OFFSET, :ISO_DATE_TIME, :BOM, :INVISIBLE, :REMARK_TEXT, :REMARK, :MAX_PARTS, :PART,
1490
+ :GLUED_NOISE, :LONE_WORD, :OFFSET_REMARK
1365
1491
 
1366
1492
  def same_date?(rest, found)
1367
1493
  complete_date?(rest) && rest.values_at(:year, :mon, :mday) == found.values_at(:year, :mon, :mday)
@@ -2466,6 +2592,7 @@ module Permittable
2466
2592
  # order: this check runs before any callback declared after the include,
2467
2593
  # so a locale switch that must cover it is a `prepend_around_action`.
2468
2594
  before_action :enforce_params_contract
2595
+ singleton_class.prepend(SkipRecorder) unless singleton_class < SkipRecorder
2469
2596
  self.permittable_callback_registered = true
2470
2597
  end
2471
2598
 
@@ -2476,7 +2603,7 @@ module Permittable
2476
2603
  # Only the rule's OWN declared mode is read, never Permittable.mode: that
2477
2604
  # is usually set by an initializer, and an app must not stop booting over
2478
2605
  # it. The effective mode can only add a requirement, so an action it makes
2479
- # monitored without a before_action is left to Audit.inert?, which reports
2606
+ # monitored without a before_action is left to Audit.inert_reason, which reports
2480
2607
  # it as not covered.
2481
2608
  def permittable_ensure_runnable!(rule)
2482
2609
  return unless defined?(::ActionController::Metal) && self < ::ActionController::Metal
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: permittable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.1
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-12 00:00:00.000000000 Z
11
+ date: 2026-11-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport