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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 47d3aa99bde7a4fba72632740871294a132d06a492d679c78d5487daa5857506
4
+ data.tar.gz: 6f937c5d724bd72b036faed46a1490129b37ee59380da63d8ba781b2f243b9db
5
+ SHA512:
6
+ metadata.gz: 7691132899688d326b7014fd010a7887a107958e717ea1da66b7640fe2fb4b4ace845d0f6c5db23f483904ff13e00d19fe4510b69539a2b1ad91a0cf18c9a429
7
+ data.tar.gz: 7667b6be0f7ab4634020198f0e30566ea1b3338b79747017c3c7460e8910ca664bbf7222c41902f013d0a161e84481df09bf35b1e69c610ca34756e7c701b72a
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] — 2026-09-15
4
+
5
+ ### Added
6
+
7
+ - **Guest sessions** — durable anonymous identity: a random session id behind
8
+ an HMAC-SHA256 signed token (`Ask::Guests::Token`), with pluggable stores
9
+ (`Stores::Memory`, plus an ActiveRecord adapter).
10
+ - **Metered AI usage** — daily, composable budget policies
11
+ (`Budget::TurnLimit`, `Budget::TokenQuota`, `Budget::Unlimited`) that check
12
+ before a turn and record after; counters roll over daily with no scheduled
13
+ work.
14
+ - **Single-use claiming** — register handlers with `Ask::Guests.claimable`;
15
+ `Ask::Guests::Claim` transfers every guest-owned record to a real owner in
16
+ one transaction, re-reading the session under a lock so replayed cookies
17
+ and double-submits can never claim twice.
18
+ - **Retention** — `Ask::Guests::Sweep` destroys unclaimed sessions inactive
19
+ past the configured window (seconds); `Rails::SweepJob` schedules it.
20
+ - **Rails adapter** (`ask-guests/rails`) — `Rails::Controller` concern
21
+ (`allow_guest_access`, `require_visitor`, `guest_session?`),
22
+ `Rails::ClaimOnAuthentication` for sign-up/sign-in hooks, and the install
23
+ generator (`bin/rails generate ask:guests:install`).
24
+ - **ActiveRecord adapter** (`ask-guests/active_record`) — `SessionStore` and
25
+ an abstract `SessionBase` model with an adapter-independent JSON column
26
+ type (PostgreSQL json/jsonb and SQLite text behave identically) and
27
+ polymorphic owner columns.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaka Ruto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,195 @@
1
+ # ask-guests
2
+
3
+ Anonymous, cookie-backed guest access for ask-rb apps. Give visitors a
4
+ durable identity *before* they sign up — with metered AI usage, and a
5
+ single-use claim that moves everything they own into a real account the
6
+ moment they register.
7
+
8
+ Four concepts, one gem:
9
+
10
+ | Concept | What it does |
11
+ |---|---|
12
+ | **Identity** | A random session id behind an HMAC-signed token. No fake `User` rows, nothing to enumerate without the secret. |
13
+ | **Ownership** | Hosts register claimable handlers; records owned by a guest carry its session id. |
14
+ | **Metering** | Daily turn/token budgets (composable policies) with automatic rollover — bound anonymous AI spend. |
15
+ | **Claim** | On sign-up/sign-in, transfer all guest records in one transaction, exactly once. |
16
+
17
+ Plus retention: unclaimed sessions are swept after a configurable window.
18
+
19
+ The core is framework-agnostic (stdlib only). Adapters plug in persistence
20
+ and web frameworks:
21
+
22
+ ```ruby
23
+ require "ask-guests" # core: sessions, budgets, claims, sweep
24
+ require "ask-guests/active_record" # ActiveRecord store + model base
25
+ require "ask-guests/rails" # controller concern, auth hook, sweep job
26
+ ```
27
+
28
+ ## Installation
29
+
30
+ ```ruby
31
+ gem "ask-guests"
32
+ ```
33
+
34
+ In a Rails app, run the installer:
35
+
36
+ ```bash
37
+ bin/rails generate ask:guests:install
38
+ ```
39
+
40
+ It creates the `guest_sessions` migration and an initializer, and prints
41
+ the model and controller wiring.
42
+
43
+ ## Core usage
44
+
45
+ ```ruby
46
+ Ask::Guests.configure do |config|
47
+ config.secret = ENV.fetch("GUEST_TOKEN_SECRET")
48
+ config.store = Ask::Guests::Stores::Memory.new
49
+ config.policies = [Ask::Guests::Budget::TurnLimit.new(per_day: 40)]
50
+ config.retention = 14 * 24 * 60 * 60 # seconds
51
+ end
52
+
53
+ # Mint a session and hand the visitor a token (put it in a cookie yourself,
54
+ # or use the Rails adapter):
55
+ session = Ask::Guests.store.create(metadata: {ip_address: request.ip})
56
+ token = Ask::Guests::Token.sign(session.id)
57
+ cookies[:guest] = token
58
+
59
+ # Resolve a returning visitor:
60
+ id = Ask::Guests::Token.verify(cookies[:guest]) # => session id or nil
61
+ session = Ask::Guests.store.find(id) # => Session or nil
62
+ ```
63
+
64
+ ### Metering AI usage
65
+
66
+ The budget is checked before a turn and recorded after — works with any
67
+ agent runtime, including ask-agent streams:
68
+
69
+ ```ruby
70
+ budget = Ask::Guests.budget
71
+
72
+ unless budget.allowed?(guest_session)
73
+ return render_quota_exceeded # "create an account to keep going"
74
+ end
75
+
76
+ response = agent.run(prompt)
77
+ budget.record!(guest_session, turns: 1, tokens: response.input_tokens + response.output_tokens)
78
+ ```
79
+
80
+ Policies compose — a guest is allowed only while every policy has budget
81
+ left, and counters roll over daily with no scheduled work:
82
+
83
+ ```ruby
84
+ config.policies = [
85
+ Ask::Guests::Budget::TurnLimit.new(per_day: 40),
86
+ Ask::Guests::Budget::TokenQuota.new(per_day: 10_000)
87
+ ]
88
+ ```
89
+
90
+ ### Claiming on sign-up
91
+
92
+ Register one handler per owned model. Handlers run inside the claim's
93
+ transaction, after the session has been re-read under a lock — a replayed
94
+ cookie or double-submit can never claim twice:
95
+
96
+ ```ruby
97
+ Ask::Guests.claimable(:work_requests) do |session, owner|
98
+ WorkRequest.where(guest_session_id: session.id)
99
+ .update_all(account_id: owner.account_id, account_user_id: owner.id, guest_session_id: nil)
100
+ end
101
+
102
+ Ask::Guests::Claim.new(session: guest_session, owner: account_user).call # => true
103
+ ```
104
+
105
+ ### Retention
106
+
107
+ ```ruby
108
+ Ask::Guests.sweep.call # destroys unclaimed sessions inactive past retention
109
+ ```
110
+
111
+ ## Rails
112
+
113
+ ```ruby
114
+ # app/models/guest_session.rb
115
+ class GuestSession < Ask::Guests::ActiveRecord::SessionBase
116
+ self.table_name = "guest_sessions"
117
+ has_many :work_requests, foreign_key: :guest_session_id, dependent: :destroy
118
+ end
119
+
120
+ # config/initializers/ask_guests.rb
121
+ Ask::Guests.configure do |config|
122
+ config.secret = Rails.application.secret_key_base
123
+ config.store = Ask::Guests::ActiveRecord::SessionStore.new(model: GuestSession)
124
+ config.policies = [Ask::Guests::Budget::TurnLimit.new(per_day: 40)]
125
+ end
126
+ ```
127
+
128
+ Controllers:
129
+
130
+ ```ruby
131
+ class WorkRequestsController < ApplicationController
132
+ include Ask::Guests::Rails::Controller
133
+
134
+ allow_guest_access # resume the cookie on every request
135
+ before_action :require_visitor # user or guest — never nobody
136
+ end
137
+ ```
138
+
139
+ Views get `guest_session?` and `current_guest_session` as helpers.
140
+
141
+ Claim on authentication (Devise shown; any auth works):
142
+
143
+ ```ruby
144
+ class Users::RegistrationsController < Devise::RegistrationsController
145
+ include Ask::Guests::Rails::ClaimOnAuthentication
146
+
147
+ after_action :claim_guest_session, only: :create
148
+
149
+ private
150
+
151
+ def guest_claim_owner = current_user.account_users.first
152
+ end
153
+ ```
154
+
155
+ Schedule the sweep (`config/recurring.yml` for Solid Queue):
156
+
157
+ ```yaml
158
+ production:
159
+ guests_sweep:
160
+ class: "Ask::Guests::Rails::SweepJob"
161
+ schedule: every day at 4am
162
+ ```
163
+
164
+ Destroying a session destroys its row, so associations declared with
165
+ `dependent: :destroy` sweep everything the guest owned.
166
+
167
+ ## Custom stores
168
+
169
+ Implement `Ask::Guests::Stores::Base` (`create`, `find`, `update`,
170
+ `destroy`, `sweep_stale`, and optionally `lock`/`transaction`) and point
171
+ `config.store` at it. Stores return `Ask::Guests::Session` value objects
172
+ and treat ids as opaque strings.
173
+
174
+ ## Hard-won details
175
+
176
+ - **Budget is checked before anything is persisted** — a rejected turn
177
+ leaves no trace, so "create an account to continue" is honest.
178
+ - **Capabilities stay server-side.** Metering bounds cost; what a guest
179
+ *cannot do* (submit work, trigger notifications) belongs in your tool
180
+ implementations, not the prompt.
181
+ - **`config.secret` rotation** invalidates outstanding guest cookies —
182
+ visitors simply start fresh sessions.
183
+ - **Touch throttling**: `last_seen_at` is written at most once per
184
+ `touch_interval` (default 300s) so page views don't write per request.
185
+
186
+ ## Development
187
+
188
+ ```bash
189
+ bundle install
190
+ bundle exec rake test
191
+ ```
192
+
193
+ ## License
194
+
195
+ MIT.
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ask
6
+ module Guests
7
+ module ActiveRecord
8
+ # Adapter-independent JSON attribute type.
9
+ #
10
+ # ActiveRecord's built-in +:json+ attribute type resolves against the
11
+ # database adapter at class-definition time (so the model cannot even
12
+ # be loaded before a connection exists, and behavior differs between
13
+ # PostgreSQL jsonb and SQLite text). This type does the same job with
14
+ # deterministic behavior everywhere:
15
+ #
16
+ # * reads Hash (PostgreSQL jsonb) as-is
17
+ # * parses String (SQLite text) into a Hash
18
+ # * writes a JSON string
19
+ class JsonValue < ActiveModel::Type::Value
20
+ def type
21
+ :json
22
+ end
23
+
24
+ def cast(value)
25
+ normalize(value)
26
+ end
27
+
28
+ def deserialize(value)
29
+ normalize(value)
30
+ end
31
+
32
+ def serialize(value)
33
+ normalize(value).to_json
34
+ end
35
+
36
+ private
37
+
38
+ def normalize(value)
39
+ case value
40
+ when nil then nil
41
+ when String then parse(value)
42
+ else value
43
+ end
44
+ end
45
+
46
+ def parse(string)
47
+ return nil if string.strip.empty?
48
+
49
+ JSON.parse(string)
50
+ rescue JSON::ParserError
51
+ nil
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Guests
5
+ module ActiveRecord
6
+ # Abstract ActiveRecord model for guest sessions. Subclass it in the
7
+ # host app (usually via the install generator), mapping it to the
8
+ # table created by the migration:
9
+ #
10
+ # class GuestSession < Ask::Guests::ActiveRecord::SessionBase
11
+ # self.table_name = "guest_sessions"
12
+ # has_many :work_requests, foreign_key: :guest_session_id, dependent: :destroy
13
+ # end
14
+ #
15
+ # Expected columns:
16
+ # counters - json/jsonb, daily usage counters ("{}" default)
17
+ # metadata - json/jsonb, free-form host data ("{}" default)
18
+ # last_seen_at - datetime, updated on visitor activity
19
+ # converted_at - datetime, set on claim; NULL means still a guest
20
+ # owner_type - string, polymorphic owner set on claim
21
+ # owner_id - bigint, polymorphic owner set on claim
22
+ class SessionBase < ::ActiveRecord::Base
23
+ self.abstract_class = true
24
+
25
+ # The gem owns these columns' semantics; use the adapter-independent
26
+ # JSON type so PostgreSQL json/jsonb and SQLite text columns behave
27
+ # identically (and the model loads without a database connection).
28
+ attribute :counters, JsonValue.new, default: {}
29
+ attribute :metadata, JsonValue.new, default: {}
30
+
31
+ belongs_to :owner, polymorphic: true, optional: true
32
+
33
+ def converted?
34
+ !converted_at.nil?
35
+ end
36
+
37
+ def unclaimed?
38
+ !converted?
39
+ end
40
+
41
+ # @return [Ask::Guests::Session]
42
+ def to_guest_session
43
+ Guests::Session.new(
44
+ id: id,
45
+ created_at: created_at,
46
+ last_seen_at: last_seen_at,
47
+ converted_at: converted_at,
48
+ owner_type: owner_type,
49
+ owner_id: owner_id,
50
+ counters: counters || {},
51
+ metadata: metadata || {}
52
+ )
53
+ end
54
+
55
+ # Writes a value object's state back onto this record (without
56
+ # saving). Used by SessionStore#update.
57
+ def apply_guest_session(session)
58
+ self.counters = session.counters
59
+ self.metadata = session.metadata
60
+ self.last_seen_at = session.last_seen_at
61
+ self.converted_at = session.converted_at
62
+ self.owner_type = session.owner_type
63
+ self.owner_id = session.owner_id
64
+ self
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Guests
5
+ module ActiveRecord
6
+ # ActiveRecord-backed session store.
7
+ #
8
+ # class GuestSession < Ask::Guests::ActiveRecord::SessionBase
9
+ # self.table_name = "guest_sessions"
10
+ # has_many :work_requests, foreign_key: :guest_session_id, dependent: :destroy
11
+ # end
12
+ #
13
+ # Ask::Guests.configure do |config|
14
+ # config.store = Ask::Guests::ActiveRecord::SessionStore.new(model: GuestSession)
15
+ # end
16
+ #
17
+ # Destroying a session destroys the record, so host associations
18
+ # declared with +dependent: :destroy+ sweep owned records too.
19
+ class SessionStore < Stores::Base
20
+ attr_reader :model
21
+
22
+ # @param model [Class] an ActiveRecord model class with the columns
23
+ # documented on SessionBase
24
+ def initialize(model:)
25
+ @model = model
26
+ end
27
+
28
+ def create(attributes = {})
29
+ attributes = attributes.to_h.transform_keys(&:to_sym)
30
+ now = Guests.configuration.now
31
+ record = model.new(
32
+ counters: attributes[:counters] || {},
33
+ metadata: attributes[:metadata] || {},
34
+ last_seen_at: attributes[:last_seen_at] || now,
35
+ created_at: attributes[:created_at] || now
36
+ )
37
+ record.id = attributes[:id] if attributes[:id]
38
+ record.save!
39
+ record.to_guest_session
40
+ end
41
+
42
+ def find(id)
43
+ return nil if id.nil?
44
+
45
+ model.find_by(id: id)&.to_guest_session
46
+ end
47
+
48
+ # Re-reads the session with a row lock where the database supports
49
+ # it, for race-safe claiming.
50
+ def lock(id)
51
+ return nil if id.nil?
52
+
53
+ scope = model.lock
54
+ scope.find_by(id: id)&.to_guest_session
55
+ end
56
+
57
+ def update(session)
58
+ record = model.find_by(id: session.id)
59
+ return session if record.nil?
60
+
61
+ record.apply_guest_session(session)
62
+ record.save!
63
+ session
64
+ end
65
+
66
+ def destroy(session)
67
+ model.find_by(id: session.id)&.destroy
68
+ session
69
+ end
70
+
71
+ def sweep_stale(before:)
72
+ destroyed = 0
73
+ stale_scope(before).find_each do |record|
74
+ record.destroy
75
+ destroyed += 1
76
+ end
77
+ destroyed
78
+ end
79
+
80
+ def transaction(&block)
81
+ model.transaction(&block)
82
+ end
83
+
84
+ def supports_transactions?
85
+ true
86
+ end
87
+
88
+ private
89
+
90
+ def stale_scope(before)
91
+ model.where(converted_at: nil)
92
+ .where("COALESCE(last_seen_at, created_at) < ?", before)
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Guests
5
+ class Budget
6
+ # Base class for usage policies. A policy owns one counter key (for
7
+ # example "turns" or "tokens") and a daily limit for it.
8
+ #
9
+ # Subclasses implement +counter_key+, +limit+, and +amount_for+.
10
+ class Policy
11
+ # @return [String] counter name on the session
12
+ def counter_key
13
+ raise NotImplementedError
14
+ end
15
+
16
+ # @return [Integer, Float::INFINITY] daily limit
17
+ def limit
18
+ raise NotImplementedError
19
+ end
20
+
21
+ # @return [Integer] how much this usage costs in counter units
22
+ def amount_for(turns:, tokens:)
23
+ raise NotImplementedError
24
+ end
25
+
26
+ # @return [Integer, Float] remaining budget for today
27
+ def remaining(session, today)
28
+ value = session.counter_value(counter_key, today)
29
+ [limit - value, 0].max
30
+ end
31
+
32
+ def record!(session, turns:, tokens:, today:)
33
+ amount = amount_for(turns: turns, tokens: tokens)
34
+ return session if amount.to_i.zero?
35
+
36
+ count = session.counter_value(counter_key, today) + amount.to_i
37
+ session.set_counter!(counter_key, count, today)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Guests
5
+ class Budget
6
+ # Limits tokens per day, for apps that meter actual consumption
7
+ # (input + output tokens) instead of turns.
8
+ #
9
+ # Budget::TokenQuota.new(per_day: 10_000)
10
+ class TokenQuota < Policy
11
+ attr_reader :per_day
12
+
13
+ def initialize(per_day:)
14
+ super()
15
+ @per_day = per_day.to_i
16
+ end
17
+
18
+ def counter_key = "tokens"
19
+ def limit = per_day
20
+ def amount_for(turns:, tokens:) = tokens
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Guests
5
+ class Budget
6
+ # Limits agent turns per day. One "turn" is one agent run — the unit
7
+ # Kawibot meters.
8
+ #
9
+ # Budget::TurnLimit.new(per_day: 40)
10
+ class TurnLimit < Policy
11
+ attr_reader :per_day
12
+
13
+ def initialize(per_day:)
14
+ super()
15
+ @per_day = per_day.to_i
16
+ end
17
+
18
+ def counter_key = "turns"
19
+ def limit = per_day
20
+ def amount_for(turns:, tokens:) = turns
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Guests
5
+ class Budget
6
+ # No limits — the default policy. Useful for development and for apps
7
+ # that meter elsewhere.
8
+ class Unlimited < Policy
9
+ def counter_key = "unlimited"
10
+ def limit = Float::INFINITY
11
+ def amount_for(turns:, tokens:) = 0
12
+ def remaining(session, today) = Float::INFINITY
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Guests
5
+ # Meters guest usage against one or more policies and records usage
6
+ # afterwards. Policies are checked collectively: a guest is allowed only
7
+ # while every policy has budget remaining.
8
+ #
9
+ # budget = Ask::Guests.budget
10
+ # budget.allowed?(session) # => true/false
11
+ # budget.remaining(session) # => Integer (minimum across policies)
12
+ # budget.record!(session, turns: 1, tokens: 850)
13
+ #
14
+ # Counters live on the session and roll over daily, so "per day" limits
15
+ # reset without any scheduled work.
16
+ class Budget
17
+ attr_reader :policies, :store
18
+
19
+ def initialize(policies: nil, store: Guests.store, clock: Guests.configuration.clock)
20
+ @policies = normalize(policies)
21
+ @store = store
22
+ @clock = clock
23
+ end
24
+
25
+ # @return [Boolean] true while every policy has budget left
26
+ def allowed?(session)
27
+ return true if session.nil?
28
+
29
+ policies.all? { |policy| policy.remaining(session, today) > 0 }
30
+ end
31
+
32
+ # Minimum remaining budget across policies (Float::INFINITY when no
33
+ # policy limits usage).
34
+ def remaining(session)
35
+ return Float::INFINITY if session.nil?
36
+
37
+ policies.map { |policy| policy.remaining(session, today) }.min
38
+ end
39
+
40
+ # Records usage, persists the session through the store, and returns it.
41
+ def record!(session, turns: 0, tokens: 0)
42
+ return session if session.nil?
43
+
44
+ policies.each { |policy| policy.record!(session, turns: turns, tokens: tokens, today: today) }
45
+ store.update(session)
46
+ session
47
+ end
48
+
49
+ def unlimited?
50
+ policies.all? { |policy| policy.remaining(Session.new(id: "probe"), today) == Float::INFINITY }
51
+ end
52
+
53
+ private
54
+
55
+ def normalize(policies)
56
+ case policies
57
+ when nil then [Budget::Unlimited.new]
58
+ when Array then policies
59
+ else [policies]
60
+ end
61
+ end
62
+
63
+ def today
64
+ @clock.call.utc.strftime("%Y-%m-%d")
65
+ end
66
+ end
67
+ end
68
+ end