ask-guests 0.1.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.
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Guests
5
+ # Transfers everything a guest session owns to a real owner, exactly once.
6
+ #
7
+ # Ask::Guests.claimable(:work_requests) do |session, owner|
8
+ # WorkRequest.where(guest_session_id: session.id)
9
+ # .update_all(owner_id: owner.id, guest_session_id: nil)
10
+ # end
11
+ #
12
+ # Claim.new(session: guest, owner: account_user).call # => true
13
+ # Claim.new(session: guest, owner: account_user).call # => false (already claimed)
14
+ #
15
+ # Claiming is race-safe: the session is re-read under a lock (when the
16
+ # store supports it) inside the store's transaction, and the converted
17
+ # flag is written in the same transaction as the record transfer. A
18
+ # double-submit or a replayed cookie can never claim twice.
19
+ class Claim
20
+ def initialize(session:, owner:, store: Guests.store, claimables: Guests.claimables, clock: Guests.configuration.clock)
21
+ @session = session
22
+ @owner = owner
23
+ @store = store
24
+ @claimables = claimables
25
+ @clock = clock
26
+ end
27
+
28
+ def call
29
+ return false if @session.nil? || @owner.nil?
30
+ return false if @session.converted?
31
+
32
+ claimed = false
33
+ @store.transaction do
34
+ current = @store.lock(@session.id)
35
+ break if current.nil? || current.converted?
36
+
37
+ @claimables.each_value { |handler| handler.call(current, @owner) }
38
+ current.claimed_by!(@owner)
39
+ @store.update(current)
40
+
41
+ # Reflect the claim on the caller's object so it sees the new state.
42
+ @session.claimed_by!(@owner)
43
+ claimed = true
44
+ end
45
+ claimed
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Guests
5
+ class Error < StandardError; end
6
+
7
+ # Raised when a token must be signed or verified but no secret is
8
+ # configured.
9
+ class MissingSecret < Error
10
+ def initialize(message = "Ask::Guests.secret is not configured. Set it in Ask::Guests.configure.")
11
+ super
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Guests
5
+ module Rails
6
+ # Claims the visitor's guest records right after they authenticate.
7
+ #
8
+ # class Users::RegistrationsController < Devise::RegistrationsController
9
+ # include Ask::Guests::Rails::ClaimOnAuthentication
10
+ #
11
+ # after_action :claim_guest_session, only: :create
12
+ #
13
+ # private
14
+ #
15
+ # def guest_claim_owner
16
+ # current_user.account_users.first # or wherever records should land
17
+ # end
18
+ # end
19
+ #
20
+ # It is a no-op when there is no unclaimed guest cookie. On success it
21
+ # flashes the configured notice, deletes the guest cookie, and lets the
22
+ # authentication flow continue.
23
+ module ClaimOnAuthentication
24
+ extend ActiveSupport::Concern
25
+ include Ask::Guests::Rails::Controller
26
+
27
+ private
28
+
29
+ def claim_guest_session
30
+ session = resume_guest_session
31
+ return if session.nil? || session.converted?
32
+
33
+ # The host defines #guest_claim_owner (often on a shared auth
34
+ # concern); we only check for it so this module never shadows it.
35
+ unless respond_to?(:guest_claim_owner, true)
36
+ raise NotImplementedError,
37
+ "#{self.class} must implement #guest_claim_owner to use ClaimOnAuthentication"
38
+ end
39
+
40
+ owner = guest_claim_owner
41
+ return if owner.nil?
42
+
43
+ if Guests::Claim.new(session: session, owner: owner).call
44
+ flash[:notice] ||= Guests.configuration.claim_notice
45
+ end_guest_session
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Guests
5
+ module Rails
6
+ # Controller support for guest access.
7
+ #
8
+ # class Kawifiles::WorkRequestsController < ApplicationController
9
+ # include Ask::Guests::Rails::Controller
10
+ #
11
+ # allow_guest_access
12
+ # before_action :require_visitor
13
+ # end
14
+ #
15
+ # +allow_guest_access+ resumes the visitor's guest session from its
16
+ # signed cookie on every request. +require_visitor+ guarantees an
17
+ # actor: an authenticated user when your controller reports one
18
+ # (it responds to +user_signed_in?+ or +signed_in?+), otherwise a
19
+ # guest session, created on the spot.
20
+ #
21
+ # The current session is available to views through +guest_session?+
22
+ # and +current_guest_session+ (registered as helper methods in Rails).
23
+ module Controller
24
+ extend ActiveSupport::Concern
25
+
26
+ included do
27
+ if respond_to?(:helper_method)
28
+ helper_method :guest_session?, :current_guest_session
29
+ end
30
+ end
31
+
32
+ class_methods do
33
+ # Resume the guest session from the cookie for the actions this is
34
+ # applied to. Pair with #require_visitor on guest-capable actions.
35
+ def allow_guest_access(**options)
36
+ before_action :resume_guest_session, **options
37
+ end
38
+ end
39
+
40
+ def current_guest_session
41
+ @current_guest_session
42
+ end
43
+
44
+ def guest_session?
45
+ !current_guest_session.nil?
46
+ end
47
+
48
+ private
49
+
50
+ # Guarantees an actor for the action: a signed-in user when the
51
+ # controller has one, otherwise a guest session.
52
+ def require_visitor
53
+ return if visitor_authenticated?
54
+
55
+ ensure_guest_session
56
+ end
57
+
58
+ def ensure_guest_session
59
+ resume_guest_session || start_guest_session!
60
+ end
61
+
62
+ # Returns the session from the signed cookie, or nil. Torn or
63
+ # tampered cookies and swept sessions resolve to nil — the next
64
+ # require_visitor mints a fresh session.
65
+ def resume_guest_session
66
+ return @current_guest_session if @current_guest_session
67
+
68
+ token = cookies.signed[Guests.configuration.cookie_name]
69
+ id = Guests::Token.verify(token, secret: Guests.secret)
70
+ return nil if id.nil?
71
+
72
+ session = Guests.store.find(id)
73
+ return nil if session.nil?
74
+
75
+ touch_guest_session(session)
76
+ @current_guest_session = session
77
+ end
78
+
79
+ def start_guest_session!
80
+ session = Guests.store.create(metadata: guest_request_metadata)
81
+ token = Guests::Token.sign(session.id, secret: Guests.secret)
82
+ cookies.signed.permanent[Guests.configuration.cookie_name] = {
83
+ value: token,
84
+ httponly: true,
85
+ same_site: :lax
86
+ }
87
+ @current_guest_session = session
88
+ end
89
+
90
+ # Retires the cookie. The converted session row stays for audit;
91
+ # a later anonymous visit starts a clean session.
92
+ def end_guest_session
93
+ cookies.delete(Guests.configuration.cookie_name)
94
+ @current_guest_session = nil
95
+ end
96
+
97
+ # Records activity, throttled to once per touch interval so ordinary
98
+ # page views don't write on every request.
99
+ def touch_guest_session(session)
100
+ now = Guests.configuration.now
101
+ last = session.last_seen_at
102
+ return session if last && (now - last) < Guests.configuration.touch_interval
103
+
104
+ session.touch_seen!(now)
105
+ Guests.store.update(session)
106
+ session
107
+ end
108
+
109
+ def visitor_authenticated?
110
+ if respond_to?(:user_signed_in?, true)
111
+ user_signed_in?
112
+ elsif respond_to?(:signed_in?, true)
113
+ signed_in?
114
+ else
115
+ false
116
+ end
117
+ end
118
+
119
+ def guest_request_metadata
120
+ metadata = {}
121
+ return metadata unless respond_to?(:request, true) && request
122
+
123
+ metadata[:ip_address] = request.remote_ip if request.respond_to?(:remote_ip)
124
+ metadata[:user_agent] = request.user_agent if request.respond_to?(:user_agent)
125
+ metadata
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module Ask
6
+ module Guests
7
+ module Rails
8
+ # Destroys stale unclaimed guest sessions on a schedule. Wire it into
9
+ # your recurring jobs config (Solid Queue, Sidekiq, etc.):
10
+ #
11
+ # production:
12
+ # guests_sweep:
13
+ # class: "Ask::Guests::Rails::SweepJob"
14
+ # schedule: every day at 4am
15
+ class SweepJob < ActiveJob::Base
16
+ queue_as { Ask::Guests.configuration.sweep_queue }
17
+
18
+ def perform
19
+ Ask::Guests.sweep.call
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Guests
5
+ # A guest session value object: identity, lifecycle timestamps, the
6
+ # owner it was claimed by, and the counters used for usage metering.
7
+ #
8
+ # Stores return these objects (never raw rows); adapters translate
9
+ # between the value object and their persistence layer.
10
+ class Session
11
+ attr_reader :id, :created_at, :last_seen_at, :converted_at,
12
+ :owner_type, :owner_id, :counters, :metadata
13
+
14
+ def initialize(id:, created_at: nil, last_seen_at: nil, converted_at: nil,
15
+ owner_type: nil, owner_id: nil, counters: {}, metadata: {})
16
+ @id = id
17
+ @created_at = created_at
18
+ @last_seen_at = last_seen_at
19
+ @converted_at = converted_at
20
+ @owner_type = owner_type
21
+ @owner_id = owner_id
22
+ @counters = stringify(counters)
23
+ @metadata = stringify(metadata)
24
+ end
25
+
26
+ def converted?
27
+ !@converted_at.nil?
28
+ end
29
+
30
+ def unclaimed?
31
+ !converted?
32
+ end
33
+
34
+ # Marks the session as claimed by +owner+ (any object responding to
35
+ # +id+; polymorphic name used when available). The caller persists the
36
+ # session through the store.
37
+ def claimed_by!(owner)
38
+ @owner_type = owner_type_for(owner)
39
+ @owner_id = owner.id
40
+ @converted_at = @converted_at || Guests.configuration.now
41
+ self
42
+ end
43
+
44
+ def touch_seen!(at = Guests.configuration.now)
45
+ @last_seen_at = at
46
+ self
47
+ end
48
+
49
+ # Raw counter value for +name+ on +date+ ("YYYY-MM-DD"). Counters roll
50
+ # over daily: a value recorded on another day reads as 0.
51
+ def counter_value(name, date)
52
+ counter = @counters[name.to_s]
53
+ return 0 unless counter.is_a?(Hash)
54
+ return 0 unless counter["on"] == date.to_s
55
+
56
+ counter["count"].to_i
57
+ end
58
+
59
+ def set_counter!(name, count, date)
60
+ @counters[name.to_s] = {"count" => count.to_i, "on" => date.to_s}
61
+ self
62
+ end
63
+
64
+ def to_h
65
+ {
66
+ id: @id,
67
+ created_at: @created_at,
68
+ last_seen_at: @last_seen_at,
69
+ converted_at: @converted_at,
70
+ owner_type: @owner_type,
71
+ owner_id: @owner_id,
72
+ counters: @counters,
73
+ metadata: @metadata
74
+ }
75
+ end
76
+
77
+ def ==(other)
78
+ other.is_a?(Session) && other.id.to_s == id.to_s
79
+ end
80
+ alias_method :eql?, :==
81
+
82
+ def hash
83
+ id.to_s.hash
84
+ end
85
+
86
+ private
87
+
88
+ def owner_type_for(owner)
89
+ if owner.respond_to?(:class) && owner.class.respond_to?(:polymorphic_name)
90
+ owner.class.polymorphic_name
91
+ else
92
+ owner.class.name
93
+ end
94
+ end
95
+
96
+ def stringify(hash)
97
+ (hash || {}).each_with_object({}) do |(key, value), result|
98
+ result[key.to_s] = value
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Ask
6
+ module Guests
7
+ module Stores
8
+ # Persistence port for guest sessions. Implement this to plug in any
9
+ # backend (ActiveRecord ships in ask-guests/active_record), or use the
10
+ # in-memory store for tests and single-process apps.
11
+ #
12
+ # Implementations must return {Guests::Session} value objects and treat
13
+ # ids as opaque strings.
14
+ class Base
15
+ # @param attributes [Hash] initial session attributes
16
+ # @return [Session]
17
+ def create(attributes = {})
18
+ raise NotImplementedError
19
+ end
20
+
21
+ # @param id [String, Object]
22
+ # @return [Session, nil]
23
+ def find(id)
24
+ raise NotImplementedError
25
+ end
26
+
27
+ # Persists a session returned by {#create}/{#find} after mutation.
28
+ # @param session [Session]
29
+ # @return [Session]
30
+ def update(session)
31
+ raise NotImplementedError
32
+ end
33
+
34
+ # @param session [Session]
35
+ def destroy(session)
36
+ raise NotImplementedError
37
+ end
38
+
39
+ # Destroys unclaimed sessions last seen (or created) before the
40
+ # cutoff, along with anything they own.
41
+ # @param before [Time]
42
+ # @return [Integer] number of sessions destroyed
43
+ def sweep_stale(before:)
44
+ raise NotImplementedError
45
+ end
46
+
47
+ # Re-reads the session under a lock when the backend supports row
48
+ # locking. Defaults to a plain read; override for race-safe claiming.
49
+ # @return [Session, nil]
50
+ def lock(id)
51
+ find(id)
52
+ end
53
+
54
+ # Runs the block in a transaction when supported.
55
+ def transaction
56
+ yield
57
+ end
58
+
59
+ def supports_transactions?
60
+ false
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Ask
6
+ module Guests
7
+ module Stores
8
+ # In-memory session store. Useful for tests and single-process apps;
9
+ # sessions do not survive process restarts.
10
+ class Memory < Base
11
+ def initialize
12
+ @sessions = {}
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def create(attributes = {})
17
+ attributes = attributes.transform_keys(&:to_sym)
18
+ id = (attributes.delete(:id) || SecureRandom.uuid).to_s
19
+ now = Guests.configuration.now
20
+
21
+ session = Session.new(
22
+ id: id,
23
+ created_at: now,
24
+ last_seen_at: now,
25
+ **attributes
26
+ )
27
+
28
+ @mutex.synchronize { @sessions[id] = session }
29
+ session
30
+ end
31
+
32
+ def find(id)
33
+ return nil if id.nil?
34
+
35
+ @mutex.synchronize { @sessions[id.to_s]&.dup }
36
+ end
37
+
38
+ def update(session)
39
+ @mutex.synchronize { @sessions[session.id.to_s] = session }
40
+ session
41
+ end
42
+
43
+ def destroy(session)
44
+ @mutex.synchronize { @sessions.delete(session.id.to_s) }
45
+ session
46
+ end
47
+
48
+ def sweep_stale(before:)
49
+ stale = @mutex.synchronize do
50
+ @sessions.values.select { |s| s.unclaimed? && last_activity(s) < before }
51
+ end
52
+ stale.each { |session| destroy(session) }
53
+ stale.size
54
+ end
55
+
56
+ # Test helper: number of stored sessions.
57
+ def size
58
+ @mutex.synchronize { @sessions.size }
59
+ end
60
+
61
+ private
62
+
63
+ def last_activity(session)
64
+ session.last_seen_at || session.created_at || Time.at(0)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Guests
5
+ # Deletes unclaimed guest sessions (and, via the store, everything they
6
+ # own) once they have been inactive for the configured retention period.
7
+ # Keeps anonymous traffic from accumulating storage and personal data
8
+ # forever.
9
+ #
10
+ # Ask::Guests.sweep.call # => number of sessions destroyed
11
+ class Sweep
12
+ def initialize(store: Guests.store, retention: Guests.configuration.retention, clock: Guests.configuration.clock)
13
+ @store = store
14
+ @retention = retention
15
+ @clock = clock
16
+ end
17
+
18
+ def call
19
+ @store.sweep_stale(before: @clock.call - @retention)
20
+ end
21
+
22
+ # Cutoff time before which sessions are stale.
23
+ def cutoff
24
+ @clock.call - @retention
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "openssl"
5
+
6
+ module Ask
7
+ module Guests
8
+ # Signs and verifies guest session tokens with HMAC-SHA256.
9
+ #
10
+ # A token embeds only the session id — a random opaque value assigned by
11
+ # the store — and a signature. Sessions are looked up in the store by id,
12
+ # so a token for a swept or converted session simply resolves to nothing,
13
+ # and there is nothing to enumerate without the secret.
14
+ #
15
+ # token = Token.sign(session.id, secret: "s3cr3t")
16
+ # Token.verify(token, secret: "s3cr3t") # => "the-session-id"
17
+ # Token.verify(tampered, secret: "s3cr3t") # => nil
18
+ module Token
19
+ SEPARATOR = "--"
20
+
21
+ class << self
22
+ def sign(id, secret: Guests.secret)
23
+ raise MissingSecret if secret.nil? || secret.empty?
24
+
25
+ payload = Base64.urlsafe_encode64(id.to_s, padding: false)
26
+ "#{payload}#{SEPARATOR}#{digest(payload, secret)}"
27
+ end
28
+
29
+ # Returns the session id for a valid token, nil for anything else
30
+ # (nil, empty, malformed, or wrong signature). Never raises for bad
31
+ # input — callers treat nil as "no session".
32
+ def verify(token, secret: Guests.secret)
33
+ return nil if token.nil? || secret.nil? || secret.empty?
34
+ return nil if token.to_s.empty?
35
+
36
+ payload, separator, signature = token.to_s.rpartition(SEPARATOR)
37
+ return nil if separator.empty? || payload.empty? || signature.empty?
38
+ return nil unless secure_compare(signature, digest(payload, secret))
39
+
40
+ Base64.urlsafe_decode64(payload)
41
+ rescue ArgumentError
42
+ nil
43
+ end
44
+
45
+ private
46
+
47
+ def digest(payload, secret)
48
+ OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, payload)
49
+ end
50
+
51
+ def secure_compare(a, b)
52
+ return false unless a.bytesize == b.bytesize
53
+
54
+ OpenSSL.fixed_length_secure_compare(a, b)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Guests
5
+ VERSION = "0.1.0"
6
+ end
7
+ end