concerns_on_rails 1.7.0 → 1.8.2

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: 7bdaf4512b253a9c3b7307ace69f16d616f180d38ce3a3f5292099f0c97dedb5
4
- data.tar.gz: de2843d32ebd1cea0e3c9fce3e8f5d6d18adaa6eb6e92ce4056af3bb2ab4d368
3
+ metadata.gz: 5126c79ca466ff1a85a20eb0d4a29f7a2d471bc4186e7b4762481bfad2084f42
4
+ data.tar.gz: '086922bf94b7efa7ef99699e4d96ab4e24ae2e731c2308bd1b0a8471e1f065a2'
5
5
  SHA512:
6
- metadata.gz: 315ff80a677ef032630d0b81f8c0b5c3b88d132a288ad075ed00732e2d676ccef954725c8966231af793ee89dc2485cb48e6b0c1de03ea93d47c7f283545815d
7
- data.tar.gz: 13c59b78fc2b9dbd8dfca9877550a3a7fcd778fca493dd1b97c9151f9c4d111f94aa8eb3d8692b9b7e0299ccd52bf270944dee2a6987aae3c88917532cce2404
6
+ metadata.gz: 6b3e968d8cf97c9d11b14ff1d8374a64731ed3416d114ca0acf525a64f65843a35f0261da219d9f725e539eba7ac6460ba331a3aaf2b701b88627d841ac70a2a
7
+ data.tar.gz: 61f80dadd2112d36c1c1ceaf88fe01fb5dcf68f22911e1535e68414ecdbaca07ca363365c7ec3032c2698aec1cb0929d054036d2b8ebf73d1c347093976f3bb4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.8.2 (2026-05-22)
4
+
5
+ ### Internal
6
+ - Regenerated `Gemfile.lock` so the pinned `concerns_on_rails` version matches the gemspec. No behavior change.
7
+
8
+ ### Notes
9
+ - The `v1.8.1` tag was pushed but failed CI (`bundle install --deployment` rejected the stale `Gemfile.lock`); `1.8.2` is the first usable release of the Tokenizable concern.
10
+
11
+ ## 1.8.1 (2026-05-22)
12
+
13
+ ### Internal
14
+ - Refactored `Models::Tokenizable` `class_methods` blocks to satisfy `Metrics/BlockLength`. No behavior change.
15
+
16
+ ### Notes
17
+ - The `v1.8.0` tag was pushed but failed RuboCop; `1.8.1` is the first usable release of the Tokenizable concern.
18
+
19
+ ## 1.8.0 (2026-05-22)
20
+
21
+ ### Added
22
+ - **Models::Tokenizable**: Security-token generation for API keys, invite codes, share links, password-reset tokens. Each `tokenizable_by` call adds an independently-configured field (one model can hold many tokens). Defaults to 32-char URL-safe values; also supports `:hex`, `:alphanumeric`, and `:numeric` types with a configurable `length:`. Auto-generates on create with best-effort uniqueness retry, and provides `regenerate_<field>!`, `revoke_<field>!`, `<field>?`, and a timing-safe `.authenticate_by_<field>` class method.
23
+
3
24
  ## 1.7.0 (2026-05-21)
4
25
 
5
26
  ### Added
data/README.md CHANGED
@@ -35,6 +35,7 @@ Article.published.without_deleted.find("hello-world")
35
35
  - [Normalizable](#-normalizable) — attribute normalization (`:email`, `:phone`, …)
36
36
  - [Searchable](#-searchable) — LIKE/ILIKE search across configured columns
37
37
  - [Activatable](#-activatable) — boolean active/inactive toggle
38
+ - [Tokenizable](#-tokenizable) — security tokens with timing-safe lookup
38
39
  - **Controller concerns**
39
40
  - [Paginatable](#-paginatable) — offset pagination with headers
40
41
  - [Filterable](#-filterable) — declarative URL-param filters
@@ -50,7 +51,7 @@ Article.published.without_deleted.find("hello-world")
50
51
 
51
52
  ## ✨ Why this gem?
52
53
 
53
- - **Ten model concerns + five controller concerns**, all production-ready
54
+ - **Eleven model concerns + five controller concerns**, all production-ready
54
55
  - **One include, one macro** — no boilerplate, no glue code
55
56
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
56
57
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -63,7 +64,7 @@ Article.published.without_deleted.find("hello-world")
63
64
  Add to your application's `Gemfile`:
64
65
 
65
66
  ```ruby
66
- gem "concerns_on_rails", "~> 1.7"
67
+ gem "concerns_on_rails", "~> 1.8"
67
68
  ```
68
69
 
69
70
  Or pull the latest from GitHub:
@@ -485,6 +486,46 @@ Subscription.inactive # WHERE active = FALSE OR active IS NULL
485
486
 
486
487
  ---
487
488
 
489
+ ## 🔑 Tokenizable
490
+
491
+ Generate and manage security tokens — API keys, invite codes, share links, password-reset tokens. One model can declare any number of independently-configured token fields.
492
+
493
+ ```ruby
494
+ class User < ApplicationRecord
495
+ include ConcernsOnRails::Tokenizable
496
+
497
+ tokenizable_by :api_token # 32-char URL-safe
498
+ tokenizable_by :reset_password_token, length: 24
499
+ tokenizable_by :invite_code, type: :alphanumeric, length: 8
500
+ end
501
+
502
+ user = User.create! # all three tokens auto-generated
503
+ user.api_token # => "k3Jf...g2" (32 URL-safe chars)
504
+ user.api_token? # => true
505
+
506
+ user.regenerate_api_token! # rotates and persists
507
+ user.revoke_api_token! # nils the column
508
+
509
+ User.find_by_api_token(token) # Rails default
510
+ User.authenticate_by_api_token(token) # timing-safe; returns user or nil
511
+ ```
512
+
513
+ **Options**
514
+
515
+ | Option | Default | Notes |
516
+ | -------- | ----------- | ------------------------------------------------------------- |
517
+ | `type:` | `:urlsafe` | One of `:urlsafe`, `:hex`, `:alphanumeric`, `:numeric` |
518
+ | `length:`| `32` | Character length of the generated token |
519
+
520
+ **Notes**
521
+ - URL-safe by default (`A–Z`, `a–z`, `0–9`, `-`, `_`) — drop straight into URLs and headers.
522
+ - Caller-supplied values are respected: `User.create!(api_token: "preset")` won't be overwritten.
523
+ - Generation does a best-effort uniqueness check before insert and retries up to 10 times. Pair with a `unique` DB index for real safety, especially for short alphanumeric/numeric codes.
524
+ - `.authenticate_by_<field>` uses `ActiveSupport::SecurityUtils.secure_compare` to avoid leaking partial matches via response timing.
525
+ - Distinct from `Hashable`: Hashable handles a single random field; Tokenizable focuses on security tokens (multi-field, URL-safe default, timing-safe lookup, revocation).
526
+
527
+ ---
528
+
488
529
  # 🎮 Controller Concerns
489
530
 
490
531
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -12,4 +12,5 @@ module ConcernsOnRails
12
12
  Normalizable = Models::Normalizable
13
13
  Searchable = Models::Searchable
14
14
  Activatable = Models::Activatable
15
+ Tokenizable = Models::Tokenizable
15
16
  end
@@ -0,0 +1,145 @@
1
+ require "active_support/concern"
2
+ require "active_support/security_utils"
3
+ require "securerandom"
4
+
5
+ module ConcernsOnRails
6
+ module Models
7
+ # Generates and manages security tokens (API keys, invite codes, share links).
8
+ #
9
+ # class User < ApplicationRecord
10
+ # include ConcernsOnRails::Tokenizable
11
+ #
12
+ # tokenizable_by :api_token # 32-char URL-safe
13
+ # tokenizable_by :reset_password_token, length: 24
14
+ # tokenizable_by :invite_code, type: :alphanumeric, length: 8
15
+ # end
16
+ #
17
+ # user = User.create! # tokens auto-generated on create
18
+ # user.regenerate_api_token! # new value, persisted
19
+ # user.revoke_api_token! # sets the column to nil
20
+ # user.api_token? # true if present
21
+ #
22
+ # User.find_by_api_token(token) # Rails default
23
+ # User.authenticate_by_api_token(token) # timing-safe lookup, returns record or nil
24
+ #
25
+ # Unlike Hashable, one model can declare multiple token fields, generation is
26
+ # URL-safe by default, and `assign_tokenizable_value` retries on uniqueness
27
+ # collisions before insert (best-effort; pair with a unique DB index).
28
+ module Tokenizable
29
+ extend ActiveSupport::Concern
30
+
31
+ VALID_TYPES = %i[urlsafe hex alphanumeric numeric].freeze
32
+ ALPHANUMERIC_ALPHABET = (("A".."Z").to_a + ("a".."z").to_a + ("0".."9").to_a).freeze
33
+ NUMERIC_ALPHABET = ("0".."9").to_a.freeze
34
+ MAX_GENERATION_ATTEMPTS = 10
35
+
36
+ included do
37
+ class_attribute :tokenizable_fields, instance_accessor: false, default: {}
38
+ end
39
+
40
+ class_methods do
41
+ # Configure a tokenizable field.
42
+ #
43
+ # Options:
44
+ # type: one of :urlsafe (default), :hex, :alphanumeric, :numeric
45
+ # length: character length of the generated token (default 32)
46
+ def tokenizable_by(field, type: :urlsafe, length: 32)
47
+ field = field.to_sym
48
+ type = type.to_sym
49
+ length = length.to_i
50
+
51
+ validate_tokenizable_options!(field, type, length)
52
+
53
+ # Build a fresh hash so subclasses don't mutate the parent's config.
54
+ self.tokenizable_fields = tokenizable_fields.merge(field => { type: type, length: length })
55
+
56
+ before_create -> { assign_tokenizable_value(field) }
57
+
58
+ define_tokenizable_methods(field)
59
+ end
60
+
61
+ # Generate a new random value for the given field using its configured type/length.
62
+ def generate_tokenizable_value(field)
63
+ config = tokenizable_fields.fetch(field) do
64
+ raise ArgumentError, "ConcernsOnRails::Models::Tokenizable: '#{field}' is not a tokenizable field"
65
+ end
66
+
67
+ length = config[:length]
68
+
69
+ case config[:type]
70
+ when :urlsafe then SecureRandom.urlsafe_base64(length)[0, length]
71
+ when :hex then SecureRandom.hex((length + 1) / 2)[0, length]
72
+ when :alphanumeric then random_string_from_alphabet(ALPHANUMERIC_ALPHABET, length)
73
+ when :numeric then random_string_from_alphabet(NUMERIC_ALPHABET, length)
74
+ end
75
+ end
76
+ end
77
+
78
+ class_methods do
79
+ private
80
+
81
+ def define_tokenizable_methods(field)
82
+ define_method("regenerate_#{field}!") { update!(field => self.class.generate_tokenizable_value(field)) }
83
+ define_method("revoke_#{field}!") { update!(field => nil) }
84
+ define_method("#{field}?") { self[field].present? }
85
+ define_singleton_method("authenticate_by_#{field}") { |value| timing_safe_find(field, value) }
86
+ end
87
+
88
+ def timing_safe_find(field, value)
89
+ return nil if value.blank?
90
+
91
+ candidate = find_by(field => value)
92
+ return nil unless candidate
93
+
94
+ stored = candidate[field].to_s
95
+ given = value.to_s
96
+ return nil unless stored.bytesize == given.bytesize
97
+
98
+ ActiveSupport::SecurityUtils.secure_compare(stored, given) ? candidate : nil
99
+ end
100
+ end
101
+
102
+ class_methods do
103
+ def validate_tokenizable_options!(field, type, length)
104
+ unless column_names.include?(field.to_s)
105
+ raise ArgumentError,
106
+ "ConcernsOnRails::Models::Tokenizable: tokenizable field '#{field}' does not exist in the database"
107
+ end
108
+
109
+ unless VALID_TYPES.include?(type)
110
+ raise ArgumentError,
111
+ "ConcernsOnRails::Models::Tokenizable: unknown type '#{type}'. Valid types: #{VALID_TYPES.join(', ')}"
112
+ end
113
+
114
+ return if length.positive?
115
+
116
+ raise ArgumentError, "ConcernsOnRails::Models::Tokenizable: length must be a positive integer"
117
+ end
118
+
119
+ def random_string_from_alphabet(alphabet, length)
120
+ Array.new(length) { alphabet[SecureRandom.random_number(alphabet.size)] }.join
121
+ end
122
+
123
+ private :validate_tokenizable_options!, :random_string_from_alphabet
124
+ end
125
+
126
+ # Assigns the generated value only when blank, so callers can pass an explicit one.
127
+ # Retries up to MAX_GENERATION_ATTEMPTS times if the in-Ruby uniqueness check hits a
128
+ # collision — useful for short codes; a unique DB index is still the real guarantee.
129
+ def assign_tokenizable_value(field)
130
+ return if self[field].present?
131
+
132
+ MAX_GENERATION_ATTEMPTS.times do
133
+ candidate = self.class.generate_tokenizable_value(field)
134
+ unless self.class.unscoped.exists?(field => candidate)
135
+ self[field] = candidate
136
+ return
137
+ end
138
+ end
139
+
140
+ raise "ConcernsOnRails::Models::Tokenizable: could not generate a unique value for '#{field}' " \
141
+ "after #{MAX_GENERATION_ATTEMPTS} attempts — consider a longer length or a larger alphabet"
142
+ end
143
+ end
144
+ end
145
+ end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.7.0".freeze
2
+ VERSION = "1.8.2".freeze
3
3
  end
@@ -17,6 +17,7 @@ require "concerns_on_rails/models/expirable"
17
17
  require "concerns_on_rails/models/normalizable"
18
18
  require "concerns_on_rails/models/searchable"
19
19
  require "concerns_on_rails/models/activatable"
20
+ require "concerns_on_rails/models/tokenizable"
20
21
 
21
22
  # Controller concerns
22
23
  require "concerns_on_rails/controllers/paginatable"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: concerns_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.0
4
+ version: 1.8.2
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-05-21 00:00:00.000000000 Z
11
+ date: 2026-05-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -86,6 +86,7 @@ files:
86
86
  - lib/concerns_on_rails/models/sluggable.rb
87
87
  - lib/concerns_on_rails/models/soft_deletable.rb
88
88
  - lib/concerns_on_rails/models/sortable.rb
89
+ - lib/concerns_on_rails/models/tokenizable.rb
89
90
  - lib/concerns_on_rails/version.rb
90
91
  homepage: https://github.com/VSN2015/concerns_on_rails
91
92
  licenses: