masks 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (126) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +50 -0
  4. data/Rakefile +11 -0
  5. data/app/assets/builds/application.css +4764 -0
  6. data/app/assets/builds/application.js +8236 -0
  7. data/app/assets/builds/application.js.map +7 -0
  8. data/app/assets/builds/masks/application.css +1 -0
  9. data/app/assets/builds/masks/application.js +7122 -0
  10. data/app/assets/builds/masks/application.js.map +7 -0
  11. data/app/assets/images/masks.png +0 -0
  12. data/app/assets/javascripts/application.js +2 -0
  13. data/app/assets/javascripts/controllers/application.js +9 -0
  14. data/app/assets/javascripts/controllers/emails_controller.js +28 -0
  15. data/app/assets/javascripts/controllers/index.js +12 -0
  16. data/app/assets/javascripts/controllers/keys_controller.js +20 -0
  17. data/app/assets/javascripts/controllers/recover_controller.js +21 -0
  18. data/app/assets/javascripts/controllers/recover_password_controller.js +21 -0
  19. data/app/assets/javascripts/controllers/session_controller.js +94 -0
  20. data/app/assets/manifest.js +2 -0
  21. data/app/assets/masks_manifest.js +2 -0
  22. data/app/assets/stylesheets/application.css +26 -0
  23. data/app/controllers/concerns/masks/controller.rb +114 -0
  24. data/app/controllers/masks/actors_controller.rb +15 -0
  25. data/app/controllers/masks/application_controller.rb +35 -0
  26. data/app/controllers/masks/backup_codes_controller.rb +34 -0
  27. data/app/controllers/masks/debug_controller.rb +9 -0
  28. data/app/controllers/masks/devices_controller.rb +20 -0
  29. data/app/controllers/masks/emails_controller.rb +60 -0
  30. data/app/controllers/masks/error_controller.rb +14 -0
  31. data/app/controllers/masks/keys_controller.rb +45 -0
  32. data/app/controllers/masks/manage/actor_controller.rb +35 -0
  33. data/app/controllers/masks/manage/actors_controller.rb +12 -0
  34. data/app/controllers/masks/manage/base_controller.rb +12 -0
  35. data/app/controllers/masks/one_time_code_controller.rb +49 -0
  36. data/app/controllers/masks/passwords_controller.rb +33 -0
  37. data/app/controllers/masks/recoveries_controller.rb +43 -0
  38. data/app/controllers/masks/sessions_controller.rb +53 -0
  39. data/app/helpers/masks/application_helper.rb +49 -0
  40. data/app/jobs/masks/application_job.rb +7 -0
  41. data/app/jobs/masks/expire_actors_job.rb +15 -0
  42. data/app/jobs/masks/expire_recoveries_job.rb +15 -0
  43. data/app/mailers/masks/actor_mailer.rb +22 -0
  44. data/app/mailers/masks/application_mailer.rb +15 -0
  45. data/app/models/concerns/masks/access.rb +162 -0
  46. data/app/models/concerns/masks/actor.rb +132 -0
  47. data/app/models/concerns/masks/adapter.rb +68 -0
  48. data/app/models/concerns/masks/role.rb +9 -0
  49. data/app/models/concerns/masks/scoped.rb +54 -0
  50. data/app/models/masks/access/actor_password.rb +20 -0
  51. data/app/models/masks/access/actor_scopes.rb +18 -0
  52. data/app/models/masks/access/actor_signup.rb +22 -0
  53. data/app/models/masks/actors/anonymous.rb +40 -0
  54. data/app/models/masks/actors/system.rb +24 -0
  55. data/app/models/masks/adapters/active_record.rb +85 -0
  56. data/app/models/masks/application_model.rb +15 -0
  57. data/app/models/masks/application_record.rb +8 -0
  58. data/app/models/masks/check.rb +192 -0
  59. data/app/models/masks/credential.rb +166 -0
  60. data/app/models/masks/credentials/backup_code.rb +30 -0
  61. data/app/models/masks/credentials/device.rb +59 -0
  62. data/app/models/masks/credentials/email.rb +48 -0
  63. data/app/models/masks/credentials/factor2.rb +71 -0
  64. data/app/models/masks/credentials/key.rb +38 -0
  65. data/app/models/masks/credentials/last_login.rb +12 -0
  66. data/app/models/masks/credentials/masquerade.rb +32 -0
  67. data/app/models/masks/credentials/nickname.rb +63 -0
  68. data/app/models/masks/credentials/one_time_code.rb +34 -0
  69. data/app/models/masks/credentials/password.rb +28 -0
  70. data/app/models/masks/credentials/recovery.rb +71 -0
  71. data/app/models/masks/credentials/session.rb +67 -0
  72. data/app/models/masks/device.rb +30 -0
  73. data/app/models/masks/error.rb +51 -0
  74. data/app/models/masks/event.rb +14 -0
  75. data/app/models/masks/mask.rb +255 -0
  76. data/app/models/masks/rails/actor.rb +190 -0
  77. data/app/models/masks/rails/actor_role.rb +12 -0
  78. data/app/models/masks/rails/device.rb +47 -0
  79. data/app/models/masks/rails/email.rb +96 -0
  80. data/app/models/masks/rails/key.rb +61 -0
  81. data/app/models/masks/rails/recovery.rb +116 -0
  82. data/app/models/masks/rails/role.rb +20 -0
  83. data/app/models/masks/rails/scope.rb +15 -0
  84. data/app/models/masks/session.rb +447 -0
  85. data/app/models/masks/sessions/access.rb +26 -0
  86. data/app/models/masks/sessions/inline.rb +16 -0
  87. data/app/models/masks/sessions/request.rb +42 -0
  88. data/app/resources/masks/actor_resource.rb +9 -0
  89. data/app/resources/masks/session_resource.rb +15 -0
  90. data/app/views/layouts/masks/application.html.erb +17 -0
  91. data/app/views/layouts/masks/mailer.html.erb +17 -0
  92. data/app/views/layouts/masks/mailer.text.erb +1 -0
  93. data/app/views/layouts/masks/manage.html.erb +25 -0
  94. data/app/views/masks/actor_mailer/recover_credentials.html.erb +33 -0
  95. data/app/views/masks/actor_mailer/recover_credentials.text.erb +1 -0
  96. data/app/views/masks/actor_mailer/verify_email.html.erb +34 -0
  97. data/app/views/masks/actor_mailer/verify_email.text.erb +8 -0
  98. data/app/views/masks/actors/current.html.erb +152 -0
  99. data/app/views/masks/application/_header.html.erb +31 -0
  100. data/app/views/masks/backup_codes/new.html.erb +103 -0
  101. data/app/views/masks/emails/new.html.erb +103 -0
  102. data/app/views/masks/emails/verify.html.erb +51 -0
  103. data/app/views/masks/keys/new.html.erb +127 -0
  104. data/app/views/masks/manage/actor/show.html.erb +126 -0
  105. data/app/views/masks/manage/actors/index.html.erb +40 -0
  106. data/app/views/masks/one_time_code/new.html.erb +150 -0
  107. data/app/views/masks/passwords/edit.html.erb +58 -0
  108. data/app/views/masks/recoveries/new.html.erb +71 -0
  109. data/app/views/masks/recoveries/password.html.erb +64 -0
  110. data/app/views/masks/sessions/new.html.erb +153 -0
  111. data/config/brakeman.ignore +28 -0
  112. data/config/locales/en.yml +286 -0
  113. data/config/routes.rb +46 -0
  114. data/db/migrate/20231205173845_create_actors.rb +94 -0
  115. data/lib/generators/masks/install/USAGE +8 -0
  116. data/lib/generators/masks/install/install_generator.rb +33 -0
  117. data/lib/generators/masks/install/templates/initializer.rb +5 -0
  118. data/lib/generators/masks/install/templates/masks.json +6 -0
  119. data/lib/masks/configuration.rb +236 -0
  120. data/lib/masks/engine.rb +25 -0
  121. data/lib/masks/middleware.rb +70 -0
  122. data/lib/masks/version.rb +5 -0
  123. data/lib/masks.rb +183 -0
  124. data/lib/tasks/masks_tasks.rake +71 -0
  125. data/masks.json +274 -0
  126. metadata +416 -0
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks for a known, valid :device.
6
+ #
7
+ # If the device is not associated with the session's actor, it will be.
8
+ # Identification is based on the +user_agent+ and a few other facets.
9
+ class Device < Masks::Credential
10
+ checks :device
11
+
12
+ def lookup
13
+ return unless actor
14
+
15
+ device = config.find_device(session, actor:)
16
+
17
+ session.extras(device:)
18
+
19
+ nil
20
+ end
21
+
22
+ def maskup
23
+ device = session.extra(:device)
24
+
25
+ return deny! unless device
26
+
27
+ session_key = session.data[:device_key]
28
+
29
+ # ensure devices match across sessions, which would only happen if a
30
+ # session cookie happened is shared across machines. this destroys
31
+ # the entire session and cleans up everything involved.
32
+ if session_key && device.session_key != session_key
33
+ raise "invalid device"
34
+ end
35
+
36
+ # store devices that are found in a database of known devices
37
+ if device.known?
38
+ session.data[:device_key] = device.session_key
39
+ actor.devices << device
40
+ approve!
41
+ else
42
+ cleanup
43
+ deny!
44
+ end
45
+ end
46
+
47
+ def backup
48
+ session.extra(:device)&.touch(:accessed_at) if session&.passed?
49
+ end
50
+
51
+ def cleanup
52
+ device = session.extra(:device)
53
+ device&.reset_version
54
+
55
+ session.data[:device_key] = nil
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks for an :actor given a matching email.
6
+ class Email < Masks::Credential
7
+ checks :actor
8
+
9
+ def lookup
10
+ return if actor || !email
11
+
12
+ actor = config.find_actor(session, email:)
13
+ actor ||=
14
+ config.build_actor(
15
+ session,
16
+ email:,
17
+ nickname: generate_nickname(email)
18
+ )
19
+ actor.signup = true
20
+ actor
21
+ end
22
+
23
+ def maskup
24
+ approve! if actor && email && valid?
25
+ end
26
+
27
+ private
28
+
29
+ def email
30
+ @email ||=
31
+ [session_params[:email], session_params[:nickname]].find do |param|
32
+ ValidateEmail.valid?(param)
33
+ end
34
+ end
35
+
36
+ def generate_nickname(email)
37
+ return email if !nickname_format || nickname_format.match?(email)
38
+
39
+ parts = email.split("@")
40
+ name = parts[0].gsub(/[^\w\d]/, "")
41
+
42
+ prefix_nickname(
43
+ "#{name.downcase.slice(0, 16)}#{SecureRandom.hex.slice(0, 8)}"
44
+ )
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Base class for factor2 credentials.
6
+ module Factor2
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ validates :secret, presence: true, if: :enabled?
11
+ validates :code, presence: { allow_nil: true }
12
+
13
+ attribute :code
14
+
15
+ checks :factor2
16
+ end
17
+
18
+ def lookup
19
+ # nothing to do here
20
+ end
21
+
22
+ def maskup
23
+ check.optional = false if actor&.factor2?
24
+
25
+ return unless enabled?
26
+
27
+ code_param = session_params&.fetch(param, nil)&.presence
28
+
29
+ self.code = verify(code_param) if code_param
30
+
31
+ if code
32
+ approve!
33
+ elsif code_param
34
+ deny!
35
+ end
36
+ end
37
+
38
+ def verified?
39
+ code
40
+ end
41
+
42
+ def enabled?
43
+ secret.present?
44
+ end
45
+
46
+ def param
47
+ raise NotImplementedError
48
+ end
49
+
50
+ def secret
51
+ raise NotImplementedError
52
+ end
53
+
54
+ def enable(code, secret:)
55
+ raise NotImplementedError
56
+ end
57
+
58
+ def verify(code)
59
+ raise NotImplementedError
60
+ end
61
+
62
+ def verify_on_enable?
63
+ false
64
+ end
65
+
66
+ def generate_secret
67
+ nil
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks :key given a valid Authorization header.
6
+ class Key < Masks::Credential
7
+ checks :key
8
+
9
+ attribute :accessed
10
+
11
+ def lookup
12
+ secret = session.request.authorization&.split&.last
13
+ key = session.config.find_key(session, secret:)
14
+
15
+ return unless key
16
+
17
+ session.extras(key:)
18
+ session.scoped = key
19
+ self.accessed = true
20
+ key.actor
21
+ end
22
+
23
+ def maskup
24
+ key = session.extra(:key)
25
+
26
+ if key&.actor == session&.actor && session.scoped == key
27
+ approve!
28
+ else
29
+ deny!
30
+ end
31
+ end
32
+
33
+ def backup
34
+ session.scoped.touch(:accessed_at) if session&.passed? && accessed
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # As of now, simply keeps track of +last_login+ times on the actor.
6
+ class LastLogin < Masks::Credential
7
+ def backup
8
+ actor&.touch(:last_login_at) if session&.passed?
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks for an :actor to masquerade as.
6
+ class Masquerade < Masks::Credential
7
+ checks :actor
8
+
9
+ def lookup
10
+ return if session.actor
11
+
12
+ value = session.data[:as]
13
+
14
+ @loaded =
15
+ case value
16
+ when Masks::ANON
17
+ Actors::Anonymous.new(session:) if session.mask&.allow_anonymous?
18
+ when session.mask.actor_scope
19
+ value
20
+ when ValidateEmail.valid?(value)
21
+ config.find_actor(session, email: value)
22
+ when String
23
+ config.find_actor(session, nickname: prefix_nickname(value))
24
+ end
25
+ end
26
+
27
+ def maskup
28
+ approve! if @loaded
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks for an :actor given a matching nickname.
6
+ class Nickname < Masks::Credential
7
+ checks :actor
8
+
9
+ delegate :actor_id, :nickname, to: :actor, allow_nil: true
10
+
11
+ validates :nickname, :actor, presence: true
12
+ validate :validates_custom, if: :nickname
13
+
14
+ def lookup
15
+ return if actor
16
+
17
+ actor =
18
+ (
19
+ if nickname_param
20
+ config.find_actor(session, nickname: nickname_param)
21
+ end
22
+ )
23
+ actor ||=
24
+ if nickname_param
25
+ config
26
+ .build_actor(session, nickname: nickname_param)
27
+ .tap { |actor| actor.signup = true }
28
+ end
29
+
30
+ if actor&.new_record?
31
+ actor.nickname =
32
+ prefix_nickname(actor.nickname, default: actor.nickname)
33
+ end
34
+
35
+ actor
36
+ end
37
+
38
+ def maskup
39
+ approve! if valid?
40
+ end
41
+
42
+ private
43
+
44
+ def nickname_param
45
+ @nickname_param ||=
46
+ prefix_nickname(
47
+ session_params[:nickname],
48
+ default: session_params[:nickname]
49
+ )
50
+ end
51
+
52
+ def validates_custom
53
+ return unless nickname
54
+
55
+ validates_length :nickname, nickname_config&.length
56
+
57
+ return unless nickname_format
58
+
59
+ errors.add(:nickname, :format) unless nickname_format.match?(nickname)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks :factor2 for a valid one-time code.
6
+ class OneTimeCode < Masks::Credential
7
+ include Factor2
8
+
9
+ private
10
+
11
+ def param
12
+ :one_time_code
13
+ end
14
+
15
+ def secret
16
+ actor&.totp_secret
17
+ end
18
+
19
+ def verify(code)
20
+ valid_code?(code, secret)
21
+ end
22
+
23
+ def valid_code?(code, secret)
24
+ if code && secret
25
+ actor.totp.verify(code)
26
+ else
27
+ false
28
+ end
29
+ rescue OpenSSL::HMACError
30
+ false
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks :password for a match.
6
+ class Password < Masks::Credential
7
+ checks :password
8
+
9
+ def lookup
10
+ actor.password = password if actor&.new_record? && password
11
+
12
+ nil
13
+ end
14
+
15
+ def maskup
16
+ return unless password
17
+
18
+ actor&.authenticate(password) && actor&.valid? ? approve! : deny!
19
+ end
20
+
21
+ private
22
+
23
+ def password
24
+ session_params[:password]
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks for a :recovery on the session.
6
+ class Recovery < Masks::Credential
7
+ checks :recovery
8
+
9
+ def lookup
10
+ return if actor
11
+
12
+ @recovery =
13
+ if recovery_key
14
+ config.find_recovery(session, token: recovery_key)
15
+ else
16
+ config.build_recovery(
17
+ session,
18
+ nickname: nickname_param,
19
+ email: email_param,
20
+ phone: phone_param,
21
+ value: recovery_value
22
+ )
23
+ end
24
+
25
+ session.extras(recovery: @recovery)
26
+
27
+ @recovery&.actor
28
+ end
29
+
30
+ def maskup
31
+ return unless valid? && actor
32
+
33
+ if recovery_key
34
+ if recovery_password && @recovery&.valid?
35
+ @recovery.reset_password!(recovery_password)
36
+ approve!
37
+ end
38
+ elsif recovery_value && @recovery&.valid?
39
+ @recovery.notify!
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def recovery_value
46
+ params.dig(:recover, :value) if writable?
47
+ end
48
+
49
+ def recovery_key
50
+ params[:token]
51
+ end
52
+
53
+ def recovery_password
54
+ params.dig(:recover, :password) if writable?
55
+ end
56
+
57
+ def phone_param
58
+ @phone_param ||= Phonelib.valid?(recovery_value) ? recovery_value : nil
59
+ end
60
+
61
+ def email_param
62
+ @email_param ||=
63
+ ValidateEmail.valid?(recovery_value) ? recovery_value : nil
64
+ end
65
+
66
+ def nickname_param
67
+ @nickname_param ||= prefix_nickname(recovery_value, default: nil)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks for past :actor(s) on a session.
6
+ #
7
+ # This can be used in lieu of supplying identifying details like an
8
+ # email/nickname (usually provided they were supplied in the past).
9
+ class Session < Masks::Credential
10
+ checks :actor
11
+
12
+ def lookup
13
+ return if session_params[:actor_id]
14
+
15
+ actor_ids = session.data[:actors]&.keys || []
16
+ actor_id = session.data[:actor]
17
+ actors = (actor_ids.any? ? config.find_actors(session, actor_ids) : [])
18
+
19
+ # only lookup and return the current actor if
20
+ # it's not provided via a param (e.g. someone
21
+ # is trying to login)
22
+ actor =
23
+ if actor_id
24
+ actors.find do |a|
25
+ a.actor_id == actor_id &&
26
+ a.session_key == session.data[:actors][a.actor_id]
27
+ end
28
+ end
29
+
30
+ actor = Actors::Anonymous.new(session:) if optional? && !actors.present?
31
+
32
+ actor
33
+ end
34
+
35
+ def maskup
36
+ return approve! if optional? && actor&.anonymous?
37
+
38
+ actor_id = actor&.actor_id
39
+
40
+ return unless actor_id && session.data[:actors]&.fetch(actor_id, nil)
41
+ return unless session.data[:actor] == actor_id
42
+
43
+ if session.data.dig(:actors, actor_id) == actor.session_key
44
+ approve!
45
+ else
46
+ cleanup
47
+ end
48
+ end
49
+
50
+ def backup
51
+ return unless actor && passed?
52
+
53
+ session.data[:actors] ||= {}
54
+ session.data[:actors][actor.actor_id] = actor.session_key
55
+ session.data[:actor] = actor.actor_id
56
+ end
57
+
58
+ def cleanup
59
+ actor_id = actor&.actor_id || session.data[:actor]
60
+
61
+ session.data[:actor] = nil
62
+ session.data[:actors] ||= {}
63
+ session.data[:actors].delete(actor_id)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ # Represents a device, used for interacting with a session.
5
+ #
6
+ # Device detection is optionally added with the +Device+ credential,
7
+ # using the +device-detector+ gem.
8
+ #
9
+ # @see Masks::Credentials::Device Masks::Credentials::Device
10
+ class Device < ApplicationModel
11
+ attribute :session
12
+
13
+ def fingerprint
14
+ return unless detector.known?
15
+
16
+ input = [
17
+ detector.name,
18
+ detector.os_name,
19
+ detector.device_name,
20
+ detector.device_type
21
+ ].compact.join("-")
22
+
23
+ Digest::SHA512.hexdigest(input)
24
+ end
25
+
26
+ def detector
27
+ @detector ||= DeviceDetector.new(session.user_agent)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Error
5
+ # Base class for Masks errors
6
+ class Base < RuntimeError
7
+ end
8
+
9
+ # Thrown when a session is required but not provided
10
+ class InvalidSession < Base
11
+ def initialize
12
+ super("a session was expected, but none was provided")
13
+ end
14
+ end
15
+
16
+ # Thrown when invalid configuration is detected
17
+ class InvalidConfiguration < Base
18
+ def initialize(value)
19
+ super("cannot use '#{value}' for masks.json")
20
+ end
21
+ end
22
+
23
+ # Thrown when configuration is not found
24
+ class ConfigurationNotFound < Base
25
+ def initialize
26
+ super("cannot find masks.json")
27
+ end
28
+ end
29
+
30
+ # Thrown when Masks encounters an unauthorized session
31
+ class Unauthorized < Base
32
+ def initialize
33
+ super("unauthorized")
34
+ end
35
+ end
36
+
37
+ # Thrown when Masks cannot find a mask for a session
38
+ class UnknownMask < Base
39
+ def initialize(session)
40
+ super("could not determine mask for #{session}")
41
+ end
42
+ end
43
+
44
+ # Thrown when Masks cannot find an access class for the given name
45
+ class UnknownAccess < Base
46
+ def initialize(name)
47
+ super("could not determine access class for #{name}")
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ # Publishes events using +ActiveSupport::Notifications+
5
+ class Event
6
+ class << self
7
+ def emit(name, **opts, &block)
8
+ ActiveSupport::Notifications.instrument("masks.#{name}", **opts) do
9
+ block.call
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end