api_keys 0.2.0 → 0.3.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/.simplecov +36 -0
  3. data/AGENTS.md +5 -0
  4. data/Appraisals +17 -0
  5. data/CHANGELOG.md +14 -0
  6. data/CLAUDE.md +5 -0
  7. data/README.md +767 -3
  8. data/Rakefile +9 -4
  9. data/app/controllers/api_keys/keys_controller.rb +43 -11
  10. data/app/controllers/api_keys/security_controller.rb +9 -1
  11. data/app/views/api_keys/keys/_empty_state.html.erb +9 -0
  12. data/app/views/api_keys/keys/_form.html.erb +31 -2
  13. data/app/views/api_keys/keys/_key_actions.html.erb +20 -0
  14. data/app/views/api_keys/keys/_key_badges.html.erb +17 -0
  15. data/app/views/api_keys/keys/_key_row.html.erb +21 -35
  16. data/app/views/api_keys/keys/_key_status.html.erb +10 -0
  17. data/app/views/api_keys/keys/_keys_table.html.erb +2 -7
  18. data/app/views/api_keys/keys/_publishable_keys.html.erb +40 -0
  19. data/app/views/api_keys/keys/_secret_keys.html.erb +39 -0
  20. data/app/views/api_keys/keys/_show_token.html.erb +5 -1
  21. data/app/views/api_keys/keys/_token_display.html.erb +11 -0
  22. data/app/views/api_keys/keys/index.html.erb +40 -8
  23. data/app/views/api_keys/security/best_practices.html.erb +73 -47
  24. data/app/views/layouts/api_keys/application.html.erb +113 -7
  25. data/context7.json +4 -0
  26. data/gemfiles/rails_7.2.gemfile +21 -0
  27. data/gemfiles/rails_8.0.gemfile +21 -0
  28. data/gemfiles/rails_8.1.gemfile +21 -0
  29. data/lib/api_keys/configuration.rb +77 -0
  30. data/lib/api_keys/errors.rb +73 -0
  31. data/lib/api_keys/form_builder_extensions.rb +158 -0
  32. data/lib/api_keys/helpers/expiration_options.rb +131 -0
  33. data/lib/api_keys/helpers/token_session.rb +68 -0
  34. data/lib/api_keys/helpers/view_helpers.rb +216 -0
  35. data/lib/api_keys/models/api_key.rb +229 -17
  36. data/lib/api_keys/models/concerns/has_api_keys.rb +183 -3
  37. data/lib/api_keys/services/authenticator.rb +45 -2
  38. data/lib/api_keys/services/digestor.rb +6 -2
  39. data/lib/api_keys/tenant_resolution.rb +3 -1
  40. data/lib/api_keys/version.rb +1 -1
  41. data/lib/api_keys.rb +12 -0
  42. data/lib/generators/api_keys/add_key_types_generator.rb +68 -0
  43. data/lib/generators/api_keys/templates/add_key_types_to_api_keys.rb.erb +18 -0
  44. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +9 -0
  45. data/lib/generators/api_keys/templates/initializer.rb +242 -120
  46. metadata +24 -58
@@ -0,0 +1,158 @@
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] The key type (:publishable, :secret, or nil)
114
+ # - :environment [String, nil] The environment (e.g., "live", "test")
115
+ #
116
+ # @example
117
+ # <% data = form.api_key_token_data %>
118
+ # <code><%= data[:masked] %></code>
119
+ # <% if data[:viewable] %>
120
+ # <button data-token="<%= data[:full] %>">Copy</button>
121
+ # <% end %>
122
+ #
123
+ def api_key_token_data
124
+ return {} unless object.respond_to?(:masked_token)
125
+
126
+ {
127
+ masked: object.masked_token,
128
+ full: object.viewable_token,
129
+ viewable: object.respond_to?(:public_key_type?) && object.public_key_type?,
130
+ type: object.key_type&.to_sym,
131
+ environment: object.environment
132
+ }
133
+ end
134
+
135
+ private
136
+
137
+ def resolve_checked_scopes(scopes, checked)
138
+ case checked
139
+ when :all
140
+ scopes.map(&:to_s)
141
+ when :none
142
+ []
143
+ when Array
144
+ checked.map(&:to_s)
145
+ when nil
146
+ # For new records, default to all checked
147
+ # For existing records, use the object's scopes
148
+ if object&.persisted?
149
+ (object.scopes || []).map(&:to_s)
150
+ else
151
+ scopes.map(&:to_s)
152
+ end
153
+ else
154
+ scopes.map(&:to_s)
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,131 @@
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
+ ].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") # => nil
69
+ #
70
+ def parse(preset)
71
+ return nil if preset.blank? || preset == "no_expiration"
72
+
73
+ # Try to extract days from the preset string (e.g., "30_days" => 30)
74
+ if preset =~ /\A(\d+)_days?\z/
75
+ days = ::Regexp.last_match(1).to_i
76
+ # Reasonable max: ~10 years to prevent overflow issues
77
+ return days.days.from_now if days.positive? && days <= 3650
78
+ end
79
+
80
+ # Check against known presets
81
+ known = DEFAULT_PRESETS.find { |p| p[:value] == preset }
82
+ return known[:days].days.from_now if known && known[:days]
83
+
84
+ nil
85
+ end
86
+
87
+ # Returns the default preset value (useful for form defaults)
88
+ #
89
+ # @return [String] The default preset value
90
+ def default_value
91
+ "no_expiration"
92
+ end
93
+
94
+ private
95
+
96
+ def filter_default_presets(include_no_expiration)
97
+ if include_no_expiration
98
+ DEFAULT_PRESETS
99
+ else
100
+ DEFAULT_PRESETS.reject { |p| p[:value] == "no_expiration" }
101
+ end
102
+ end
103
+
104
+ def build_custom_presets(days_list, include_no_expiration)
105
+ options = []
106
+
107
+ if include_no_expiration
108
+ options << { label: "No Expiration", value: "no_expiration", days: nil }
109
+ end
110
+
111
+ days_list.each do |days|
112
+ options << preset_for_days(days)
113
+ end
114
+
115
+ options
116
+ end
117
+
118
+ def preset_for_days(days)
119
+ label = case days
120
+ when 1 then "1 day"
121
+ when 365 then "1 year"
122
+ when ->(d) { d % 365 == 0 } then "#{days / 365} years"
123
+ else "#{days} days"
124
+ end
125
+
126
+ { label: label, value: "#{days}_days", days: days }
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiKeys
4
+ module Helpers
5
+ # Helper for managing API key tokens in the session.
6
+ #
7
+ # Secret keys can only be shown once (immediately after creation) because
8
+ # the plaintext token is not stored in the database. This helper provides
9
+ # a clean interface for the "show token once" pattern:
10
+ #
11
+ # 1. After creating a key, store the token in the session
12
+ # 2. On the success page, retrieve (and clear) the token
13
+ # 3. If the user refreshes, the token is gone
14
+ #
15
+ # @example In your controller
16
+ # # After creating a key:
17
+ # def create
18
+ # @api_key = current_org.create_api_key!(...)
19
+ # ApiKeys::Helpers::TokenSession.store(session, @api_key)
20
+ # redirect_to success_path
21
+ # end
22
+ #
23
+ # # On the success page:
24
+ # def success
25
+ # @token = ApiKeys::Helpers::TokenSession.retrieve_once(session)
26
+ # redirect_to index_path, alert: "Token already shown" unless @token
27
+ # end
28
+ #
29
+ class TokenSession
30
+ # Default session key for storing the token
31
+ DEFAULT_SESSION_KEY = :api_keys_new_token
32
+
33
+ class << self
34
+ # Store an API key's token in the session for later retrieval.
35
+ #
36
+ # @param session [ActionDispatch::Request::Session] The Rails session
37
+ # @param api_key [ApiKeys::ApiKey] The newly created API key
38
+ # @param key [Symbol] Optional custom session key (default: :api_keys_new_token)
39
+ # @return [String] The token that was stored
40
+ def store(session, api_key, key: DEFAULT_SESSION_KEY)
41
+ token = api_key.respond_to?(:token) ? api_key.token : api_key.to_s
42
+ session[key] = token
43
+ token
44
+ end
45
+
46
+ # Retrieve and clear the token from the session.
47
+ # Returns nil if no token is stored (e.g., page was refreshed).
48
+ #
49
+ # @param session [ActionDispatch::Request::Session] The Rails session
50
+ # @param key [Symbol] Optional custom session key (default: :api_keys_new_token)
51
+ # @return [String, nil] The token, or nil if not present
52
+ def retrieve_once(session, key: DEFAULT_SESSION_KEY)
53
+ session.delete(key)
54
+ end
55
+
56
+ # Check if a token is available in the session without removing it.
57
+ # Useful for conditional rendering.
58
+ #
59
+ # @param session [ActionDispatch::Request::Session] The Rails session
60
+ # @param key [Symbol] Optional custom session key (default: :api_keys_new_token)
61
+ # @return [Boolean] true if a token is stored
62
+ def available?(session, key: DEFAULT_SESSION_KEY)
63
+ session[key].present?
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiKeys
4
+ module Helpers
5
+ # View helpers for displaying API key information.
6
+ #
7
+ # These helpers provide formatted data without HTML opinions,
8
+ # allowing integrators to build their own UI while using
9
+ # consistent data formatting.
10
+ #
11
+ # @example Include in your ApplicationHelper
12
+ # module ApplicationHelper
13
+ # include ApiKeys::Helpers::ViewHelpers
14
+ # end
15
+ #
16
+ # @example Or include in a specific controller
17
+ # class Settings::ApiKeysController < ApplicationController
18
+ # helper ApiKeys::Helpers::ViewHelpers
19
+ # end
20
+ #
21
+ module ViewHelpers
22
+ # Returns the status of an API key as a symbol.
23
+ #
24
+ # @param api_key [ApiKeys::ApiKey] The API key
25
+ # @return [Symbol] :active, :expired, or :revoked
26
+ #
27
+ # @example
28
+ # api_key_status(@key) # => :active
29
+ #
30
+ def api_key_status(api_key)
31
+ return :revoked if api_key.revoked?
32
+ return :expired if api_key.expired?
33
+
34
+ :active
35
+ end
36
+
37
+ # Returns a human-readable status label for an API key.
38
+ #
39
+ # @param api_key [ApiKeys::ApiKey] The API key
40
+ # @return [String] "Active", "Expired", or "Revoked"
41
+ #
42
+ # @example
43
+ # api_key_status_label(@key) # => "Active"
44
+ #
45
+ def api_key_status_label(api_key)
46
+ case api_key_status(api_key)
47
+ when :active then "Active"
48
+ when :expired then "Expired"
49
+ when :revoked then "Revoked"
50
+ end
51
+ end
52
+
53
+ # Returns the environment label for an API key.
54
+ #
55
+ # @param api_key [ApiKeys::ApiKey] The API key
56
+ # @return [String] "Test", "Live", or "Default" (if no environment set)
57
+ #
58
+ # @example
59
+ # api_key_environment_label(@key) # => "Live"
60
+ #
61
+ def api_key_environment_label(api_key)
62
+ return "Default" if api_key.environment.blank?
63
+
64
+ api_key.environment.to_s.capitalize
65
+ end
66
+
67
+ # Returns the key type label for an API key.
68
+ #
69
+ # @param api_key [ApiKeys::ApiKey] The API key
70
+ # @return [String] "Publishable", "Secret", or the key_type capitalized
71
+ #
72
+ # @example
73
+ # api_key_type_label(@key) # => "Secret"
74
+ #
75
+ def api_key_type_label(api_key)
76
+ return "Secret" if api_key.key_type.blank?
77
+
78
+ case api_key.key_type.to_s
79
+ when "publishable" then "Publishable"
80
+ when "secret" then "Secret"
81
+ else api_key.key_type.to_s.capitalize
82
+ end
83
+ end
84
+
85
+ # Returns whether the key is a publishable (public) key type.
86
+ # Useful for conditional rendering (e.g., showing/hiding copy button).
87
+ #
88
+ # @param api_key [ApiKeys::ApiKey] The API key
89
+ # @return [Boolean] true if the key is publishable
90
+ #
91
+ # @example
92
+ # <% if api_key_publishable?(@key) %>
93
+ # <%= button_tag "Copy", data: { token: @key.viewable_token } %>
94
+ # <% end %>
95
+ #
96
+ def api_key_publishable?(api_key)
97
+ api_key.key_type.to_s == "publishable"
98
+ end
99
+
100
+ # Returns whether the key is a secret key type.
101
+ #
102
+ # @param api_key [ApiKeys::ApiKey] The API key
103
+ # @return [Boolean] true if the key is secret (or legacy with no type)
104
+ #
105
+ def api_key_secret?(api_key)
106
+ api_key.key_type.blank? || api_key.key_type.to_s == "secret"
107
+ end
108
+
109
+ # Detects the environment from a token string by parsing its prefix.
110
+ # Useful for displaying environment info when you only have the token.
111
+ #
112
+ # @param token [String] The full token string (e.g., "sk_test_abc123...")
113
+ # @return [Symbol, nil] The detected environment (:test, :live, etc.) or nil
114
+ #
115
+ # @example
116
+ # api_key_environment_from_token("sk_test_abc123") # => :test
117
+ # api_key_environment_from_token("pk_live_xyz789") # => :live
118
+ # api_key_environment_from_token("ak_abc123") # => nil
119
+ #
120
+ def api_key_environment_from_token(token)
121
+ return nil if token.blank?
122
+ return nil if token.length > 500 # Reasonable max length for tokens
123
+
124
+ config = ApiKeys.configuration
125
+ return nil unless config.environments.present?
126
+
127
+ # Check each configured environment's prefix segment
128
+ config.environments.each do |env_name, env_config|
129
+ segment = env_config[:prefix_segment]
130
+ next if segment.blank?
131
+
132
+ # Match pattern like _test_ or _live_ in the token
133
+ if token.include?("_#{segment}_")
134
+ return env_name
135
+ end
136
+ end
137
+
138
+ nil
139
+ end
140
+
141
+ # Returns a human-readable environment label from a token string.
142
+ # Convenience wrapper around api_key_environment_from_token.
143
+ #
144
+ # @param token [String] The full token string
145
+ # @return [String] "Test mode", "Live mode", or "Default" if unknown
146
+ #
147
+ # @example
148
+ # api_key_environment_label_from_token("sk_test_abc") # => "Test mode"
149
+ # api_key_environment_label_from_token("sk_live_xyz") # => "Live mode"
150
+ #
151
+ def api_key_environment_label_from_token(token)
152
+ env = api_key_environment_from_token(token)
153
+ return "Default" if env.nil?
154
+
155
+ "#{env.to_s.capitalize} mode"
156
+ end
157
+
158
+ # Returns a hash of status information for an API key.
159
+ # Useful for building custom status badges.
160
+ #
161
+ # @param api_key [ApiKeys::ApiKey] The API key
162
+ # @return [Hash] Hash with :status, :label, and :color keys
163
+ #
164
+ # @example
165
+ # info = api_key_status_info(@key)
166
+ # # => { status: :active, label: "Active", color: :green }
167
+ #
168
+ def api_key_status_info(api_key)
169
+ status = api_key_status(api_key)
170
+ {
171
+ status: status,
172
+ label: api_key_status_label(api_key),
173
+ color: status_color(status)
174
+ }
175
+ end
176
+
177
+ # Returns a hash of type information for an API key.
178
+ # Useful for building custom type badges.
179
+ #
180
+ # @param api_key [ApiKeys::ApiKey] The API key
181
+ # @return [Hash] Hash with :type, :label, and :color keys
182
+ #
183
+ # @example
184
+ # info = api_key_type_info(@key)
185
+ # # => { type: :publishable, label: "Publishable", color: :green }
186
+ #
187
+ def api_key_type_info(api_key)
188
+ type = api_key.key_type.presence&.to_sym || :secret
189
+ {
190
+ type: type,
191
+ label: api_key_type_label(api_key),
192
+ color: type_color(type)
193
+ }
194
+ end
195
+
196
+ private
197
+
198
+ def status_color(status)
199
+ case status
200
+ when :active then :green
201
+ when :expired then :red
202
+ when :revoked then :gray
203
+ else :gray
204
+ end
205
+ end
206
+
207
+ def type_color(type)
208
+ case type
209
+ when :publishable then :green
210
+ when :secret then :amber
211
+ else :gray
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end