api_keys 0.2.1 → 0.4.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/README.md +851 -25
  4. data/SECURITY.md +33 -0
  5. data/app/controllers/api_keys/application_controller.rb +58 -10
  6. data/app/controllers/api_keys/keys_controller.rb +77 -23
  7. data/app/controllers/api_keys/security_controller.rb +8 -0
  8. data/app/views/api_keys/keys/_empty_state.html.erb +9 -0
  9. data/app/views/api_keys/keys/_form.html.erb +33 -4
  10. data/app/views/api_keys/keys/_key_actions.html.erb +20 -0
  11. data/app/views/api_keys/keys/_key_badges.html.erb +17 -0
  12. data/app/views/api_keys/keys/_key_row.html.erb +21 -35
  13. data/app/views/api_keys/keys/_key_status.html.erb +10 -0
  14. data/app/views/api_keys/keys/_keys_table.html.erb +3 -11
  15. data/app/views/api_keys/keys/_publishable_keys.html.erb +40 -0
  16. data/app/views/api_keys/keys/_secret_keys.html.erb +39 -0
  17. data/app/views/api_keys/keys/_show_token.html.erb +10 -47
  18. data/app/views/api_keys/keys/_token_display.html.erb +11 -0
  19. data/app/views/api_keys/keys/index.html.erb +40 -8
  20. data/app/views/api_keys/keys/show.html.erb +2 -2
  21. data/app/views/api_keys/security/best_practices.html.erb +73 -47
  22. data/app/views/layouts/api_keys/application.html.erb +267 -14
  23. data/lib/api_keys/authentication.rb +39 -11
  24. data/lib/api_keys/configuration.rb +444 -17
  25. data/lib/api_keys/engine.rb +5 -20
  26. data/lib/api_keys/errors.rb +73 -0
  27. data/lib/api_keys/form_builder_extensions.rb +168 -0
  28. data/lib/api_keys/helpers/expiration_options.rb +139 -0
  29. data/lib/api_keys/helpers/token_session.rb +203 -0
  30. data/lib/api_keys/helpers/view_helpers.rb +220 -0
  31. data/lib/api_keys/jobs/callbacks_job.rb +10 -17
  32. data/lib/api_keys/jobs/update_stats_job.rb +27 -12
  33. data/lib/api_keys/models/api_key.rb +452 -21
  34. data/lib/api_keys/models/concerns/has_api_keys.rb +269 -26
  35. data/lib/api_keys/services/authenticator.rb +300 -112
  36. data/lib/api_keys/services/digestor.rb +81 -14
  37. data/lib/api_keys/services/token_generator.rb +41 -1
  38. data/lib/api_keys/tenant_resolution.rb +4 -4
  39. data/lib/api_keys/version.rb +1 -1
  40. data/lib/api_keys.rb +12 -0
  41. data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
  42. data/lib/generators/api_keys/add_key_types_generator.rb +68 -0
  43. data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
  44. data/lib/generators/api_keys/templates/add_key_types_to_api_keys.rb.erb +18 -0
  45. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +11 -3
  46. data/lib/generators/api_keys/templates/initializer.rb +261 -120
  47. metadata +29 -63
  48. data/Rakefile +0 -32
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiKeys
4
+ # Opt-in form builder extensions for API key forms.
5
+ #
6
+ # These helpers reduce boilerplate while letting you control the styling.
7
+ # Include them in your form builder to use:
8
+ #
9
+ # # In config/initializers/api_keys.rb:
10
+ # Rails.application.config.to_prepare do
11
+ # ActionView::Helpers::FormBuilder.include(ApiKeys::FormBuilderExtensions)
12
+ # end
13
+ #
14
+ # @example Expiration select
15
+ # <%= form.api_key_expiration_select(class: "my-select-class") %>
16
+ #
17
+ # @example Scopes checkboxes with block for custom rendering
18
+ # <%= form.api_key_scopes_checkboxes(@scopes) do |scope, checked| %>
19
+ # <label>
20
+ # <%= check_box_tag "api_key[scopes][]", scope, checked, class: "my-checkbox" %>
21
+ # <%= scope %>
22
+ # </label>
23
+ # <% end %>
24
+ #
25
+ module FormBuilderExtensions
26
+ # Renders a select field for API key expiration presets.
27
+ #
28
+ # @param options [Hash] Options passed to the select helper
29
+ # @param html_options [Hash] HTML attributes for the select element
30
+ # @return [String] HTML select element
31
+ #
32
+ # @example Basic usage
33
+ # <%= form.api_key_expiration_select %>
34
+ #
35
+ # @example With Tailwind classes
36
+ # <%= form.api_key_expiration_select(class: "w-full px-4 py-3 border rounded-lg") %>
37
+ #
38
+ # @example With custom default
39
+ # <%= form.api_key_expiration_select(selected: "30_days") %>
40
+ #
41
+ def api_key_expiration_select(options = {}, html_options = {})
42
+ # expires_at_preset is a form-only param, not a model attribute
43
+ # So we only use the provided :selected option or default
44
+ selected = options.delete(:selected) || ApiKeys::ExpirationOptions.default_value
45
+
46
+ select(
47
+ :expires_at_preset,
48
+ @template.options_for_select(ApiKeys::ExpirationOptions.for_select, selected),
49
+ options,
50
+ html_options
51
+ )
52
+ end
53
+
54
+ # Renders checkboxes for API key scopes.
55
+ #
56
+ # If a block is given, yields each scope and its checked state for custom rendering.
57
+ # If no block is given, returns an array of checkbox data for manual iteration.
58
+ #
59
+ # @param scopes [Array<String>] Available scopes to render
60
+ # @param checked [Symbol, Array] Which scopes should be checked:
61
+ # - :all (default for new records) - all scopes checked
62
+ # - :none - no scopes checked
63
+ # - Array - specific scopes to check
64
+ # - nil - uses the object's current scopes
65
+ # @return [String, Array] HTML if block given, otherwise array of scope data
66
+ #
67
+ # @example With block (recommended for custom styling)
68
+ # <%= form.api_key_scopes_checkboxes(@scopes) do |scope, checked| %>
69
+ # <label class="flex items-center gap-2">
70
+ # <%= check_box_tag "api_key[scopes][]", scope, checked, class: "rounded" %>
71
+ # <code><%= scope %></code>
72
+ # </label>
73
+ # <% end %>
74
+ #
75
+ # @example Simple rendering without block
76
+ # <% form.api_key_scopes_checkboxes(@scopes).each do |scope_data| %>
77
+ # <%= check_box_tag "api_key[scopes][]", scope_data[:value], scope_data[:checked] %>
78
+ # <%= scope_data[:value] %>
79
+ # <% end %>
80
+ #
81
+ def api_key_scopes_checkboxes(scopes, checked: nil, &block)
82
+ # Determine which scopes should be checked
83
+ checked_scopes = resolve_checked_scopes(scopes, checked)
84
+
85
+ scope_data = scopes.map do |scope|
86
+ {
87
+ value: scope,
88
+ checked: checked_scopes.include?(scope),
89
+ field_name: "#{object_name}[scopes][]"
90
+ }
91
+ end
92
+
93
+ if block_given?
94
+ # Yield each scope for custom rendering
95
+ safe_buffer = ActiveSupport::SafeBuffer.new
96
+ scope_data.each do |data|
97
+ safe_buffer << @template.capture { yield(data[:value], data[:checked]) }
98
+ end
99
+ safe_buffer
100
+ else
101
+ # Return raw data for manual iteration
102
+ scope_data
103
+ end
104
+ end
105
+
106
+ # Returns structured data for building a token display UI.
107
+ # Useful when you need to build a custom token display with copy functionality.
108
+ #
109
+ # @return [Hash] Token display data with keys:
110
+ # - :masked [String] The masked token (e.g., "sk_live_••••abc")
111
+ # - :full [String, nil] The full token (only for viewable public keys)
112
+ # - :viewable [Boolean] Whether the full token can be displayed
113
+ # - :type [Symbol, String, nil] A known built-in type is a Symbol; custom
114
+ # untrusted values remain Strings so they are never interned as Symbols.
115
+ # - :environment [String, nil] The environment (e.g., "live", "test")
116
+ #
117
+ # @example
118
+ # <% data = form.api_key_token_data %>
119
+ # <code><%= data[:masked] %></code>
120
+ # <% if data[:viewable] %>
121
+ # <button data-token="<%= data[:full] %>">Copy</button>
122
+ # <% end %>
123
+ #
124
+ def api_key_token_data
125
+ return {} unless object.respond_to?(:masked_token)
126
+
127
+ {
128
+ masked: object.masked_token,
129
+ full: object.viewable_token,
130
+ viewable: object.respond_to?(:public_key_type?) && object.public_key_type?,
131
+ type: safe_key_type(object.key_type),
132
+ environment: object.environment
133
+ }
134
+ end
135
+
136
+ private
137
+
138
+ def safe_key_type(value)
139
+ case value.to_s
140
+ when "publishable" then :publishable
141
+ when "secret" then :secret
142
+ when "" then nil
143
+ else value.to_s
144
+ end
145
+ end
146
+
147
+ def resolve_checked_scopes(scopes, checked)
148
+ case checked
149
+ when :all
150
+ scopes.map(&:to_s)
151
+ when :none
152
+ []
153
+ when Array
154
+ checked.map(&:to_s)
155
+ when nil
156
+ # For new records, default to all checked
157
+ # For existing records, use the object's scopes
158
+ if object&.persisted?
159
+ (object.scopes || []).map(&:to_s)
160
+ else
161
+ scopes.map(&:to_s)
162
+ end
163
+ else
164
+ scopes.map(&:to_s)
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiKeys
4
+ module Helpers
5
+ # Helper for handling API key expiration presets.
6
+ #
7
+ # Provides a consistent set of expiration options for use in forms,
8
+ # and parsing logic to convert preset strings to actual dates.
9
+ #
10
+ # @example In your view (form select)
11
+ # <%= form.select :expires_at_preset,
12
+ # ApiKeys::Helpers::ExpirationOptions.for_select %>
13
+ #
14
+ # @example In your controller
15
+ # expires_at = ApiKeys::Helpers::ExpirationOptions.parse(params[:expires_at_preset])
16
+ # @api_key = current_org.create_api_key!(expires_at: expires_at, ...)
17
+ #
18
+ class ExpirationOptions
19
+ # Default preset options with human-readable labels
20
+ DEFAULT_PRESETS = [
21
+ { label: "No Expiration", value: "no_expiration", days: nil },
22
+ { label: "7 days", value: "7_days", days: 7 },
23
+ { label: "30 days", value: "30_days", days: 30 },
24
+ { label: "60 days", value: "60_days", days: 60 },
25
+ { label: "90 days", value: "90_days", days: 90 },
26
+ { label: "1 year", value: "365_days", days: 365 }
27
+ ].map(&:freeze).freeze
28
+
29
+ class << self
30
+ # Returns options suitable for a Rails select helper.
31
+ #
32
+ # @param include_no_expiration [Boolean] Whether to include "No Expiration" option (default: true)
33
+ # @param presets [Array<Integer>, nil] Custom list of days to include (e.g., [7, 30, 90])
34
+ # If nil, uses DEFAULT_PRESETS
35
+ # @return [Array<Array>] Array of [label, value] pairs for select helper
36
+ #
37
+ # @example Default options
38
+ # ExpirationOptions.for_select
39
+ # # => [["No Expiration", "no_expiration"], ["7 days", "7_days"], ...]
40
+ #
41
+ # @example Custom presets
42
+ # ExpirationOptions.for_select(presets: [7, 30, 365])
43
+ # # => [["No Expiration", "no_expiration"], ["7 days", "7_days"], ["30 days", "30_days"], ["1 year", "365_days"]]
44
+ #
45
+ # @example Without "No Expiration"
46
+ # ExpirationOptions.for_select(include_no_expiration: false)
47
+ # # => [["7 days", "7_days"], ["30 days", "30_days"], ...]
48
+ #
49
+ def for_select(include_no_expiration: true, presets: nil)
50
+ options = if presets
51
+ build_custom_presets(presets, include_no_expiration)
52
+ else
53
+ filter_default_presets(include_no_expiration)
54
+ end
55
+
56
+ options.map { |opt| [opt[:label], opt[:value]] }
57
+ end
58
+
59
+ # Parse an expiration preset string into an actual datetime.
60
+ #
61
+ # @param preset [String, nil] The preset value (e.g., "30_days", "no_expiration")
62
+ # @return [ActiveSupport::TimeWithZone, nil] The expiration date, or nil for no expiration
63
+ #
64
+ # @example
65
+ # ExpirationOptions.parse("30_days") # => 30.days.from_now
66
+ # ExpirationOptions.parse("no_expiration") # => nil
67
+ # ExpirationOptions.parse(nil) # => nil
68
+ # ExpirationOptions.parse("invalid") # => raises ArgumentError
69
+ #
70
+ def parse(preset)
71
+ return nil if preset.nil? || preset == ""
72
+ unless preset.is_a?(String) && preset.valid_encoding? && preset.bytesize <= 32
73
+ raise ArgumentError, "Invalid API key expiration preset"
74
+ end
75
+ return nil if preset.blank? || preset == "no_expiration"
76
+
77
+ # Try to extract days from the preset string (e.g., "30_days" => 30)
78
+ if preset =~ /\A(\d+)_days?\z/
79
+ days = ::Regexp.last_match(1).to_i
80
+ # Reasonable max: ~10 years to prevent overflow issues
81
+ return days.days.from_now if days.positive? && days <= 3650
82
+ end
83
+
84
+ # Check against known presets
85
+ known = DEFAULT_PRESETS.find { |p| p[:value] == preset }
86
+ return known[:days].days.from_now if known && known[:days]
87
+
88
+ raise ArgumentError, "Invalid API key expiration preset"
89
+ end
90
+
91
+ # Returns the default preset value (useful for form defaults)
92
+ #
93
+ # @return [String] The default preset value
94
+ def default_value
95
+ "no_expiration"
96
+ end
97
+
98
+ private
99
+
100
+ def filter_default_presets(include_no_expiration)
101
+ if include_no_expiration
102
+ DEFAULT_PRESETS
103
+ else
104
+ DEFAULT_PRESETS.reject { |p| p[:value] == "no_expiration" }
105
+ end
106
+ end
107
+
108
+ def build_custom_presets(days_list, include_no_expiration)
109
+ unless days_list.is_a?(Array) && days_list.all? { |days| days.is_a?(Integer) && days.between?(1, 3650) }
110
+ raise ArgumentError, "Expiration presets must be an array of integers between 1 and 3650"
111
+ end
112
+
113
+ options = []
114
+
115
+ if include_no_expiration
116
+ options << { label: "No Expiration", value: "no_expiration", days: nil }
117
+ end
118
+
119
+ days_list.each do |days|
120
+ options << preset_for_days(days)
121
+ end
122
+
123
+ options
124
+ end
125
+
126
+ def preset_for_days(days)
127
+ label = case days
128
+ when 1 then "1 day"
129
+ when 365 then "1 year"
130
+ when ->(d) { d % 365 == 0 } then "#{days / 365} years"
131
+ else "#{days} days"
132
+ end
133
+
134
+ { label: label, value: "#{days}_days", days: days }
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/numeric/time"
4
+ require "active_support/message_encryptor"
5
+ require "json"
6
+
7
+ module ApiKeys
8
+ module Helpers
9
+ # Helper for managing API key tokens in the session.
10
+ #
11
+ # Secret keys can only be shown once (immediately after creation) because
12
+ # the plaintext token is not stored in the database. This helper provides
13
+ # a clean interface for the "show token once" pattern:
14
+ #
15
+ # 1. After creating a key, store an encrypted, short-lived handoff in the session
16
+ # 2. On the success page, decrypt, retrieve, and clear the token
17
+ # 3. If the user refreshes, the token is gone
18
+ #
19
+ # @example In your controller
20
+ # # After creating a key:
21
+ # def create
22
+ # @api_key = current_org.create_api_key!(...)
23
+ # ApiKeys::Helpers::TokenSession.store(session, @api_key)
24
+ # redirect_to success_path
25
+ # end
26
+ #
27
+ # # On the success page:
28
+ # def success
29
+ # @token = ApiKeys::Helpers::TokenSession.retrieve_once(session)
30
+ # redirect_to index_path, alert: "Token already shown" unless @token
31
+ # end
32
+ #
33
+ class TokenSession
34
+ # Default session key for storing the token
35
+ DEFAULT_SESSION_KEY = :api_keys_new_token
36
+ MAX_TOKEN_BYTESIZE = 512
37
+ MAX_CIPHERTEXT_BYTESIZE = 4096
38
+ MAX_KEY_ID_BYTESIZE = 128
39
+ HANDOFF_VERSION = 2
40
+ HANDOFF_TTL = 10.minutes
41
+ ENCRYPTION_CIPHER = "aes-256-gcm"
42
+ ENCRYPTION_SALT = "api_keys/token_session/v2"
43
+ ENCRYPTION_PURPOSE = "api_keys.token_session"
44
+ JSON_SERIALIZER = Module.new do
45
+ module_function
46
+
47
+ def dump(value)
48
+ JSON.generate(value)
49
+ end
50
+
51
+ def load(value)
52
+ JSON.parse(value)
53
+ end
54
+ end
55
+
56
+ class << self
57
+ # Store an encrypted API key-token handoff in the session for later retrieval.
58
+ #
59
+ # @param session [ActionDispatch::Request::Session] The Rails session
60
+ # @param api_key [ApiKeys::ApiKey] The newly created API key
61
+ # @param key [Symbol] Optional custom session key (default: :api_keys_new_token)
62
+ # @return [String] The token that was stored
63
+ def store(session, api_key, key: DEFAULT_SESSION_KEY)
64
+ token = api_key.respond_to?(:token) ? api_key.token : api_key.to_s
65
+ unless valid_token_payload?(token)
66
+ raise ArgumentError, "Cannot store an invalid API key token in the session"
67
+ end
68
+
69
+ api_key_id = normalize_api_key_id(api_key.id) if api_key.respond_to?(:id)
70
+ encrypted_payload = token_encryptor.encrypt_and_sign(
71
+ { "token" => token, "api_key_id" => api_key_id },
72
+ expires_in: HANDOFF_TTL,
73
+ purpose: ENCRYPTION_PURPOSE
74
+ )
75
+ session[key] = {
76
+ "version" => HANDOFF_VERSION,
77
+ "ciphertext" => encrypted_payload,
78
+ "api_key_id" => api_key_id
79
+ }
80
+ token
81
+ end
82
+
83
+ # Retrieve and clear the token from the session.
84
+ # Returns nil if no token is stored (e.g., page was refreshed).
85
+ #
86
+ # @param session [ActionDispatch::Request::Session] The Rails session
87
+ # @param key [Symbol] Optional custom session key (default: :api_keys_new_token)
88
+ # @return [String, nil] The token, or nil if not present
89
+ def retrieve_once(session, key: DEFAULT_SESSION_KEY, api_key: nil, api_key_id: nil)
90
+ payload = session.delete(key)
91
+ expected_id = normalize_api_key_id(api_key_id || (api_key.id if api_key.respond_to?(:id)))
92
+
93
+ # Plain string payloads from older versions remain readable only when
94
+ # the caller does not request ID binding.
95
+ return payload if expected_id.nil? && valid_token_payload?(payload)
96
+ decoded_payload = decode_payload(payload)
97
+ return nil unless decoded_payload
98
+
99
+ token = decoded_payload["token"] || decoded_payload[:token]
100
+ stored_id = decoded_payload["api_key_id"] || decoded_payload[:api_key_id]
101
+ return nil if expected_id && stored_id.to_s != expected_id.to_s
102
+ return nil unless valid_token_payload?(token)
103
+
104
+ token
105
+ end
106
+
107
+ # Check if a token is available in the session without removing it.
108
+ # Useful for conditional rendering.
109
+ #
110
+ # @param session [ActionDispatch::Request::Session] The Rails session
111
+ # @param key [Symbol] Optional custom session key (default: :api_keys_new_token)
112
+ # @return [Boolean] true if a token is stored
113
+ def available?(session, key: DEFAULT_SESSION_KEY, api_key: nil, api_key_id: nil)
114
+ payload = session[key]
115
+ expected_id = normalize_api_key_id(api_key_id || (api_key.id if api_key.respond_to?(:id)))
116
+
117
+ return valid_token_payload?(payload) if payload.is_a?(String) && expected_id.nil?
118
+ decoded_payload = decode_payload(payload)
119
+ return false unless decoded_payload
120
+
121
+ token = decoded_payload["token"] || decoded_payload[:token]
122
+ stored_id = decoded_payload["api_key_id"] || decoded_payload[:api_key_id]
123
+ return false if expected_id && stored_id.to_s != expected_id.to_s
124
+
125
+ valid_token_payload?(token)
126
+ end
127
+
128
+ private
129
+
130
+ def decode_payload(payload)
131
+ return nil unless payload.is_a?(Hash)
132
+
133
+ version_present = payload.key?("version") || payload.key?(:version)
134
+ return legacy_payload(payload) unless version_present
135
+
136
+ version = payload["version"] || payload[:version]
137
+ return nil unless version == HANDOFF_VERSION
138
+
139
+ ciphertext = payload["ciphertext"] || payload[:ciphertext]
140
+ outer_id = normalize_api_key_id(payload["api_key_id"] || payload[:api_key_id])
141
+ return nil unless ciphertext.is_a?(String) && ciphertext.bytesize <= MAX_CIPHERTEXT_BYTESIZE
142
+
143
+ decoded = token_encryptor.decrypt_and_verify(ciphertext, purpose: ENCRYPTION_PURPOSE)
144
+ return nil unless decoded.is_a?(Hash)
145
+
146
+ inner_id = normalize_api_key_id(decoded["api_key_id"] || decoded[:api_key_id])
147
+ return nil unless outer_id == inner_id
148
+
149
+ decoded
150
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage, ArgumentError
151
+ nil
152
+ end
153
+
154
+ def legacy_payload(payload)
155
+ token = payload["token"] || payload[:token]
156
+ stored_id = normalize_api_key_id(payload["api_key_id"] || payload[:api_key_id])
157
+ return nil unless valid_token_payload?(token)
158
+
159
+ { "token" => token, "api_key_id" => stored_id }
160
+ rescue ArgumentError
161
+ nil
162
+ end
163
+
164
+ def normalize_api_key_id(value)
165
+ return nil if value.nil?
166
+
167
+ normalized = value.to_s
168
+ valid = normalized.present? && normalized.valid_encoding? &&
169
+ normalized.bytesize <= MAX_KEY_ID_BYTESIZE &&
170
+ normalized.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
171
+ raise ArgumentError, "API key ID is invalid" unless valid
172
+
173
+ normalized
174
+ rescue ArgumentError
175
+ raise ArgumentError, "API key ID is invalid"
176
+ end
177
+
178
+ def token_encryptor
179
+ application = Rails.application if defined?(Rails) && Rails.respond_to?(:application)
180
+ unless application&.respond_to?(:key_generator)
181
+ raise ArgumentError, "Rails.application.key_generator is required for secure token handoff"
182
+ end
183
+
184
+ key_length = ActiveSupport::MessageEncryptor.key_len(ENCRYPTION_CIPHER)
185
+ encryption_key = application.key_generator.generate_key(ENCRYPTION_SALT, key_length)
186
+ ActiveSupport::MessageEncryptor.new(
187
+ encryption_key,
188
+ cipher: ENCRYPTION_CIPHER,
189
+ serializer: JSON_SERIALIZER
190
+ )
191
+ end
192
+
193
+ def valid_token_payload?(token)
194
+ token.is_a?(String) && token.present? && token.valid_encoding? &&
195
+ token.bytesize <= MAX_TOKEN_BYTESIZE &&
196
+ token.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
197
+ rescue ArgumentError
198
+ false
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end