sessions 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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +61 -0
  3. data/.simplecov +54 -0
  4. data/AGENTS.md +5 -0
  5. data/Appraisals +26 -0
  6. data/CHANGELOG.md +26 -0
  7. data/CLAUDE.md +5 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +454 -0
  10. data/Rakefile +32 -0
  11. data/app/assets/stylesheets/sessions.css +50 -0
  12. data/app/controllers/sessions/application_controller.rb +159 -0
  13. data/app/controllers/sessions/devices_controller.rb +48 -0
  14. data/app/helpers/sessions/engine_helper.rb +126 -0
  15. data/app/views/sessions/_device.html.erb +40 -0
  16. data/app/views/sessions/_devices.html.erb +34 -0
  17. data/app/views/sessions/_event.html.erb +13 -0
  18. data/app/views/sessions/_history.html.erb +20 -0
  19. data/app/views/sessions/devices/history.html.erb +5 -0
  20. data/app/views/sessions/devices/index.html.erb +15 -0
  21. data/config/locales/en.yml +59 -0
  22. data/config/locales/es.yml +59 -0
  23. data/config/routes.rb +17 -0
  24. data/docs/PRD.md +743 -0
  25. data/docs/research/01-carhey.md +250 -0
  26. data/docs/research/02-ecosystem.md +261 -0
  27. data/docs/research/03-rails-core.md +220 -0
  28. data/docs/research/04-devise-warden.md +249 -0
  29. data/docs/research/05-oauth.md +193 -0
  30. data/docs/research/06-prior-art.md +312 -0
  31. data/docs/research/07-device-detection.md +250 -0
  32. data/docs/research/08-rails8-landscape.md +216 -0
  33. data/docs/research/09-market-security.md +450 -0
  34. data/gemfiles/rails_7.1.gemfile +34 -0
  35. data/gemfiles/rails_7.2.gemfile +34 -0
  36. data/gemfiles/rails_8.0.gemfile +34 -0
  37. data/gemfiles/rails_8.1.gemfile +34 -0
  38. data/lib/generators/sessions/install_generator.rb +230 -0
  39. data/lib/generators/sessions/madmin_generator.rb +95 -0
  40. data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +81 -0
  41. data/lib/generators/sessions/templates/create_sessions.rb.erb +101 -0
  42. data/lib/generators/sessions/templates/create_sessions_events.rb.erb +108 -0
  43. data/lib/generators/sessions/templates/initializer.rb +201 -0
  44. data/lib/generators/sessions/templates/madmin/event_resource.rb +77 -0
  45. data/lib/generators/sessions/templates/madmin/session_events_controller.rb +21 -0
  46. data/lib/generators/sessions/templates/madmin/session_resource.rb +65 -0
  47. data/lib/generators/sessions/templates/madmin/sessions_controller.rb +30 -0
  48. data/lib/generators/sessions/templates/session.rb.erb +14 -0
  49. data/lib/generators/sessions/templates/sessions_sweep_job.rb +20 -0
  50. data/lib/generators/sessions/views_generator.rb +33 -0
  51. data/lib/sessions/adapters/omakase.rb +195 -0
  52. data/lib/sessions/adapters/omniauth.rb +64 -0
  53. data/lib/sessions/adapters/warden.rb +293 -0
  54. data/lib/sessions/classifier.rb +208 -0
  55. data/lib/sessions/configuration.rb +441 -0
  56. data/lib/sessions/current.rb +20 -0
  57. data/lib/sessions/device.rb +411 -0
  58. data/lib/sessions/engine.rb +120 -0
  59. data/lib/sessions/errors.rb +24 -0
  60. data/lib/sessions/geolocation.rb +111 -0
  61. data/lib/sessions/ip_address.rb +56 -0
  62. data/lib/sessions/jobs/geolocate_job.rb +58 -0
  63. data/lib/sessions/macros.rb +26 -0
  64. data/lib/sessions/middleware.rb +41 -0
  65. data/lib/sessions/models/concerns/device_display.rb +134 -0
  66. data/lib/sessions/models/concerns/has_sessions.rb +116 -0
  67. data/lib/sessions/models/concerns/model.rb +513 -0
  68. data/lib/sessions/models/event.rb +293 -0
  69. data/lib/sessions/version.rb +5 -0
  70. data/lib/sessions.rb +423 -0
  71. metadata +225 -0
@@ -0,0 +1,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "browser"
4
+
5
+ module Sessions
6
+ # Turns a raw user agent (+ optional client-hint / X-Client-* headers) into
7
+ # the parsed device columns a "your devices" page renders. This is a
8
+ # PROJECTION: the raw UA is always persisted alongside, so parsing can be
9
+ # re-run as parsers and conventions improve.
10
+ #
11
+ # Three layers, in order (→ docs/research/07-device-detection.md):
12
+ #
13
+ # 1. Native matcher — Hotwire Native UAs (`/(Turbo|Hotwire) Native/`, the
14
+ # same contract as turbo-rails' `hotwire_native_app?`), the documented
15
+ # `AppName/1.2.3 (model; OS version; build N);` prefix convention,
16
+ # legacy shapes like "MyApp Android 1.0.5 (build 6; Android 14; sdk
17
+ # 34; Pixel 7)", and validated X-Client-* headers. No third-party
18
+ # parser understands any of this; it's the layer that names a session
19
+ # "MyApp 2.4.1 on Pixel 8 (Android 16)".
20
+ # 2. Web parser — the `browser` gem by default (MIT, zero-dep, what
21
+ # Mastodon uses), auto-upgrading to `device_detector` when the host
22
+ # bundles it, or any `->(user_agent, headers) { ... }` lambda.
23
+ # 3. Honesty filter — 2026 web UAs are frozen husks ("Windows NT 10.0",
24
+ # "Intel Mac OS X 10_15_7", "Android 10; K"); we never present a
25
+ # frozen token as a fact. OS versions are kept only where they're
26
+ # real: iOS UAs, Android WebViews (exempt from UA reduction), and
27
+ # Chromium client hints. Everything else renders version-less
28
+ # ("Chrome on macOS") — accurate beats impressive.
29
+ class Device
30
+ # Hard input bound (GitLab's SafeDeviceDetector precedent): parse at most
31
+ # this many characters. The FULL raw UA is still stored by the caller.
32
+ UA_PARSE_LIMIT = 1024
33
+
34
+ # Same contract as turbo-rails (app/controllers/turbo/native/navigation.rb).
35
+ NATIVE_MARKER = /(?:Turbo|Hotwire) Native (iOS|Android)/i
36
+
37
+ # The README's recommended prefix convention:
38
+ # MyApp/2.4.1 (iPhone15,2; iOS 19.5; build 241);
39
+ # Product tokens that are part of every browser UA must never match as
40
+ # app names.
41
+ BROWSER_PRODUCT_TOKENS = %w[
42
+ Mozilla AppleWebKit Chrome CriOS Safari Version Firefox FxiOS Gecko
43
+ KHTML Mobile OPR Edg\w* SamsungBrowser
44
+ ].freeze
45
+ CONVENTION_DENYLIST = /\A(?:#{BROWSER_PRODUCT_TOKENS.join("|")})\z/i
46
+ CONVENTION = %r{(?<app>[A-Za-z][\w .-]*?)/(?<version>\d[\w.]*)\s*\((?<fields>[^)]*)\)}
47
+
48
+ # The legacy native-HTTP-client shape (apps declare the name via
49
+ # config.native_app_names):
50
+ # MyApp Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)
51
+ LEGACY = /(?<app>[A-Za-z][\w .-]*?) (?<platform>iOS|Android) (?<version>\d[\w.]*)\s*\((?<fields>[^)]*)\)/
52
+
53
+ # The Android WebView segment of a Hotwire Native UA — exempt from
54
+ # Chrome's UA reduction, so model + OS version here are REAL.
55
+ ANDROID_WEBVIEW = %r{\(Linux; Android (?<os_version>[\d.]+); (?<model>[^;)]+?)(?: Build/[^;)]*)?[;)]}
56
+
57
+ # Real iOS version from the WebKit UA ("CPU iPhone OS 19_5 like Mac OS X").
58
+ IOS_OS_VERSION = /CPU (?:iPhone )?OS (?<version>\d+(?:_\d+)*)/
59
+
60
+ DEVICE_TYPES = %w[desktop smartphone tablet native_ios native_android bot unknown].freeze
61
+
62
+ ATTRIBUTES = %i[
63
+ browser_name browser_version os_name os_version
64
+ device_type device_model app_name app_version app_build
65
+ ].freeze
66
+
67
+ attr_reader(*ATTRIBUTES)
68
+
69
+ # Parse a raw user agent string (and optional canonical headers hash from
70
+ # `Device.headers_from`). Never raises: a hostile or unparseable UA
71
+ # degrades to device_type "unknown" — tracking must never break login.
72
+ def self.parse(user_agent, headers: {})
73
+ new(user_agent, headers: headers)
74
+ rescue StandardError => e
75
+ Sessions.warn("device parsing failed: #{e.class}: #{e.message}")
76
+ allocate.tap { |device| device.send(:initialize_blank) }
77
+ end
78
+
79
+ # The interesting request headers, normalized to their canonical names —
80
+ # used both as parser input and as the raw `client_hints` column value
81
+ # (so a future `sessions:reparse` can re-run parsing offline).
82
+ CAPTURED_HEADERS = {
83
+ "HTTP_SEC_CH_UA" => "Sec-CH-UA",
84
+ "HTTP_SEC_CH_UA_MOBILE" => "Sec-CH-UA-Mobile",
85
+ "HTTP_SEC_CH_UA_PLATFORM" => "Sec-CH-UA-Platform",
86
+ "HTTP_SEC_CH_UA_PLATFORM_VERSION" => "Sec-CH-UA-Platform-Version",
87
+ "HTTP_SEC_CH_UA_MODEL" => "Sec-CH-UA-Model",
88
+ "HTTP_SEC_CH_UA_FULL_VERSION_LIST" => "Sec-CH-UA-Full-Version-List",
89
+ "HTTP_X_CLIENT_PLATFORM" => "X-Client-Platform",
90
+ "HTTP_X_CLIENT_VERSION" => "X-Client-Version",
91
+ "HTTP_X_CLIENT_BUILD" => "X-Client-Build",
92
+ "HTTP_X_CLIENT_OS" => "X-Client-OS"
93
+ }.freeze
94
+
95
+ def self.headers_from(request)
96
+ return {} unless request
97
+
98
+ CAPTURED_HEADERS.each_with_object({}) do |(env_key, name), hints|
99
+ value = request.get_header(env_key)
100
+ hints[name] = value if value.respond_to?(:to_str) && !value.to_str.empty?
101
+ end
102
+ rescue StandardError
103
+ {}
104
+ end
105
+
106
+ def initialize(user_agent, headers: {})
107
+ initialize_blank
108
+ # Strip BEFORE the emptiness check: the browser gem's bot list treats
109
+ # whitespace-only UAs as bots, but for a devices page they're just
110
+ # unknown.
111
+ @user_agent = user_agent.to_s.strip[0, UA_PARSE_LIMIT]
112
+ @headers = headers || {}
113
+
114
+ if native_platform
115
+ parse_native
116
+ elsif @user_agent.empty?
117
+ @device_type = "unknown"
118
+ else
119
+ parse_web
120
+ end
121
+
122
+ freeze
123
+ end
124
+
125
+ def native?
126
+ device_type&.start_with?("native_")
127
+ end
128
+
129
+ def bot?
130
+ device_type == "bot"
131
+ end
132
+
133
+ # Only the attributes that map 1:1 onto session/event columns.
134
+ def to_h
135
+ ATTRIBUTES.index_with { |attribute| public_send(attribute) }.compact
136
+ end
137
+
138
+ private
139
+
140
+ def initialize_blank
141
+ ATTRIBUTES.each { |attribute| instance_variable_set(:"@#{attribute}", nil) }
142
+ @device_type = "unknown"
143
+ @user_agent = ""
144
+ @headers = {}
145
+ end
146
+
147
+ # --- Layer 1: native ------------------------------------------------------
148
+
149
+ # ios/android/nil — the union of the three native signals.
150
+ def native_platform
151
+ @native_platform ||=
152
+ if (marker = @user_agent.match(NATIVE_MARKER))
153
+ marker[1].downcase == "ios" ? "ios" : "android"
154
+ elsif %w[ios android].include?(header("X-Client-Platform").to_s.downcase)
155
+ header("X-Client-Platform").downcase
156
+ elsif (legacy = legacy_match) && known_native_app?(legacy[:app])
157
+ legacy[:platform].downcase == "ios" ? "ios" : "android"
158
+ end
159
+ end
160
+
161
+ def parse_native
162
+ @device_type = "native_#{native_platform}"
163
+
164
+ apply_native_fallbacks # weakest signals first…
165
+ apply_ua_prefix
166
+ apply_client_headers # …explicit headers win
167
+ end
168
+
169
+ # Platform defaults available even when the app sets no UA prefix at all:
170
+ # Android WebView UAs carry real model + OS; iOS UAs carry real OS.
171
+ def apply_native_fallbacks
172
+ if native_platform == "android"
173
+ @os_name = "Android"
174
+ if (webview = @user_agent.match(ANDROID_WEBVIEW))
175
+ @os_version = webview[:os_version]
176
+ @device_model = clean_token(webview[:model])
177
+ end
178
+ else
179
+ @os_name = "iOS"
180
+ if (ios = @user_agent.match(IOS_OS_VERSION))
181
+ @os_version = ios[:version].tr("_", ".")
182
+ end
183
+ # WKWebView UAs carry the device FAMILY (real, unlike desktop
184
+ # Safari) — never the hardware model; the prefix convention adds
185
+ # that and overwrites this via apply_prefix_fields.
186
+ @device_model = "iPad" if @user_agent.include?("iPad")
187
+ @device_model = "iPhone" if @user_agent.include?("iPhone")
188
+ end
189
+ end
190
+
191
+ def apply_ua_prefix
192
+ if (convention = convention_match)
193
+ @app_name = clean_token(convention[:app])
194
+ @app_version = convention[:version]
195
+ apply_prefix_fields(convention[:fields])
196
+ elsif (legacy = legacy_match)
197
+ @app_name = clean_token(legacy[:app])
198
+ @app_version = legacy[:version]
199
+ apply_prefix_fields(legacy[:fields])
200
+ end
201
+ end
202
+
203
+ # Semicolon-separated, order-insensitive fields from the parenthesized
204
+ # part of the prefix: "iPhone15,2; iOS 19.5; build 241". The first
205
+ # unrecognized field is the device model — more specific than any
206
+ # platform fallback (the iOS family, the Android WebView model), so it
207
+ # overwrites.
208
+ def apply_prefix_fields(fields)
209
+ model = nil
210
+ fields.to_s.split(";").map(&:strip).each do |field|
211
+ case field
212
+ when /\Abuild (\w+)\z/i then @app_build = Regexp.last_match(1)
213
+ when /\A(?:iOS|iPadOS) ([\d.]+)\z/i then @os_name = "iOS"
214
+ @os_version = Regexp.last_match(1)
215
+ when /\AAndroid ([\d.]+)\z/i then @os_name = "Android"
216
+ @os_version = Regexp.last_match(1)
217
+ when /\Asdk \d+\z/i then nil # Android API level — implied by the OS version
218
+ when "" then nil
219
+ else
220
+ model ||= field
221
+ end
222
+ end
223
+ @device_model = model if model
224
+ end
225
+
226
+ # Validated headers, production-proven (spoofable, diagnostics-only —
227
+ # never authorization). Bounds mirror ClientVersionInfo: semver-ish
228
+ # versions, OS strings capped at 64 chars.
229
+ def apply_client_headers
230
+ if (version = header("X-Client-Version")) && version.match?(/\A\d+(\.\d+){0,3}\z/)
231
+ @app_version = version
232
+ end
233
+ if (build = header("X-Client-Build")) && build.match?(/\A\w{1,32}\z/)
234
+ @app_build = build
235
+ end
236
+ if (os = header("X-Client-OS")) && (os = os[0, 64].strip) &&
237
+ (parsed = os.match(/\A(?<name>iOS|iPadOS|Android)\s+(?<version>[\d.]+)\z/i))
238
+ @os_name = parsed[:name].casecmp("android").zero? ? "Android" : "iOS"
239
+ @os_version = parsed[:version]
240
+ end
241
+ end
242
+
243
+ def convention_match
244
+ @user_agent.to_enum(:scan, CONVENTION).map { Regexp.last_match }.find do |match|
245
+ !match[:app].match?(CONVENTION_DENYLIST) && match[:fields].include?(";")
246
+ end
247
+ end
248
+
249
+ def legacy_match
250
+ @user_agent.match(LEGACY)
251
+ end
252
+
253
+ def known_native_app?(app_name)
254
+ Sessions.config.native_app_names.any? { |known| app_name.strip.casecmp?(known) }
255
+ end
256
+
257
+ # --- Layer 2: web ---------------------------------------------------------
258
+
259
+ def parse_web
260
+ parser = Sessions.config.ua_parser
261
+
262
+ if parser.respond_to?(:call)
263
+ apply_custom(parser.call(@user_agent, @headers))
264
+ elsif parser == :device_detector && defined?(::DeviceDetector)
265
+ parse_with_device_detector
266
+ else
267
+ parse_with_browser
268
+ end
269
+
270
+ apply_client_hints
271
+ enforce_honest_os_version
272
+ end
273
+
274
+ def parse_with_browser
275
+ browser = ::Browser.new(@user_agent)
276
+
277
+ if browser.bot?
278
+ @device_type = "bot"
279
+ @browser_name = browser.bot.name
280
+ return
281
+ end
282
+
283
+ @browser_name = presence(browser.name) unless browser.name == "Unknown Browser"
284
+ @browser_version = presence(browser.version) unless browser.version.to_s == "0"
285
+ @os_name = browser_platform_name(browser)
286
+ @os_version = presence(browser.platform.version)
287
+ @device_type = if browser.device.tablet? then "tablet"
288
+ elsif browser.device.mobile? then "smartphone"
289
+ elsif @os_name then "desktop"
290
+ else "unknown"
291
+ end
292
+ end
293
+
294
+ def browser_platform_name(browser)
295
+ case browser.platform.id
296
+ when :mac then "macOS"
297
+ when :windows then "Windows"
298
+ when :linux then "Linux"
299
+ when :ios then "iOS"
300
+ when :android then "Android"
301
+ when :chrome_os then "ChromeOS"
302
+ when :unknown_platform, nil then nil
303
+ else presence(browser.platform.name)
304
+ end
305
+ end
306
+
307
+ def parse_with_device_detector
308
+ detector = ::DeviceDetector.new(@user_agent, device_detector_headers)
309
+
310
+ if detector.bot?
311
+ @device_type = "bot"
312
+ @browser_name = detector.bot_name
313
+ return
314
+ end
315
+
316
+ @browser_name = presence(detector.name) unless detector.name == "UNK"
317
+ @browser_version = presence(detector.full_version.to_s.split(".").first)
318
+ @os_name = presence(detector.os_name) unless detector.os_name == "UNK"
319
+ @os_name = "macOS" if @os_name == "Mac"
320
+ @os_version = presence(detector.os_full_version)
321
+ @device_model = presence(detector.device_name)
322
+ @device_type = case detector.device_type
323
+ when "smartphone", "phablet" then "smartphone"
324
+ when "tablet" then "tablet"
325
+ when "desktop" then "desktop"
326
+ when nil then @os_name ? "desktop" : "unknown"
327
+ else "unknown"
328
+ end
329
+ end
330
+
331
+ # device_detector expects literal header names, not Rack env keys — our
332
+ # canonical hash already uses them.
333
+ def device_detector_headers
334
+ @headers.slice(*CAPTURED_HEADERS.values.grep(/\ASec-CH/))
335
+ end
336
+
337
+ def apply_custom(result)
338
+ return unless result.respond_to?(:to_h)
339
+
340
+ result.to_h.symbolize_keys.slice(*ATTRIBUTES).each do |attribute, value|
341
+ instance_variable_set(:"@#{attribute}", presence(value&.to_s))
342
+ end
343
+ @device_type = "unknown" unless DEVICE_TYPES.include?(@device_type)
344
+ end
345
+
346
+ # High-entropy Chromium client hints recover what the frozen UA dropped:
347
+ # real platform versions and Android device models.
348
+ def apply_client_hints
349
+ if (model = header("Sec-CH-UA-Model")) && !(model = unquote(model)).empty?
350
+ @device_model = model
351
+ end
352
+
353
+ if (platform_version = header("Sec-CH-UA-Platform-Version"))
354
+ version = unquote(platform_version)
355
+ @os_version = hinted_os_version(version) unless version.empty?
356
+ end
357
+
358
+ case header("Sec-CH-UA-Mobile")
359
+ when "?1" then @device_type = "smartphone" if @device_type == "desktop"
360
+ when "?0" then @device_type = "desktop" if @device_type == "smartphone"
361
+ end
362
+ end
363
+
364
+ # Windows is the one platform whose hinted version needs decoding: the
365
+ # platform version is the internal build line, where 13+ means Windows 11
366
+ # (learn.microsoft.com/microsoft-edge/web-platform/how-to-detect-win11).
367
+ def hinted_os_version(version)
368
+ return version unless @os_name == "Windows"
369
+
370
+ major = version.split(".").first.to_i
371
+ if major >= 13 then "11"
372
+ elsif major.positive? then "10"
373
+ end
374
+ end
375
+
376
+ # Frozen-UA honesty: a 2026 web UA carries REAL OS versions only on iOS.
377
+ # macOS is frozen at 10.15.7, Windows at NT 10.0, Chrome-on-Android at
378
+ # "Android 10; K" — rendering those as facts would be lying to users on
379
+ # their own security page. Client hints (handled above) overwrite with
380
+ # real values when present.
381
+ def enforce_honest_os_version
382
+ return if @os_version.nil?
383
+ return if @os_name == "iOS"
384
+ return if hinted?("Sec-CH-UA-Platform-Version")
385
+
386
+ @os_version = nil
387
+ end
388
+
389
+ # --- Helpers ----------------------------------------------------------------
390
+
391
+ def header(name)
392
+ presence(@headers[name])
393
+ end
394
+
395
+ def hinted?(name)
396
+ !header(name).nil?
397
+ end
398
+
399
+ def unquote(value)
400
+ value.to_s.delete('"').strip
401
+ end
402
+
403
+ def clean_token(value)
404
+ presence(value.to_s.strip.delete_suffix(";"))
405
+ end
406
+
407
+ def presence(value)
408
+ value.nil? || value.to_s.empty? ? nil : value
409
+ end
410
+ end
411
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module Sessions
6
+ # The mountable engine: wires autoloading, migrations, locales, the
7
+ # `has_sessions` macro, the request-capture middleware, and all three
8
+ # adapters into the host app — every adapter strictly capability-detected,
9
+ # so the gem is inert wherever its target isn't present.
10
+ class Engine < ::Rails::Engine
11
+ isolate_namespace Sessions
12
+
13
+ # -------------------------------------------------------------------------
14
+ # Zeitwerk: the gem keeps its ActiveRecord models and jobs under
15
+ # lib/sessions/{models,jobs} (same layout as the moderate and chats gems)
16
+ # so the whole domain ships in lib/ and the engine's app/ tree only holds
17
+ # the web layer (controllers, helpers, views). For that to autoload
18
+ # correctly we manage the loader by hand:
19
+ #
20
+ # - `push_dir(lib/sessions, namespace: Sessions)` makes
21
+ # lib/sessions/models/... autoloadable *under the Sessions namespace*.
22
+ # - `collapse(models)` + `collapse(models/concerns)` + `collapse(jobs)`
23
+ # mean those files define Sessions::Event / Sessions::Model /
24
+ # Sessions::GeolocateJob — not Sessions::Models::Event.
25
+ # - The SPINE files (version/errors/configuration/adapters/…) are
26
+ # required explicitly by lib/sessions.rb at boot, so they must be
27
+ # *ignored* by the loader or Zeitwerk would complain about double
28
+ # definitions / unmanaged constants.
29
+ # -------------------------------------------------------------------------
30
+ LIB_ROOT = File.expand_path("..", __dir__)
31
+ SESSIONS_LIB = File.expand_path("sessions", LIB_ROOT)
32
+
33
+ ZEITWERK_IGNORED = %w[
34
+ version.rb errors.rb configuration.rb current.rb ip_address.rb
35
+ device.rb classifier.rb geolocation.rb middleware.rb macros.rb
36
+ adapters engine.rb
37
+ ].freeze
38
+
39
+ initializer "sessions.autoload", before: :set_autoload_paths do
40
+ loader = Rails.autoloaders.main
41
+
42
+ ZEITWERK_IGNORED.each do |entry|
43
+ path = File.join(SESSIONS_LIB, entry)
44
+ loader.ignore(path) if File.exist?(path)
45
+ end
46
+
47
+ %w[models models/concerns jobs].each do |dir|
48
+ path = File.join(SESSIONS_LIB, dir)
49
+ loader.collapse(path) if File.directory?(path)
50
+ end
51
+
52
+ loader.push_dir(SESSIONS_LIB, namespace: Sessions)
53
+ end
54
+
55
+ config.eager_load_paths << SESSIONS_LIB
56
+
57
+ # Request capture for model-callback context (and the opt-in Accept-CH
58
+ # advertisement). Inserted after the Executor so CurrentAttributes'
59
+ # executor-driven reset cleans our state after every request.
60
+ initializer "sessions.middleware" do |app|
61
+ app.middleware.insert_after ActionDispatch::Executor, Sessions::Middleware
62
+ rescue StandardError
63
+ app.middleware.use Sessions::Middleware
64
+ end
65
+
66
+ # Expose `has_sessions` on every AR model.
67
+ initializer "sessions.active_record" do
68
+ ActiveSupport.on_load(:active_record) do
69
+ extend Sessions::Macros
70
+ end
71
+ end
72
+
73
+ # Ship the gem's locale files (en, es). Host locale files with the same
74
+ # keys override these automatically (I18n's load order puts the app last).
75
+ initializer "sessions.locales" do |app|
76
+ app.config.i18n.load_path += Dir[root.join("config", "locales", "**", "*.{rb,yml}").to_s]
77
+ end
78
+
79
+ # Serve the engine's stylesheet through the host's asset pipeline
80
+ # (propshaft or sprockets — both honor config.assets.paths).
81
+ initializer "sessions.assets" do |app|
82
+ app.config.assets.paths << root.join("app/assets/stylesheets") if app.config.respond_to?(:assets)
83
+ end
84
+
85
+ # The Devise/Warden adapter. Bundler.require has already loaded every
86
+ # gem in the Gemfile by the time initializers run, so `defined?` is
87
+ # decisive regardless of Gemfile order; Warden hooks live on the Manager
88
+ # CLASS and are read live per request, so registering here (before the
89
+ # first request) is all that's needed (→ research/04 §8).
90
+ initializer "sessions.warden" do
91
+ Sessions::Adapters::Warden.install! if defined?(::Warden::Manager)
92
+ end
93
+
94
+ # Rails 8.1's rate_limit notification — a free brute-force-threshold
95
+ # signal on the generated sessions/passwords controllers. Subscribed
96
+ # once per process; inert on earlier Rails.
97
+ initializer "sessions.rate_limit" do
98
+ Sessions::Adapters::Omakase.subscribe_rate_limit_notifications!
99
+ end
100
+
101
+ # The OmniAuth failure composer must wrap LAST — after Devise replaced
102
+ # the failure endpoint (at require time) and after the app's own
103
+ # initializers possibly customized it. after_initialize runs once, after
104
+ # everything.
105
+ config.after_initialize do
106
+ Sessions::Adapters::Omniauth.install! if defined?(::OmniAuth)
107
+ end
108
+
109
+ # The omakase adapter touches autoloaded app constants (Session,
110
+ # ApplicationController), so it must re-apply on every code reload.
111
+ config.to_prepare do
112
+ # Touch the helper so its bottom-of-file on_load(:action_view) hook
113
+ # registers even if no engine code was referenced yet — and clear its
114
+ # mount-name memo, since a development reload may have redrawn routes.
115
+ Sessions::EngineHelper.reset_engine_mount_name!
116
+
117
+ Sessions::Adapters::Omakase.install!
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ # Base class for every error this gem raises, so hosts can
5
+ # `rescue Sessions::Error` to catch anything sessions-specific.
6
+ #
7
+ # Deliberately tiny: the gem sits on the authentication hot path, where its
8
+ # contract is to OBSERVE and never to interrupt — tracking code rescues its
9
+ # own failures (see Sessions.safely) instead of raising into a sign-in.
10
+ # Errors here are reserved for things that SHOULD stop you: invalid
11
+ # configuration at boot, and generator-time misdetection.
12
+ class Error < StandardError; end
13
+
14
+ # Raised by `Sessions.configure` / setters when the configuration is
15
+ # invalid (unknown ip_mode, non-callable hook, blank class name, …).
16
+ # Fails at boot with a plain-English message, not at 3am with a
17
+ # NoMethodError.
18
+ class ConfigurationError < Error; end
19
+
20
+ # Raised at generator time when `rails generate sessions:install` can't
21
+ # tell which auth system the app uses (no Rails 8 authentication, no
22
+ # Devise) — the gem decorates a session of record; it never creates one.
23
+ class UnknownAuthSystemError < Error; end
24
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ # IP geolocation through the `trackdown` gem — a SOFT dependency, never
5
+ # required: every call site is `defined?(::Trackdown)`-guarded and
6
+ # rescue-everything (trackdown raises on private/loopback IPs in
7
+ # development, and a geo hiccup must never block a login write). The
8
+ # integration contract is lifted verbatim from footprinted's proven one
9
+ # (→ docs/research/02-ecosystem.md §2):
10
+ #
11
+ # - always pass `request:` through so Cloudflare headers win when present
12
+ # (zero config, free, synchronous header read);
13
+ # - go async only when sync would mean a database lookup (the MaxMind
14
+ # mode) — Sessions::GeolocateJob enriches the row after commit;
15
+ # - skip lookups when country_code is already present.
16
+ #
17
+ # Without trackdown, geo columns simply stay nil and the devices page
18
+ # omits location cleanly.
19
+ module Geolocation
20
+ COLUMNS = %i[country_code country_name city region].freeze
21
+
22
+ module_function
23
+
24
+ def enabled?
25
+ Sessions.config.geolocate == :auto && defined?(::Trackdown)
26
+ end
27
+
28
+ # Synchronous geolocation — called inline at session-creation time ONLY
29
+ # when it's free (Cloudflare already did the lookup and put the answer
30
+ # in request headers). Returns a column hash or {}. `coordinates: true`
31
+ # adds precision-reduced lat/lng (event rows only).
32
+ def locate(ip, request: nil, coordinates: false)
33
+ return {} unless enabled?
34
+ return {} if ip.to_s.empty?
35
+
36
+ result = ::Trackdown.locate(ip.to_s, request: request)
37
+ columns = columns_from(result)
38
+ columns = columns.merge(coordinates_from(result)) if coordinates && columns.any?
39
+ columns
40
+ rescue StandardError => e
41
+ Sessions.warn("geolocation failed for #{ip}: #{e.class}: #{e.message}")
42
+ {}
43
+ end
44
+
45
+ # Hand a record to the async MaxMind path (no-op without ActiveJob or a
46
+ # trackdown database — see async_capable?).
47
+ def enqueue(record)
48
+ return false unless record&.persisted?
49
+ return false unless async_capable?
50
+
51
+ Sessions::GeolocateJob.perform_later(record.class.name, record.id)
52
+ true
53
+ rescue StandardError => e
54
+ Sessions.warn("geolocation enqueue failed: #{e.class}: #{e.message}")
55
+ false
56
+ end
57
+
58
+ # Whether this request already carries a Cloudflare geo answer — the
59
+ # header read is free, so we resolve synchronously.
60
+ def cloudflare_headers?(request)
61
+ return false unless request
62
+
63
+ country = request.get_header("HTTP_CF_IPCOUNTRY")
64
+ !country.nil? && !country.empty? && country != "XX"
65
+ rescue StandardError
66
+ false
67
+ end
68
+
69
+ # Whether an async MaxMind lookup could possibly succeed — used to avoid
70
+ # enqueueing a no-op job per login on hosts without a MaxMind database.
71
+ def async_capable?
72
+ enabled? &&
73
+ defined?(::ActiveJob) &&
74
+ ::Trackdown.respond_to?(:database_exists?) &&
75
+ ::Trackdown.database_exists?
76
+ rescue StandardError
77
+ false
78
+ end
79
+
80
+ def columns_from(result)
81
+ return {} unless result
82
+ return {} if result.country_code.to_s.empty?
83
+
84
+ {
85
+ country_code: result.country_code,
86
+ country_name: presence(result.country_name),
87
+ city: presence(result.city),
88
+ region: presence(result.region)
89
+ }.compact
90
+ end
91
+
92
+ # Latitude/longitude for EVENT rows only, precision-reduced per
93
+ # config.geo_precision (2 decimals ≈ 1km — privacy now,
94
+ # impossible-travel math later).
95
+ def coordinates_from(result)
96
+ return {} unless result.respond_to?(:latitude) && result.latitude
97
+
98
+ precision = Sessions.config.geo_precision
99
+ {
100
+ latitude: result.latitude.to_f.round(precision),
101
+ longitude: result.longitude.to_f.round(precision)
102
+ }
103
+ rescue StandardError
104
+ {}
105
+ end
106
+
107
+ def presence(value)
108
+ value.nil? || value.to_s.empty? || value.to_s == "Unknown" ? nil : value
109
+ end
110
+ end
111
+ end