permittable 0.9.0 → 0.10.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: 0af0930f23dca74dd690373044ec7ee08ae68a2562a34db3b55c4dff2879c82e
4
- data.tar.gz: d838905e4413a8d0abd3aaed67a6b43caedf32c9387802db0f761da11c1d9ab5
3
+ metadata.gz: e135a1605d2d98e7c3a3952e2f9dc47394a656249f52d22b7018ed5dc15b23cb
4
+ data.tar.gz: f3748c6014a1f74099719fc8c42af289910c9f94d36687837cbe409fd67538a1
5
5
  SHA512:
6
- metadata.gz: 5f4e6649f02667d1a2ea035974ec110c47b0e5ed1d8337776a97f51753e6c67a27c6da2d72516834d780bab1481ec6356c8ea20a29b39059df85296bdda42516
7
- data.tar.gz: e2837022cae64c9e74e6c83e6dc48627535d782d91eab9546887f2d2ecf99cb8083869135ec2011dde222eaaaa0f1a36c893bf382f3c7b549c66d0d72a76d4d7
6
+ metadata.gz: 12881e40e20e390c7eb93963a5ca89aa402ed58d79eb0bfa7383d7a2ea6c5b93f6d61d1b35c94ed27b3b75774ddc75256e1d176f9cea5470d06200f64aaecb7e
7
+ data.tar.gz: 312bd2b824147af2821dc318c0a66a250d65bd7626d80574e91a0bfb5cc06159a0e22428e30b6d463bfe57ece1fe72c56b4ca8b513b7cdbde1419eb637cd0533
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.10.0 (2026-09-30)
4
+ <!-- title: canonical numeric strings, the app's own CSRF param, and defaults that agree with requests -->
5
+
6
+ Ten fixes and no new surface. Three change what an existing contract does. A numeric string must now be spelled canonically: `Integer()`, `Float()` and `BigDecimal()` all read underscore separators and surrounding whitespace, so `"1_8"` was eighteen and `" 99 "` was ninety-nine, and both are now `invalid_type`. An array `default:` now runs its sub-fields' `transform:` when the contract loads, so a request that omits the field and one that sends the default's value hand the action the same thing. And an app that renamed its CSRF parameter with `config.action_controller.request_forgery_protection_token` stops failing every form POST under `unknown: :error`. Alongside them: a `sensitive:` field no longer publishes its own `default:`/`example:` in the exported schema, a `message:` shared through `use` can no longer be rewritten from one contract into another, `permittable:generate` stops drafting an enum accessor the model does not answer to and stops hiding a real error behind an empty draft, and three pieces of process-wide state — the error-response schemas, the scalar JSON Schema entries and the `sensitive:` sink list — can no longer be corrupted by a caller or a concurrent class load.
7
+
8
+ Minor rather than patch: the API is untouched, but two fixes are visible from outside. A client that pads a number with whitespace or underscores now gets a 422 where it used to get the number, and an array `default:` whose sub-fields declare `transform:` is now handed out transformed — that `transform:` is app code, and it now runs when the contract loads. Read the first and third entries under **Fixed** before upgrading if either applies to your contracts.
9
+
10
+ ### Fixed
11
+ - **`:integer`, `:float` and `:decimal` read `"1_8"` as eighteen and `" 99 "` as ninety-nine.** The casts delegate to `Integer()`, `Float()` and `BigDecimal()`, which all accept underscore digit separators and surrounding whitespace — a convenience for a number literal in Ruby source, not for a request body, and the same kind of leniency the NaN/Infinity and underflow checks already close off through other doors. A numeric string is now checked against a canonical form before anything parses it: an optional sign, digits, an optional fraction and an optional exponent — `"-12"`, `"007"`, `".5"`, `"1.5e10"`, and `"1e400"` for `:decimal` all still cast — and anything else is `invalid_type`. `normalize:` is a `:string`-only option, so there is no in-contract way to keep the old whitespace tolerance; a client that pads a number has a formatting bug upstream, which is now reported rather than repaired.
12
+ - **A CSRF parameter renamed with `request_forgery_protection_token` failed every form POST under `unknown: :error`.** The top-level exemption hard-coded Rails' default `authenticity_token`, so an app that renamed it got `{ param: "csrf_token", code: "unknown" }` on every ordinary form submission — exactly the failure the exemption exists to prevent, spelled with the app's own key. The controller's configured token name is now read fresh on each request (it can vary per controller, and Rails may not have finished initializing when the gem loads) and exempted alongside the fixed keys. A plain params duck and a standalone `Contract` have no such setting and are unchanged, and so is monitor mode's raw pass-through.
13
+ - **An array `default:` skipped its sub-fields' `transform:`, so an omitted field and an explicitly-sent identical value diverged.** An authored array default is walked by the same code a request goes through, but that walk suppressed `transform:` on *every* field it visited rather than only the array's own. With `array :line_items, default: [{ "price" => "10.00" }] do optional :price, :decimal, transform: ->(v) { v * 100 } end`, a request omitting `line_items` got `price: 10` and one sending `[{ "price" => "10.00" }]` got `price: 1000`, and the exported OpenAPI `default` documented a value the server never produced. A sub-field's own `transform:` now runs over the default when the contract loads, so the stored value is what an equivalent request yields. The array field's **own** `transform:` still never runs over its default, exactly as `default:` documents, and the `with_default` matcher reads its expected array through the same walk, so it stays in agreement.
14
+ - **A field's `message:` was never deep-frozen, so two contracts sharing it through `use` could corrupt each other.** `default:`, `example:` and `in:` are deep-copied and deep-frozen on declaration; `message:` was frozen only at the top level in its Hash form and not at all as a String. `Permittable.fields` splices a group's field hashes into each contract by reference, so `msg << " NOW"` on one contract's violation message rewrote the message every other contract using that group would render, for the life of the process. Both forms now go through the same copy-then-freeze as the other authored values.
15
+ - **A `sensitive:` field's `default:`/`example:` were exported in the clear.** The field was marked `writeOnly` and registered for log redaction, and then the exported JSON Schema and OpenAPI document — the one output meant to leave the app, for client generators or a public docs endpoint — carried the real value under `default` and `examples`. Both keys are now omitted for a sensitive field, a child inheriting the cascade included; `description` is still exported, being authored prose rather than the value.
16
+ - **`permittable:generate` drafted `Model.statuses.keys` for an accessor the model does not have.** The generator only checked that the pluralised enum name was a valid identifier, so a renamed or excluded accessor produced a draft that raised `NoMethodError` the moment it loaded — while the schema-drift guard's own error messages already made the safer choice. The draft now also checks `respond_to?` and otherwise spells the list as `Model.defined_enums["status"].keys`, which is always callable.
17
+ - **`permittable:generate` swallowed every error while reading a model's columns and drafted an empty contract instead.** The rescue was a blanket `StandardError`, so a broken custom type adapter, or any plain bug, quietly produced a degraded draft with nothing to say why. It is now scoped to `ActiveRecord::ActiveRecordError`, the same rule `ColumnGuard.schema_reachable?` follows: a genuinely unreachable schema (no database yet, table not migrated) still degrades to "no columns", and anything else propagates.
18
+ - **`OpenAPI.document` handed out its error-response schemas by reference, so a caller editing its own document rewrote them for every document generated afterward.** `ERROR_SCHEMA` and `PROBLEM_SCHEMA` are frozen, but Ruby's `freeze` is shallow: assigning into a nested level of a generated document raised nothing and permanently changed the constant. Each document now gets a deep copy. The JSON Schema exporter had the same shape of exposure — a `SCALAR_SCHEMAS` entry was shallow-copied, so every `:decimal` schema shared one live `type` Array with the constant — and now deep-copies too. Nothing inside the gem mutated either in place; this closes the door on a caller doing so.
19
+ - **`sensitive:` sink callbacks were iterated without the lock they are appended under.** `Permittable.on_sensitive_parameter` appends to the sink list under `@registry_mutex`; a contract declaring a `sensitive:` field iterated that same list without it. The GVL hides this on MRI, but on JRuby or TruffleRuby a contract class-loading while a sink is installed is a concurrent mutation during iteration — a raise, or a sink call silently skipped, which is a parameter name that never reaches `config.filter_parameters`. The list is now snapshotted under the mutex and the callbacks run against the snapshot, outside it, so a sink that calls back into the gem cannot deadlock.
20
+
3
21
  ## 0.9.0 (2026-09-27)
4
22
  <!-- title: params.expect drafting, audit coverage, field groups, and a rewritten pattern translator -->
5
23
 
data/README.md CHANGED
@@ -297,7 +297,7 @@ Which options are legal depends on the field kind — anything else raises at cl
297
297
  | `format:` | ✅¹ | — | — | Regexp the value must match, or a [preset name](#format-presets): `:email`, `:uuid`, `:url`, `:slug`, `:hostname` |
298
298
  | `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays, where it short-circuits — see [the field DSL](#the-field-dsl) |
299
299
  | `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **first** — before the absence rule, so a value that normalizes to `""` is absent |
300
- | `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load either way. On a field with **no** `transform:`, stored as a request sending it would read it — normalized, cast (`default: "18"` on an `:integer` is `18`), an array walked at every depth. On a field **with** `transform:`, stored exactly **as authored** instead — `transform:` never runs on a default (see [output reshaping](#output-reshaping-transform-and-finalize)), and neither does the cast, so the value you write is the value the action receives. Either way it is frozen, and each request gets its own deep copy |
300
+ | `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load either way. On a field with **no** `transform:`, stored as a request sending it would read it — normalized, cast (`default: "18"` on an `:integer` is `18`), an array walked at every depth with its sub-fields' own `transform:` applied. On a field **with** `transform:`, stored exactly **as authored** instead — `transform:` never runs on a default (see [output reshaping](#output-reshaping-transform-and-finalize)), and neither does the cast, so the value you write is the value the action receives. Either way it is frozen, and each request gets its own deep copy |
301
301
  | `validate:` | ✅ | ✅ | — | Callable. Falsy fails as `"invalid"`; a returned `Symbol` becomes the violation code. On an array it runs only when no element violated — an undeclared key inside an element does not count — and `transform:` runs only when nothing violated at all |
302
302
  | `transform:` | ✅ | ✅ | — | Callable applied **after** cast and validation — see [output reshaping](#output-reshaping-transform-and-finalize) |
303
303
  | `virtual:` | ✅ | ✅ | ✅ | Exempt this field from the schema-drift guard |
@@ -359,9 +359,9 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
359
359
  | Type | Accepts | Rejects (`invalid_type`) |
360
360
  |---|---|---|
361
361
  | `:string` | `String`, returned in the encoding it arrived in; `Numeric`/`true`/`false` are stringified | Arrays, hashes, a `String` whose bytes are invalid in its own encoding (`"caf\xC3"`) |
362
- | `:integer` | `Integer`; whole `Float`s (`4.0`); base-10 numeric strings | `"4.5"`, `"abc"`, `4.5`, NaN/Infinity |
363
- | `:float` | `Numeric`; any `Float()`-parseable string | `"abc"` |
364
- | `:decimal` | `Numeric` or `String` → `BigDecimal` | Unparseable strings |
362
+ | `:integer` | `Integer`; whole `Float`s (`4.0`); canonical base-10 numeric strings (`"-12"`, `"007"`) | `"4.5"`, `"abc"`, `"1_8"`, `" 99 "`, `4.5`, NaN/Infinity |
363
+ | `:float` | `Numeric`; a canonical numeric string (`"1.5"`, `".5"`, `"-2e3"`) | `"abc"`, `"1_8.5"`, `" 1.5 "` |
364
+ | `:decimal` | `Numeric`, or a canonical numeric string → `BigDecimal` | Unparseable strings, `"1_000"`, `" 1.5 "` |
365
365
  | `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
366
366
  | `:date` | `Date`; a string naming a **complete** date, in any format `Date.parse` understands (`"2026-09-05"`, `"2026/09/05"`, `"Sep 5, 2026"`) | Unparseable strings, and **incomplete** ones (`"09/2026"`, `"5th"`, `"Sept"`) |
367
367
  | `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date`; a string naming a complete date, with or without a time | Unparseable strings, and any string without a complete date (`"10:30"`) |
@@ -373,6 +373,8 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
373
373
 
374
374
  **Numbers must be finite.** `Float("1e400")` is `Infinity` and `Float("1e-400")` is `0.0` — neither represents what was sent, and neither is a value a numeric column can store, so both are `invalid_type`. A genuine zero is unaffected however it is spelled (`"0"`, `"0.0"`, `"0e10"`). `:decimal` has no exponent limit, so `"1e400"` is fine there — but `BigDecimal("NaN")` and `BigDecimal("Infinity")` *succeed* where `Float()` raises, so those literal strings are rejected explicitly.
375
375
 
376
+ **Numbers are spelled the way a client spells them.** `Integer()`, `Float()` and `BigDecimal()` all read underscore digit separators and surrounding whitespace — conveniences for a number literal in Ruby source — so `"1_8"` would be eighteen and `" 99 "` ninety-nine. A numeric string must instead be canonical: an optional sign, digits, an optional fraction and an optional exponent (`"-12"`, `"007"`, `".5"`, `"1.5e10"`). Anything else is `invalid_type`.
377
+
376
378
  Two more behaviours worth committing to memory:
377
379
 
378
380
  - **Type confusion is a violation, not a 500.** A request of `?age[]=1` against a scalar `:integer` field yields `invalid_type`. Arrays, hashes, and nested `ActionController::Parameters` can never satisfy a scalar type, so the classic "`NoMethodError` on `[]`" crash is impossible.
@@ -572,7 +574,7 @@ The setting is app-wide, not per-contract, because the error format of an API is
572
574
 
573
575
  Under `:log` that bound is the whole record: nothing else names an undeclared key, so beyond the tenth only the count survives. Where you need every name — auditing what a client really sends during a rollout — use `unknown: :error` in monitor mode, which records all of them in `details` and in the instrumentation payload without rejecting the request.
574
576
 
575
- Rails merges its own keys into `params`: `controller`, `action`, and `format` from the router, plus `authenticity_token`, `_method`, `utf8`, and `commit` from an ordinary form POST. All seven are exempt at the top level. So are the route's **path parameters** (`PATCH /users/1` merges `id`, which the exported OpenAPI documents as a path parameter rather than a body field); a contract that *declares* `id` has it validated as usual, since the URL really carried it. **ParamsWrapper's copy of a JSON body** under the controller's wrapper key (`user` for `UsersController`) goes further: when Rails made that copy, a rootless contract does not see the key at all, because the client never sent it. So an undeclared wrapper key is not flagged, and a scalar or array field that happens to share the wrapper's name (`optional :feedback, :string` on `FeedbackController`) is simply absent, rather than failing as `invalid_type` against Rails' copy of the whole body. The one exception is a rootless contract that declares the wrapper key as a hash container — a nested block (`required :user do ... end`) or `:json`. That contract is reading the copy on purpose, like a `root:` spelled as a field, so the copy is kept and validated as that field. A client that sends `user` itself is checked like any other key: validated if declared, flagged if not. That holds whether the wrapper name is configured as a String or as a Symbol (`wrap_parameters :user`). Either way `unknown: :error` flags what the *client* got wrong rather than what the framework added. Inside a `root:` or a nested hash there is no such exemption, because nothing legitimately injects keys there — and a standalone `Contract` exempts nothing at all, having neither a router, a form, nor a request.
577
+ Rails merges its own keys into `params`: `controller`, `action`, and `format` from the router, plus `authenticity_token` (or whatever `config.action_controller.request_forgery_protection_token` renames it to), `_method`, `utf8`, and `commit` from an ordinary form POST. All seven are exempt at the top level. So are the route's **path parameters** (`PATCH /users/1` merges `id`, which the exported OpenAPI documents as a path parameter rather than a body field); a contract that *declares* `id` has it validated as usual, since the URL really carried it. **ParamsWrapper's copy of a JSON body** under the controller's wrapper key (`user` for `UsersController`) goes further: when Rails made that copy, a rootless contract does not see the key at all, because the client never sent it. So an undeclared wrapper key is not flagged, and a scalar or array field that happens to share the wrapper's name (`optional :feedback, :string` on `FeedbackController`) is simply absent, rather than failing as `invalid_type` against Rails' copy of the whole body. The one exception is a rootless contract that declares the wrapper key as a hash container — a nested block (`required :user do ... end`) or `:json`. That contract is reading the copy on purpose, like a `root:` spelled as a field, so the copy is kept and validated as that field. A client that sends `user` itself is checked like any other key: validated if declared, flagged if not. That holds whether the wrapper name is configured as a String or as a Symbol (`wrap_parameters :user`). Either way `unknown: :error` flags what the *client* got wrong rather than what the framework added. Inside a `root:` or a nested hash there is no such exemption, because nothing legitimately injects keys there — and a standalone `Contract` exempts nothing at all, having neither a router, a form, nor a request.
576
578
 
577
579
  All of this changes what is *checked* only. Monitor mode still hands back the form keys, the path parameters and the wrapper's copy in its raw pass-through, where behaving exactly like the pre-contract app is the whole promise and a legacy action may read `params[:id]` or `_method` itself; only the router's three are dropped there.
578
580
 
@@ -1104,7 +1106,7 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
1104
1106
  | nested block / `array` | `object` + `properties` / `array` + `items` |
1105
1107
  | `unknown: :error` | `additionalProperties: false`, at every nesting level |
1106
1108
  | `root:` | the wrapping object, itself required |
1107
- | `sensitive: true` | `writeOnly: true` (never echoed in responses) |
1109
+ | `sensitive: true` | `writeOnly: true` (never echoed in responses); the field's own `default:`/`example:` are omitted rather than published |
1108
1110
 
1109
1111
  </details>
1110
1112
 
@@ -3,12 +3,11 @@ module Permittable
3
3
  # at class load — the same permittable_check_array a request goes
4
4
  # through, so the two cannot drift apart. Two seams are overridden:
5
5
  #
6
- # * `transform:` is NOT run. It is app code reshaping a value the client
7
- # sent, and a default is stored as the contract reads it, not as the
8
- # app reshapes it — so a contract with transform: hands out its
9
- # default untransformed, exactly as it always has, and nothing of the
10
- # app's runs at class load beyond the `validate:` an authored value was
11
- # already checked with.
6
+ # * the field WHOSE default:/example: is being validated does not run
7
+ # its own `transform:` — see permittable_transform below for exactly
8
+ # which field that is and why. A SUB-FIELD's own transform: still
9
+ # runs, so what gets stored is what an equivalent request would
10
+ # produce.
12
11
  # * violations carry no `message:` — they become an ArgumentError for
13
12
  # the contract's author, not a response for a client, and I18n may not
14
13
  # be loaded yet.
@@ -26,6 +25,7 @@ module Permittable
26
25
  # [value as a request would get it, violations]
27
26
  def read_array(field, value)
28
27
  violations = []
28
+ @field = field
29
29
  read = permittable_check_array(field, value, path: field[:name].to_s, unknown: :ignore, violations: violations)
30
30
  [read, violations]
31
31
  end
@@ -36,8 +36,24 @@ module Permittable
36
36
 
37
37
  private
38
38
 
39
- def permittable_transform(_field, value)
40
- value
39
+ # Suppresses transform: for the field WHOSE default/example is being
40
+ # authored-validated (@field, compared by identity) — never for a
41
+ # sub-field nested inside it. A sub-field's own transform: is app code
42
+ # too, but it belongs to a DIFFERENT field's contract: an equivalent
43
+ # request sending that sub-field's value would run it, so the stored
44
+ # default has to match, or an omitted field and an explicitly-sent
45
+ # identical value silently diverge (and the exported OpenAPI default,
46
+ # which reads this same value, documents one the server never produces).
47
+ #
48
+ # When @field itself has a transform:, its result is discarded anyway
49
+ # (validate_array_authored_value! stores the value AS AUTHORED instead —
50
+ # see its comment), so nothing inside its subtree is worth reading
51
+ # transformed: suppressing every nested call too keeps that walk free of
52
+ # app code, exactly as a scalar default's cast-only check already is.
53
+ def permittable_transform(field, value)
54
+ return value if @field[:transform] || field.equal?(@field)
55
+
56
+ super
41
57
  end
42
58
 
43
59
  def permittable_violation(_field, param, code)
@@ -400,6 +400,15 @@ module Permittable
400
400
  # top (draft_column) must not share this rescue: an error there would
401
401
  # otherwise discard EVERY column — the draft silently falls back to a
402
402
  # scan alone, or to nothing — for what is one column's problem.
403
+ #
404
+ # The rescue is scoped to ActiveRecord::ActiveRecordError, same as
405
+ # ColumnGuard.schema_reachable? and for the same reason: a genuinely
406
+ # unreachable schema (no database yet, table not migrated) degrades to
407
+ # no columns, but a real bug — a broken custom type adapter, a NameError
408
+ # from a typo — must keep surfacing instead of quietly emitting an empty
409
+ # draft. The defined? guard keeps this gem loadable without
410
+ # activerecord, same as schema_reachable? (a host without it duck-types
411
+ # `model:` and cannot raise an ActiveRecordError in the first place).
403
412
  def schema_columns(model)
404
413
  return nil unless model.respond_to?(:columns)
405
414
  return nil unless model.table_exists?
@@ -408,7 +417,9 @@ module Permittable
408
417
  # into its column names; a nil primary key becomes [].
409
418
  skipped = SKIPPED_COLUMNS + Array(model.primary_key).map(&:to_s)
410
419
  model.columns.reject { |c| skipped.include?(c.name) }
411
- rescue StandardError
420
+ rescue StandardError => e
421
+ raise unless defined?(ActiveRecord::ActiveRecordError) && e.is_a?(ActiveRecord::ActiveRecordError)
422
+
412
423
  nil
413
424
  end
414
425
 
@@ -507,7 +518,11 @@ module Permittable
507
518
  # can be CALLED as `Model.name`: a `first-status` enum's
508
519
  # `Order.first-statuses.keys` parses as `Order.first - statuses.keys`,
509
520
  # which runs a query when the draft loads. defined_enums reaches the
510
- # same mapping by name.
521
+ # same mapping by name. The model is also checked for the accessor
522
+ # itself (mirrors ColumnGuard.enum_keys_expr): a name can pluralize to a
523
+ # valid identifier that the model does not actually answer to — renamed
524
+ # or otherwise excluded — and calling it would raise NoMethodError when
525
+ # the draft loads.
511
526
  def enum_for(model, name)
512
527
  return nil unless model.respond_to?(:defined_enums)
513
528
 
@@ -515,7 +530,11 @@ module Permittable
515
530
  return nil unless mapping
516
531
 
517
532
  plural = name.pluralize
518
- accessor = METHOD_NAME.match?(plural) ? "#{model.name}.#{plural}" : "#{model.name}.defined_enums[#{name.inspect}]"
533
+ accessor = if METHOD_NAME.match?(plural) && model.respond_to?(plural)
534
+ "#{model.name}.#{plural}"
535
+ else
536
+ "#{model.name}.defined_enums[#{name.inspect}]"
537
+ end
519
538
  { mapping: mapping, accessor: accessor }
520
539
  end
521
540
 
@@ -114,8 +114,16 @@ module Permittable
114
114
  schema
115
115
  end
116
116
 
117
+ # deep_dup, not dup: .freeze is shallow, so the Array nested in an entry
118
+ # like :decimal ("type" => %w[string number]) stays live inside the frozen
119
+ # top-level Hash, and a shallow .dup would hand every :decimal field's
120
+ # exported schema that SAME Array. Nothing in this file mutates it in
121
+ # place (nullify! rebinds "type" to a new Array), but the exported
122
+ # document is caller-owned data, and a caller appending to it would
123
+ # otherwise silently rewrite the constant for every schema exported
124
+ # afterward in the process.
117
125
  def scalar_schema(field)
118
- schema = SCALAR_SCHEMAS.fetch(field[:type]).dup
126
+ schema = SCALAR_SCHEMAS.fetch(field[:type]).deep_dup
119
127
  apply_format_name!(schema, field)
120
128
  apply_in!(schema, field)
121
129
  apply_string_bounds!(schema, field)
@@ -151,7 +159,11 @@ module Permittable
151
159
  min, max = length_bounds(field[:length])
152
160
  schema["minItems"] = min if min
153
161
  schema["maxItems"] = max if max
154
- schema["items"] = field[:fields] ? object(field[:fields], unknown: unknown) : SCALAR_SCHEMAS.fetch(field[:of]).dup
162
+ # deep_dup here for the same reason as scalar_schema: a scalar `of:`
163
+ # otherwise hands every array field the SAME nested Array/Hash from
164
+ # SCALAR_SCHEMAS.
165
+ schema["items"] =
166
+ field[:fields] ? object(field[:fields], unknown: unknown) : SCALAR_SCHEMAS.fetch(field[:of]).deep_dup
155
167
  schema
156
168
  end
157
169
 
@@ -343,10 +355,22 @@ module Permittable
343
355
  # (silently changing a value outside any :decimal schema's justification)
344
356
  # or coerced through Float, where a non-finite BigDecimal (Infinity, NaN)
345
357
  # would crash JSON.generate.
358
+ #
359
+ # A `sensitive:` field's `default:`/`example:` are OMITTED rather than
360
+ # published: this exported document is the one channel meant to leave
361
+ # the app (client-generator tooling, a public docs endpoint), unlike the
362
+ # request log a `sensitive:` value is otherwise only redacted from, so
363
+ # shipping the real value here would defeat the redaction entirely.
364
+ # Omitting — rather than a placeholder string — matches how every other
365
+ # untranslatable or opaque fact in this file is handled: left out, with
366
+ # the `x-permittable-*` extension (here, `writeOnly`/`x-permittable-
367
+ # sensitive`) as the only signal that something is missing.
346
368
  def annotate(schema, field)
347
369
  decimal_mode = field[:kind] == JSON_TYPE ? :string : :number
348
- schema["default"] = json_value(field[:default], decimal: decimal_mode) if field.key?(:default)
349
- schema["examples"] = [json_value(field[:example], decimal: decimal_mode)] if field.key?(:example)
370
+ unless field[:sensitive]
371
+ schema["default"] = json_value(field[:default], decimal: decimal_mode) if field.key?(:default)
372
+ schema["examples"] = [json_value(field[:example], decimal: decimal_mode)] if field.key?(:example)
373
+ end
350
374
  schema["description"] = field[:desc] if field[:desc]
351
375
  if field[:sensitive]
352
376
  schema["writeOnly"] = true
@@ -115,8 +115,16 @@ module Permittable
115
115
  Permittable.error_format == :problem
116
116
  end
117
117
 
118
+ # ERROR_SCHEMA/PROBLEM_SCHEMA are frozen, but `.freeze` is shallow — only
119
+ # the top-level Hash is frozen, not the Hashes nested inside it — so
120
+ # handing either constant out by reference let a caller mutate a nested
121
+ # level of ITS document and permanently corrupt the shared constant for
122
+ # every document generated for the rest of the process. `deep_dup` (the
123
+ # same ActiveSupport helper the contract registry uses to copy authored
124
+ # default:/example: values before freezing, see permittable.rb) gives
125
+ # every caller its own independent copy instead.
118
126
  def error_schema
119
- problem_format? ? PROBLEM_SCHEMA : ERROR_SCHEMA
127
+ (problem_format? ? PROBLEM_SCHEMA : ERROR_SCHEMA).deep_dup
120
128
  end
121
129
 
122
130
  def error_media_type
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.9.0".freeze
2
+ VERSION = "0.10.0".freeze
3
3
  end
data/lib/permittable.rb CHANGED
@@ -403,7 +403,14 @@ module Permittable
403
403
  # replay of #names — otherwise a sink deduplicating by value would hold
404
404
  # both :ssn and "ssn".
405
405
  name = name.to_s.downcase
406
- sensitive_parameter_sinks.each { |sink| sink.call(name) } unless name.empty?
406
+ unless name.empty?
407
+ # sensitive_parameter_sinks is the same process-global Array
408
+ # on_sensitive_parameter appends to under @registry_mutex. Reading it
409
+ # here without that lock is an unsynchronized concurrent mutation
410
+ # during iteration on any Ruby without a GVL — a thread class-loading
411
+ # a sensitive: true contract can race a thread installing a sink.
412
+ @registry_mutex.synchronize { sensitive_parameter_sinks.dup }.each { |sink| sink.call(name) }
413
+ end
407
414
  nil
408
415
  end
409
416
 
@@ -744,6 +751,17 @@ module Permittable
744
751
  end
745
752
  end
746
753
 
754
+ # Kernel#Integer/Float and BigDecimal() all accept underscore digit
755
+ # separators and surrounding whitespace — a convenience for a NUMBER
756
+ # LITERAL IN RUBY SOURCE, not for a request body. "1_8" is not how a
757
+ # client spells eighteen, and " 99 " is not how one spells ninety-nine;
758
+ # silently accepting either is the same kind of leniency as the
759
+ # NaN/Infinity/`0e10` cases below, just arriving from a different door.
760
+ # Checked against the raw String before any of those delegate, so a
761
+ # non-canonical spelling never reaches them at all.
762
+ INTEGER_FORMAT = /\A[+-]?\d+\z/
763
+ NUMERIC_FORMAT = /\A[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?\z/
764
+
747
765
  def cast_integer(value)
748
766
  case value
749
767
  when Integer then [:ok, value]
@@ -751,7 +769,7 @@ module Permittable
751
769
  # RangeError, which the ArgumentError rescue below does not catch), and
752
770
  # no integer is what either one sent. Same rule as finite_float.
753
771
  when Float then value.finite? && value == value.truncate ? [:ok, value.to_i] : [:error, "invalid_type"]
754
- when String then [:ok, Integer(value, 10)]
772
+ when String then value.match?(INTEGER_FORMAT) ? [:ok, Integer(value, 10)] : [:error, "invalid_type"]
755
773
  else [:error, "invalid_type"]
756
774
  end
757
775
  rescue ArgumentError
@@ -761,7 +779,7 @@ module Permittable
761
779
  def cast_float(value)
762
780
  case value
763
781
  when Numeric then finite_float(value.to_f)
764
- when String then finite_float(Float(value), source: value)
782
+ when String then value.match?(NUMERIC_FORMAT) ? finite_float(Float(value), source: value) : [:error, "invalid_type"]
765
783
  else [:error, "invalid_type"]
766
784
  end
767
785
  rescue ArgumentError
@@ -792,7 +810,8 @@ module Permittable
792
810
 
793
811
  def cast_decimal(value)
794
812
  case value
795
- when Numeric, String then finite_decimal(BigDecimal(value.to_s))
813
+ when Numeric then finite_decimal(BigDecimal(value.to_s))
814
+ when String then value.match?(NUMERIC_FORMAT) ? finite_decimal(BigDecimal(value)) : [:error, "invalid_type"]
796
815
  else [:error, "invalid_type"]
797
816
  end
798
817
  rescue ArgumentError
@@ -1561,8 +1580,10 @@ module Permittable
1561
1580
  # gets); with one, the array exactly AS AUTHORED — the walker still runs,
1562
1581
  # so a declaration mistake (an element `validate:` refuses, a sub-field
1563
1582
  # default out of bounds) still fails at class load, but its cast result
1564
- # is discarded rather than stored. `transform:` itself is deliberately
1565
- # never run on a default either way — see AuthoredValues.
1583
+ # is discarded rather than stored. The array's OWN `transform:` is
1584
+ # deliberately never run on a default either way; a SUB-FIELD's
1585
+ # `transform:` still runs during that walk, so "the walker's read" above
1586
+ # really is what an equivalent request produces — see AuthoredValues.
1566
1587
  def validate_array_authored_value!(field, opt)
1567
1588
  value = field[opt]
1568
1589
  return if authored_nil!(field, opt)
@@ -1621,7 +1642,11 @@ module Permittable
1621
1642
  def validate_message!(field)
1622
1643
  spec = field[:message]
1623
1644
  return if spec.nil?
1624
- return if spec.is_a?(String)
1645
+
1646
+ if spec.is_a?(String)
1647
+ field[:message] = freeze_authored(spec)
1648
+ return
1649
+ end
1625
1650
 
1626
1651
  valid_hash = spec.is_a?(Hash) && !spec.empty? &&
1627
1652
  spec.all? { |code, text| (code.is_a?(Symbol) || code.is_a?(String)) && text.is_a?(String) }
@@ -1630,7 +1655,7 @@ module Permittable
1630
1655
  "or a Hash of violation code => String (e.g. { missing: \"is required\" })"
1631
1656
  end
1632
1657
 
1633
- field[:message] = spec.transform_keys(&:to_sym).freeze
1658
+ field[:message] = freeze_authored(spec.transform_keys(&:to_sym))
1634
1659
  end
1635
1660
  end
1636
1661
 
@@ -2357,7 +2382,10 @@ module Permittable
2357
2382
 
2358
2383
  declared = fields.map { |f| f[:name].to_s }
2359
2384
  extra = hash.keys.map(&:to_s) - declared
2360
- extra -= UNCHECKED_TOP_LEVEL_KEYS + permittable_request_supplied_keys if top_level
2385
+ if top_level
2386
+ extra -= UNCHECKED_TOP_LEVEL_KEYS + permittable_request_supplied_keys
2387
+ extra -= [permittable_configured_csrf_key].compact
2388
+ end
2361
2389
  return if extra.empty?
2362
2390
 
2363
2391
  if unknown == :error
@@ -2382,6 +2410,25 @@ module Permittable
2382
2410
  request.path_parameters.keys.map(&:to_s)
2383
2411
  end
2384
2412
 
2413
+ # FORM_KEYS bakes in "authenticity_token" — Rails' DEFAULT CSRF parameter
2414
+ # name — but `config.action_controller.request_forgery_protection_token`
2415
+ # lets an app rename it, and an app that does trips `unknown: :error` on
2416
+ # every ordinary form submission: exactly the bug FORM_KEYS exists to
2417
+ # prevent, just spelled with the app's own key instead of the default one.
2418
+ # The configured name isn't knowable at class-load time (it can vary per
2419
+ # controller, and Rails may not have finished initializing yet), so it's
2420
+ # read fresh here off the live controller instead of folded into a frozen
2421
+ # constant. `request_forgery_protection_token` comes from
2422
+ # ActionController::RequestForgeryProtection, included by ActionController
2423
+ # ::Base; a plain params duck or a standalone Contract has no such method
2424
+ # and exempts nothing beyond FORM_KEYS's own "authenticity_token".
2425
+ def permittable_configured_csrf_key
2426
+ return nil unless respond_to?(:request_forgery_protection_token)
2427
+
2428
+ token = request_forgery_protection_token
2429
+ token && token.to_s
2430
+ end
2431
+
2385
2432
  # A rootless contract's input without ParamsWrapper's copy of the body,
2386
2433
  # when Rails made one (see process_action). Removed rather than merely
2387
2434
  # exempted from the unknown-keys check, because the client never sent that
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.9.0
4
+ version: 0.10.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-09-27 00:00:00.000000000 Z
11
+ date: 2026-09-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport