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,250 @@
1
+ # CarHey audit: auth, sessions & device detection
2
+
3
+ Read-only audit of `/Users/javi/GitHub/carhey` (Rails 8.1.1, RailsFast, Devise), its native shells `/Users/javi/GitHub/carhey-ios` + `/Users/javi/GitHub/carhey-android`, and (addendum) `/Users/javi/GitHub/licenseseat`. Audited 2026-06-11. All paths relative to each repo root unless absolute. Facts cite `path:line`; anything inferred is marked as such.
4
+
5
+ ## Top findings
6
+
7
+ - Devise 5.0.4 with `:trackable` **on** (`app/models/user.rb:74-76`) — but trackable only stores 1 current + 1 last sign-in on the `users` row. **There is no sessions table, no devices table, no login-attempt log anywhere in `db/schema.rb`.** That is the gem's hole to fill.
8
+ - Admin is a `users.admin` boolean (`db/schema.rb:1260`) gated by `authenticate :user, lambda { |u| u.admin? }` (`config/routes.rb:26`). Single Devise scope; no OmniAuth, no passkeys, no 2FA, no magic links.
9
+ - Session store is Rails' default CookieStore — no `session_store` initializer exists anywhere in `config/` (verified by grep). Sessions are therefore **unenumerable and unrevocable server-side** today.
10
+ - Native apps get silent 1-year remember-me: `remember_hotwire_native_session` (`app/controllers/users/sessions_controller.rb:187-198`) + `config.remember_for = 1.year` (`config/initializers/devise.rb:175`).
11
+ - CarHey already has a world-class **signup-time** device fingerprint: 22 `signup_*` columns on `users` (`db/schema.rb:1306-1322`), populated by `SignupAttribution` (DeviceDetector + UA Client Hints, `app/services/signup_attribution.rb`) and `SignupDisplayMetrics`. It is one-shot — never refreshed at login.
12
+ - Per-user "what client is this user running NOW" exists as `last_seen_*` columns (`db/schema.rb:1280-1284`), written only by the native JSON API via a race-safe throttled SQL `UPDATE ... WHERE IS DISTINCT FROM` (`app/controllers/api/v1/base_controller.rb:32-57`). Web requests never touch it; one row per user, not per device.
13
+ - Native shells announce themselves via load-bearing UA tokens: Android WebView prefix `"CarHey Android;"` (`carhey-android .../CarHeyApplication.kt:169`), iOS `"CarHey iOS; RailsFast Native iOS;"` (`carhey-ios RailsFast/Core/AppConfiguration.swift:10-12`). Server matches `/\b(?:CarHey\s+Android|Hotwire Native Android|Turbo Native Android)\b/i` (`app/services/signup_attribution.rb:315-324`).
14
+ - Both shells also stamp **exact** `X-Client-Platform/Version/Build/OS` headers on native JSON calls (Android OkHttp interceptor `NativeHttpClient.kt:47-80`; iOS `NativeHttpClient.swift:12-56`), parsed server-side by `ClientVersionInfo` (`app/controllers/concerns/client_version_info.rb`).
15
+ - **No push notifications anywhere**: no FCM/APNs code in either shell (grep for `UNUserNotificationCenter|registerForRemoteNotifications|Firebase` returned nothing), no device-token tables, no push gems.
16
+ - IP geolocation = `trackdown` 0.2.0, **no initializer** (default `:auto` provider → Cloudflare `CF-IPCountry`/`CF-IPCity` headers), called exactly once in the whole app: synchronously at signup (`app/controllers/users/registrations_controller.rb:45`).
17
+ - Real client IP behind Cloudflare = `cloudflare-rails` 7.0.0, production group only (`Gemfile:86-89`); it prepends CF ranges into `ActionDispatch::RemoteIp` so plain `request.remote_ip` is correct in prod.
18
+ - `AuditLog` (`app/models/audit_log.rb`) is a SHA-256 hash-chained, immutable, advisory-lock-serialized ledger with a `AuditLog.log(event_type:, data:, auditable:, user:, request:)` API capturing `remote_ip` + `user_agent` — wired to the `moderate` gem (`config/initializers/moderate.rb:32-41`). It logs **moderation** events only; login/logout events are never audited.
19
+ - The user-facing surface for a "your devices" page exists and has clear conventions: `GET /settings` → `SettingsController#show` → `app/views/settings/show.html.erb` built from `<section>` + `shared/setting_row` partials; admin surface = madmin resources in `app/madmin/resources/` drawn at `/admin/dashboard` (`config/routes.rb:38-40`).
20
+ - Bot/abuse protection at the auth boundary is Cloudflare Turnstile (`rails_cloudflare_turnstile` 0.4.4) on sign-in/sign-up `create`, **skipped for native apps** (`app/controllers/application_controller.rb:32-46`). No rack-attack.
21
+ - LicenseSeat (second target app) runs the same RailsFast Devise wiring (same modules, same 4 custom controllers) but devise 4.9.4, no native shell, no `last_seen_*`/UA columns — and adds `api_keys` 0.3.0 token auth + an **active** `footprinted` 0.3.1 install used for product telemetry, not login tracking.
22
+
23
+ ---
24
+
25
+ ## 1. Auth stack
26
+
27
+ **Modules** — `app/models/user.rb:74-76`:
28
+
29
+ ```ruby
30
+ devise :database_authenticatable, :registerable,
31
+ :recoverable, :rememberable, :validatable,
32
+ :confirmable, :lockable, :trackable
33
+ ```
34
+
35
+ `:omniauthable` and `:timeoutable` are explicitly not used (comment at `user.rb:72-73`). devise `5.0.4`, warden `1.2.9` (`Gemfile.lock:162,580`).
36
+
37
+ **`config/initializers/devise.rb` notable active settings** (line-cited): `stretches = 12` (:128), `allow_unconfirmed_access_for = 3.days` (:154), `reconfirmable = true` (:168), `remember_for = 1.year` (:175), `expire_all_remember_me_on_sign_out = true` (:178), `extend_remember_period = true` (:181), `rememberable_options = { same_site: :lax }` (:185), `password_length = 6..128` (:189), `reset_password_within = 6.hours` (:235), `sign_out_via = :delete` (:277), custom Warden failure app `Devise::CurrentHostFailureApp` (:288-294, required at :11), Turbo-era responder statuses 422/303 (:317-318), `config.mailer = "DeviseGoodmailer"` (:325).
38
+
39
+ **Custom failure app** — `lib/devise/current_host_failure_app.rb:7-20`: overrides `route(scope)` to return `:"new_#{scope}_session_path"` (path, not URL) so Android-emulator hosts (`10.0.2.2`) aren't bounced to `localhost`. Any gem middleware that redirects on auth failure must respect this same current-host constraint.
40
+
41
+ **Custom Devise controllers** — `config/routes.rb:2-7`: `users/confirmations`, `users/registrations`, `users/sessions`, `users/passwords`; plus `POST users/confirmation/resend` (:9-15) — an authenticated, throttled in-app resend (`app/controllers/users/confirmations_controller.rb:9,48`; cooldown columns `confirmation_resend_email`/`confirmation_resend_sent_at`, `db/schema.rb:1262-1263`).
42
+
43
+ - `Users::RegistrationsController`: Turnstile gate (:4), captures `request.remote_ip` + `SignupAttribution.from_request(request)` + display metrics *before* Devise mutates state (:21-23), persists via `update_columns` post-create (:36-42), geolocates synchronously with `Trackdown.locate(ip, request: request)` writing `signup_country/_code/_city` (:45-52, rescue→log), and sets `Accept-CH` to request high-entropy UA Client Hints (:66-68).
44
+ - `Users::SessionsController`: Turnstile on `create` for browsers only (:6); a `manual_sign_in_flow?` branch (native app or pending org invitation, :136-138) re-implements lookup/`valid_password?`/`active_for_authentication?` so native sheets render 422 form errors instead of Devise's failure app (:42-118); auto remember-me for native at :187-198 (`remember_me(resource)` whenever `hotwire_native_app?`).
45
+ - `ApplicationController` owns `after_sign_in/up/out_path_for` with native handoff routing (:61-152) and a read-only Warden peek `warden.user(scope: :user, run_callbacks: false)` (:360) used to inspect sessions without waking rememberable.
46
+
47
+ **Other login methods**: none. No OmniAuth gem, no Google One Tap, no Sign in with Apple, no WebAuthn/passkeys, no TOTP/2FA, no magic links (verified: zero `omniauth|webauthn|rotp|devise-` hits in `Gemfile`/`Gemfile.lock`). Twilio SMS OTP exists (`phonelib 0.10.18`, `twilio-ruby 7.10.5`, `app/services/phone_verification_service.rb`) but it is onboarding **phone verification**, not login 2FA. Dev-only auto-login endpoint for screenshot capture: `appstore/demo_sessions#create` (`config/routes.rb:53`). `POST invitation_account_switch/:token` (`config/routes.rb:21`) signs out the current user to accept an org invitation as another account.
48
+
49
+ **Admin**: no second Devise scope. `users.admin` boolean + routes lambda (`config/routes.rb:26-41`) wrapping Profitable, Mission Control Jobs, and madmin.
50
+
51
+ ## 2. Existing session/login tracking
52
+
53
+ **There is no `sessions`, `devices`, `login_activities`, or push/device-token table** in `db/schema.rb` (full `create_table` list inspected). What exists:
54
+
55
+ `users` (`db/schema.rb:1259-1338`), the relevant columns verbatim:
56
+
57
+ ```ruby
58
+ t.datetime "current_sign_in_at" # :1269 (Devise trackable)
59
+ t.string "current_sign_in_ip" # :1270
60
+ t.integer "failed_attempts", default: 0, null: false # :1275 (lockable)
61
+ t.integer "last_seen_app_build" # :1280
62
+ t.string "last_seen_app_version", limit: 32
63
+ t.datetime "last_seen_client_at"
64
+ t.string "last_seen_os_version", limit: 64
65
+ t.string "last_seen_platform", limit: 16 # :1284
66
+ t.datetime "last_sign_in_at" # :1285
67
+ t.string "last_sign_in_ip" # :1286
68
+ t.datetime "remember_created_at" # :1295
69
+ t.integer "sign_in_count", default: 0, null: false # :1298
70
+ t.string "signup_bot_name", limit: 128 # :1299
71
+ t.string "signup_browser_name", limit: 64
72
+ t.string "signup_browser_version", limit: 64
73
+ t.string "signup_city"
74
+ t.string "signup_client", limit: 32
75
+ t.string "signup_country"
76
+ t.string "signup_country_code"
77
+ t.string "signup_device_brand", limit: 64
78
+ t.string "signup_device_category", limit: 32
79
+ t.string "signup_device_model", limit: 128
80
+ t.decimal "signup_device_pixel_ratio", precision: 6, scale: 3
81
+ t.string "signup_device_platform", limit: 32
82
+ t.string "signup_ip"
83
+ t.string "signup_os_version", limit: 64
84
+ t.integer "signup_screen_height"
85
+ t.string "signup_screen_orientation", limit: 32
86
+ t.integer "signup_screen_width"
87
+ t.boolean "signup_touch_capable"
88
+ t.text "signup_user_agent"
89
+ t.integer "signup_viewport_height"
90
+ t.integer "signup_viewport_width" # :1322
91
+ ```
92
+
93
+ Indexes: `[signup_client, signup_device_platform]` partial, `signup_client` partial, `signup_country_code` (`db/schema.rb:1333-1335`).
94
+
95
+ **`audit_logs`** (`db/schema.rb:46-66`) — the `moderate` `config.audit` target and the strongest in-house pattern reference:
96
+
97
+ ```ruby
98
+ create_table "audit_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
99
+ t.uuid "auditable_id"
100
+ t.string "auditable_type"
101
+ t.datetime "created_at", null: false
102
+ t.jsonb "event_data", default: {}, null: false
103
+ t.string "event_type", null: false
104
+ t.inet "ip_address"
105
+ t.string "previous_hash", null: false
106
+ t.string "record_hash", null: false
107
+ t.datetime "recorded_at", null: false
108
+ t.bigint "sequence_id", null: false
109
+ t.datetime "updated_at", null: false
110
+ t.text "user_agent"
111
+ t.uuid "user_id"
112
+ t.index ["auditable_type", "auditable_id"], ...
113
+ t.index ["event_type"], ...
114
+ t.index ["recorded_at"], ...
115
+ t.index ["sequence_id"], ..., unique: true
116
+ t.index ["user_id"], ...
117
+ end
118
+ ```
119
+
120
+ `AuditLog` model (`app/models/audit_log.rb`): immutable (`before_update`/`before_destroy` raise, :21-23), genesis row + SHA-256 hash chain with canonicalized JSON (:94-117), single-writer ordering via `pg_advisory_xact_lock` (:127-131), `verify_chain` (:64-92), and the API the sessions gem should imitate — `AuditLog.log(event_type:, data:, auditable:, user:, request:)` auto-capturing `request&.remote_ip` / `request&.user_agent` (:30-41). Wired in `config/initializers/moderate.rb:32-41`; only `moderation.*` events flow through it today.
121
+
122
+ **Other ip/UA captures** (scattered, per-feature, all one-shot): `waitlist_entries` (`db/schema.rb:1361-1374`: `inet ip_address`, `platform`, `text user_agent`; written at `app/controllers/waitlist_entries_controller.rb:11-13`); `identity_verifications` `ip_address`/`user_agent` (`db/schema.rb:582,590`); `energy_savings_user_transfer_contracts` has `device_fingerprint`, `ip_address`, `user_agent` (`db/schema.rb:553-566`); `ride_participants.client_platform` limit 16 (`db/schema.rb:1026`).
123
+
124
+ **Parsers** worth lifting into the gem: `SignupAttribution` (`app/services/signup_attribution.rb`) — `DeviceDetector.new(ua, client_hint_headers)` (:120-124) normalized into business buckets `client` (desktop_web/mobile_web/tablet_web/android_native_app/ios_native_app/bot), `platform`, `device_category`, `browser`, versions, brand/model, bot name (:138-151 `to_user_attributes`); extensive fallback regexes incl. iPad-desktop-mode detection (:341). `SignupDisplayMetrics` (`app/services/signup_display_metrics.rb:7-15`) bounds-checks screen/viewport/DPR/touch/orientation from hidden signup-form fields.
125
+
126
+ ## 3. Hotwire Native: detection, UA contracts, identity routes
127
+
128
+ **Server-side detection** is turbo-rails' `hotwire_native_app?` (UA `~ /(Turbo|Hotwire) Native/`; cited in-code at `app/controllers/application_controller.rb:195-196`). Per-platform: `HotwireNativeHelper#hotwire_native_platform` (`app/helpers/hotwire_native_helper.rb:63-69`) checks UA for literal `"Hotwire Native Android"` / `"Hotwire Native iOS"`. Bridge-component capability sniffing parses the UA's `bridge-components: [...]` list (:78-94) — e.g. the toast component decides flash handling at `app/controllers/native/entries_controller.rb:40-45`.
129
+
130
+ **iOS WebView UA** — prefix built at `carhey-ios/RailsFast/Core/AppConfiguration.swift:10-12`:
131
+
132
+ ```swift
133
+ static var userAgentPrefix: String {
134
+ "\(applicationName) iOS; RailsFast Native iOS;"
135
+ }
136
+ ```
137
+
138
+ `applicationName` = `CFBundleDisplayName` = `CarHey` (`carhey-ios/project.yml:63`), set via `Hotwire.config.applicationUserAgentPrefix` (`carhey-ios/RailsFast/App/AppDelegate.swift:102`). Per the comment at `AppDelegate.swift:90-93`, the framework appends `"Hotwire Native iOS"`, `"Turbo Native iOS"`, and the bridge-component list — so the effective WebView UA starts `CarHey iOS; RailsFast Native iOS; Hotwire Native iOS; Turbo Native iOS; bridge-components: [...]` followed by the WebKit UA (final composed string is framework behavior — inference from those comments, not observed at runtime).
139
+
140
+ **iOS native-JSON UA + headers** — `carhey-ios/RailsFast/Core/NativeHttpClient.swift:12-15,61-72`: headers `X-Client-Platform: ios`, `X-Client-Version` (CFBundleShortVersionString), `X-Client-Build` (CFBundleVersion), `X-Client-OS` (`"iOS \(version)"`), and UA:
141
+
142
+ ```swift
143
+ return "\(applicationName) iOS \(version) (build \(build); iOS \(osVersion); \(resolvedModel))"
144
+ ```
145
+
146
+ **Android WebView UA** — `carhey-android/.../CarHeyApplication.kt:169`: `Hotwire.config.applicationUserAgentPrefix = "CarHey Android;"`.
147
+
148
+ **Android OkHttp UA + headers** — `carhey-android/.../ClientHeaders.kt:25-28,66-77`:
149
+
150
+ ```kotlin
151
+ return "CarHey Android $versionName (build $versionCode; Android $osRelease; sdk $sdkInt; $device)"
152
+ ```
153
+
154
+ stamped by an application-level interceptor with `.header()` replace-not-append semantics (`carhey-android/.../NativeHttpClient.kt:47-80`). `ClientHeaders.kt:17-21` documents the **critical contract**: the UA must contain the literal space-separated token `CarHey Android` because the server matches `/\bCarHey\s+Android\b/i` — `SignupAttribution#detect_native_platform` (`app/services/signup_attribution.rb:315-324`) accepts `CarHey Android|Hotwire Native Android|Turbo Native Android` and `CarHey iOS|iPhone|iPad|Hotwire Native iOS|Turbo Native iOS`.
155
+
156
+ **Header parsing server-side** — `ClientVersionInfo` concern (`app/controllers/concerns/client_version_info.rb`): platform allow-list `%w[android ios web]`, semver regex, build clamped to Play's 2.1B cap, 64-char OS cap; explicit "spoofable, diagnostics-only, never authorization" doctrine (:7-12). Fallback path delegates to `SignupAttribution` (:66-75). Consumed by `Api::V1::BaseController#touch_last_seen_client` (:32-57) — the throttled, race-safe `update_all` with `IS DISTINCT FROM` predicates that maintains `users.last_seen_*`.
157
+
158
+ **Session-relevant native routes** (`config/routes.rb:84-95`): `/native/entry` (canonical signed-out bootstrap), `/native/handoff` (auth-success), `/native/auth/welcome` (legacy alias), `/native/configurations/{ios,android}/v1` (server-driven path configuration, version-gated auth sheet/handoff rules in `app/controllers/native/configurations_controller.rb:67-75,196-205,344-352,494`). Native bootstrap defensively consumes **stale remember cookies of now-inactive users** before Warden runs: `consume_inactive_native_remembered_user!` reads `cookies.signed["remember_user_token"]` via `User.serialize_from_cookie` without firing strategies (`app/controllers/application_controller.rb:371-417`) — a subtle Devise+native lifecycle edge any session gem must not break.
159
+
160
+ **One cookie = one session across surfaces.** Native JSON requests authenticate with the *same Rails session cookie* as the WebView: Hotwire Native syncs WKWebView cookies into `HTTPCookieStorage.shared` after every page load and the iOS URLSession is explicitly configured to use it (`carhey-ios/RailsFast/Core/NativeHttpClient.swift:75-100`, with source links in-code); Android's OkHttp client follows the same posture. Consequence for the gem: a "device" is one shared cookie jar spanning WebView navigations *and* native HTTP calls — per-request UA strings differ (WebView UA vs `ClientHeaders` UA) while the session identity stays constant, so device identity must key off the session/remember cookie, never off the UA.
161
+
162
+ **Push device registration: none.** No notification code in either shell, no token model, no `/native` push route. (Verified: zero `UNUserNotificationCenter`/`registerForRemoteNotifications`/Firebase hits in either repo.)
163
+
164
+ ## 4. IP & geolocation
165
+
166
+ - `trackdown` pinned `>= 0.2.0`, locked `0.2.0` (`Gemfile:95`, `Gemfile.lock:560`). **No `config/initializers/trackdown.rb` exists** → gem defaults: `provider = :auto` (try Cloudflare headers, fall back to MaxMind; gem source `trackdown-0.2.0/lib/trackdown/configuration.rb:25`), no MaxMind keys configured, so effectively Cloudflare-header-only: reads `HTTP_CF_IPCOUNTRY` / `HTTP_CF_IPCITY` from the request (gem `providers/cloudflare_provider.rb:14-15`).
167
+ - Single call site: signup, synchronous, inline, best-effort (`app/controllers/users/registrations_controller.rb:45-53`). No jobs, no per-login lookups, no geo columns outside `users.signup_country/_code/_city`.
168
+ - `cloudflare-rails 7.0.0` in `group :production` (`Gemfile:86-89`): its railtie prepends `CheckTrustedProxies` into Rack and `RemoteIpProxies` into `ActionDispatch::RemoteIp` (gem `lib/cloudflare_rails/railtie.rb:18-29`), fetching/caching CF IP ranges — so `request.remote_ip` is the real client IP behind Cloudflare in production with zero app code. No hand-rolled `CF-Connecting-IP` middleware. In development/test the gem is absent, so `remote_ip` is the raw socket peer.
169
+ - `request.remote_ip` consumers: registrations (:21), `AuditLog.log` (:38), waitlist (:12). `footprinted` is present but **commented out** (`Gemfile:205`).
170
+
171
+ ## 5. Where the UI would live
172
+
173
+ **End-user**: `get/patch/delete "settings"` inside `authenticate :user` (`config/routes.rb:215-217`) → `SettingsController` (`app/controllers/settings_controller.rb`), `layout "app"`, JSON CSRF-skip for native preference writes (:14). View `app/views/settings/show.html.erb` is a stack of `<section>` blocks with uppercase `h2` labels and `shared/setting_row` partial rows (:49-62, :72), `<details>` accordions deep-linkable via `?section=` (:126), plus a native overflow menu partial (:8). All copy is Spanish. A "Sesiones y dispositivos" section is one more `<section>` of `setting_row`s, or its own route in the same pattern. RailsFast components live at `app/views/components/railsfast` (copy-up-to-customize convention, `.cursor/rules/0-overview.mdc`).
174
+
175
+ **Admin**: madmin resources at `app/madmin/resources/*.rb`, custom fields at `app/madmin/fields/`, drawn at `/admin/dashboard` behind the admin lambda (`config/routes.rb:38-40`), menu groups pre-seeded in `config/initializers/madmin_menu.rb` ("Trust & Safety", position 90). Template to copy: `audit_log_resource.rb` (`menu label: "Audit Logs", parent: "Compliance"`, `default_sort_column = "sequence_id"` desc, RelativeTimeField). `user_resource.rb` already curates security columns: `signup_location`/`signup_attribution` custom fields (:23-25), `last_seen_*` block (:25-32), `failed_attempts`, `last_sign_in_ip` shown, `current_sign_in_*`/`remember_created_at` hidden (:63-71). A `SessionResource`/`LoginActivityResource` slots in alongside with a "Security" parent.
176
+
177
+ ## 6. Conventions (from AGENTS.md → .cursor/rules)
178
+
179
+ `CLAUDE.md` (symlinked as `AGENTS.md`) defers to `.cursor/rules/{0-overview,1-quality,3-project-specifics}.mdc`.
180
+
181
+ - **Stack** (`0-overview.mdc`): RailsFast omakase — Postgres, importmaps/no-build, Tailwind 4 + heroicons, Solid Queue/Cache/Cable (no Redis), Kamal deploys behind Cloudflare, AWS SES mail, madmin admin, goodmail emails, `moderate` for T&S. Inline comments must carry rationale + source URLs ("Document as you go").
182
+ - **Quality** (`1-quality.mdc`): DRY/KISS/YAGNI, "the best part is no part", no enterprise architecture, idiomatic Rails, syntactic sugar valued.
183
+ - **Project specifics** (`3-project-specifics.mdc`): mobile-first Hotwire Native hybrid; minimize native code, maximize Rails-rendered surface; sister repos `../carhey-android`, `../carhey-ios`; design parity native↔web.
184
+ - **Namespacing**: domain modules as dirs — `Ride::` (`app/models/ride/*.rb`), `User::TripFeed` (`app/models/user/trip_feed.rb`), controllers under `users/`, `native/`, `ride/`, `compliance/`; jobs namespaced (`app/jobs/operations/`, `Operations::ReportAlertJob` per `config/initializers/moderate.rb:52`) on Solid Queue.
185
+ - **Mailers**: `*Goodmailer` classes; `DeviseGoodmailer < Devise::Mailer` rebuilds every Devise email with goodmail's DSL (`text`/`button`/`code_box`/`sign`) (`app/mailers/devise_goodmailer.rb`); brand config in `config/initializers/goodmail.rb`.
186
+ - **Testing**: Minitest, `parallelize(workers: :number_of_processors)`, `fixtures :all`, helpers `create_user`/`create_organization_for`, PostGIS SRID bootstrap (`test/test_helper.rb:8-40`).
187
+ - **Logging**: `[SignupAttribution]`/`[HotwireNative]` tagged single-line key=value logs (`application_controller.rb:255-259`, dev-only for native traces).
188
+
189
+ ## 7. Auth/security-adjacent gems (locked versions)
190
+
191
+ | Gem | Version | Where / role |
192
+ |---|---|---|
193
+ | devise | 5.0.4 | `Gemfile:98`; core auth |
194
+ | warden | 1.2.9 | transitive |
195
+ | cloudflare-rails | 7.0.0 | `Gemfile:88` (production group); real IP behind CF |
196
+ | rails_cloudflare_turnstile | 0.4.4 | `Gemfile:92`; bot gate on auth forms, skipped for native |
197
+ | trackdown | 0.2.0 | `Gemfile:95`; signup geolocation via CF headers |
198
+ | device_detector | 1.1.3 | `Gemfile:107`; UA parsing inside SignupAttribution |
199
+ | nondisposable | 0.1.0 | `Gemfile:104`; disposable-email validator on User (`user.rb:142`) |
200
+ | organizations | 0.4.3 | `Gemfile:101`; invitation-aware sign-in branches |
201
+ | moderate | 1.0.0.beta1 | `Gemfile:165`; T&S; `config.audit` → AuditLog |
202
+ | madmin | 2.3.2 | `Gemfile:153`; admin panel |
203
+ | goodmail | 0.4.0 | `Gemfile:162`; transactional email DSL |
204
+ | telegrama | 0.3.0 | `Gemfile:149-150`; admin Telegram alerts |
205
+ | phonelib / twilio-ruby | 0.10.18 / 7.10.5 | onboarding phone OTP |
206
+ | brakeman, bundler-audit | dev group | static security |
207
+
208
+ **Absent** (verified): rack-attack, invisible_captcha, omniauth-*, webauthn, rotp, any devise-* extension, browser gem, footprinted (commented, `Gemfile:205`), api_keys (commented, `Gemfile:212`).
209
+
210
+ ## 8. Gaps the sessions gem must fill
211
+
212
+ 1. **No per-session records.** Devise trackable keeps exactly 2 sign-ins (current/last) on `users` (`db/schema.rb:1269-1286`); history is destroyed on every login.
213
+ 2. **No login-attempt audit.** Failures only increment `failed_attempts` (lockable); no record of who/where/when failed, no AuditLog hook on Warden events. Admin fraud triage relies on signup columns only.
214
+ 3. **No device registry / "your devices" page.** Settings has account/password/deletion but zero session visibility; users cannot see or revoke anything.
215
+ 4. **No remote revocation primitive.** CookieStore sessions can't be invalidated server-side; `expire_all_remember_me_on_sign_out` rotates remember tokens only on an explicit sign-out from a browser that still has the cookie. A stolen 1-year native remember cookie is irrevocable today short of a password change.
216
+ 5. **No "log out everywhere".**
217
+ 6. **No per-login device/geo enrichment.** The excellent UA+geo capture runs once at signup; a user who signs up on desktop and lives in the Android app forever still shows `signup_client: desktop_web`, and sign-in IPs are stored raw, never geolocated.
218
+ 7. **No new-device / new-location notification emails** (DeviseGoodmailer makes adding one cheap).
219
+ 8. **`last_seen_*` is single-slot and native-API-only** — web sessions never update it; two devices overwrite each other.
220
+ 9. **No push/device tokens** — when push ships, it needs a per-device row to hang tokens off; none exists.
221
+ 10. **No session concurrency/limit controls, no impossible-travel or anomaly signals.**
222
+
223
+ ---
224
+
225
+ ## LicenseSeat (second target app: plain-web RailsFast SaaS)
226
+
227
+ `/Users/javi/GitHub/licenseseat` — Rails 8 licensing SaaS, same RailsFast template, **no Hotwire Native shell**, UI in English.
228
+
229
+ - **Auth stack is the same RailsFast wiring**: identical Devise module list incl. `:trackable` (`app/models/user.rb:6-8`), identical 4 custom controllers (`config/routes.rb:2-7`), `DeviseGoodmailer`, Turnstile, madmin drawn at `/admin/dashboard` (`config/routes.rb:36-37`), `users.admin` boolean. Differences: devise **4.9.4** (`Gemfile.lock:167`) and a leaner initializer — `allow_unconfirmed_access_for = 6.hours`, **no** `remember_for`/`extend_remember_period`/`rememberable_options` overrides, **no** custom failure app (verified non-comment lines of `config/initializers/devise.rb`). So the gem must span devise 4.x and 5.x.
230
+ - **Leaner users table**: trackable columns + `signup_ip/_city/_country/_country_code` only — none of CarHey's 18 UA/device/display `signup_*` columns, no `last_seen_*` (users table in `db/schema.rb`, cols at offsets :2-26 of the block). No `SignupAttribution`; `device_detector` not in the Gemfile.
231
+ - **Trackdown 0.3.1 with a full initializer** (`config/initializers/trackdown.rb`): `provider = :auto`, MaxMind account/license from credentials, `db/geodata/GeoLite2-City.mmdb`, `reject_private_ips` in prod, auto-download on boot, plus `TrackdownDatabaseRefreshJob` (`app/jobs/trackdown_database_refresh_job.rb:1-5`). Same single sync call site at signup (`app/controllers/users/registrations_controller.rb:80-87`). This is the MaxMind-backed config shape the gem's soft dependency should also support (vs CarHey's zero-config CF-header mode).
232
+ - **`footprinted` 0.3.1 is ACTIVE** (`Gemfile:181`): `footprints` table (`db/schema.rb:119-160`) is polymorphic geo+device telemetry — `event_type`, `inet ip`, lat/lng, country/city/continent, plus promoted device columns `device_id`, `app_version`, `platform`, `os_name/_version`, `device_type`, `architecture`, `cpu_cores`, `memory_gb`, `jsonb metadata`. Configured async (`config/initializers/footprinted.rb`: `config.async = true`), metadata→column promotion monkey-patch (`config/initializers/footprinted_extensions.rb`), used for **product/licensing telemetry** (DAU/MAU by `device_id`, `app/models/concerns/product_analytics.rb:13-27`; intake via `app/controllers/concerns/license_seat_telemetry.rb`) — *not* for login tracking. It's the closest existing schema to a "device" row in either app.
233
+ - **Token auth exists**: `api_keys` 0.3.0 (git main; `Gemfile:190`) — org-owned `pk_`/`sk_` keys with per-key permissions (`config/initializers/api_keys.rb:1-25`), `api_keys` table with `last_used_at`/`revoked_at`/`token_digest` (`db/schema.rb:45-75`), self-serve UI under `namespace :settings { resources :api_keys ... }` (`config/routes.rb:160-162`) and madmin resources at `app/madmin/resources/api_keys/`. The licensing API authenticates with these keys, not cookies — session tracking must not swallow those requests. `license_seat_activations` even stores `ip_address` per machine activation (`db/schema.rb:192-198`).
234
+ - **Settings layout differs**: single `settings#show` (`config/routes.rb:157`) + a `settings` namespace for sub-resources; views are form partials (`app/views/settings/_account_form.html.erb`, `_organization_form.html.erb`) rather than CarHey's setting_row sections. A sessions page here would naturally be `settings/sessions` inside that namespace.
235
+ - No `moderate`/`AuditLog` (moderate commented at `Gemfile:184`); audit needs are gem-owned (`license_seat_audit_events`, `db/schema.rb:214`).
236
+
237
+ ## Implications for the sessions gem (opinion)
238
+
239
+ 1. **Own the truth in a DB-backed session/device registry** (à la Rails 8 `Session` model): a signed per-device cookie (or session-id claim) checked against a `sessions` row each request. That's the only way to get enumeration + revocation on top of CookieStore — and it must also bind/rotate with Devise's remember-me, since CarHey's native sessions are effectively 1-year remember cookies (`sessions_controller.rb:187-198`). Per-device remember tokens or a session-epoch column on the row is the revocation primitive.
240
+ 2. **Hook Warden, not Devise controllers**: `after_set_user`/`after_authentication`/`before_logout` covers stock + CarHey's manual native branch (which still calls `sign_in`); ship a separate adapter for Rails 8 omakase auth (no Warden there). Never run model callbacks in the hot path — see the read-only `warden.user(run_callbacks: false)` pattern and the stale-remember-cookie bootstrap (`application_controller.rb:360-417`) the gem must coexist with.
241
+ 3. **Record failures too**: subscribe to `warden.authenticate` failure / Devise lockable paths; CarHey's manual branch renders 422 without touching Warden's failure app (`sessions_controller.rb:96-118`), so the gem needs an explicit `track_failed_attempt` seam callable from custom controllers.
242
+ 4. **Adopt the AuditLog.log signature** (`event_type:, data:, user:, request:`) for its event API and `inet ip_address` + `text user_agent` column types; offer an optional sink so apps can tee login events into their own AuditLog.
243
+ 5. **Lift SignupAttribution into the gem as the per-session parser** (device_detector + Client Hints + Hotwire Native tokens incl. configurable app-prefix regexes like `CarHey Android`, plus `bridge-components:` parsing) and honor `X-Client-Platform/Version/Build/OS` with `ClientVersionInfo`-style validation. That turns CarHey's signup-only intelligence into every-session intelligence and lets the gem name devices "CarHey en Pixel 7 (Android 14)".
244
+ 6. **Copy the throttled `update_all ... IS DISTINCT FROM` touch** (`api/v1/base_controller.rb:32-57`) for per-session `last_seen_at` — hot-row safe, callback-free.
245
+ 7. **Geo = trackdown soft dependency, both modes**: sync CF-header path when free (CarHey), async job fallback for MaxMind lookups (LicenseSeat shape); never block sign-in on geo (mirror the rescue at `registrations_controller.rb:45-53`).
246
+ 8. **UI**: ship a controller + plain-Tailwind views/partials apps can render inside their own settings shell (CarHey's `setting_row` sections vs LicenseSeat's `settings` namespace prove one fixed page won't fit); i18n-first (CarHey is Spanish). Provide a madmin resource template mirroring `audit_log_resource.rb`. New-device email should be a plain ActionMailer that apps can override with a `*Goodmailer`.
247
+ 9. **Stay out of token-auth lanes**: skip tracking for `api_keys`-authenticated and other non-cookie requests by default (LicenseSeat's licensing API).
248
+ 10. **Schema suggestion**: `sessions` (per device/browser: user, token/epoch, ip `inet`, ua `text`, parsed client/platform/browser/os/app_version/app_build/device_model, geo country/city, created/last_seen/revoked_at, revoked_reason) + `login_attempts` (success+failure, email-as-typed, user nullable, ip, ua, geo, failure_reason) — append-only like AuditLog but without the hash chain in v1 (advisory-lock serialization is explicitly unsuitable for high-frequency writes per `audit_log.rb` comments echoed in `api/v1/base_controller.rb:29-30`). Use UUID PKs — every table in both apps is `id: :uuid, default: gen_random_uuid()` — but don't hard-require Postgres types if SQLite support matters. Leave a `push_token` column or a `session_devices`-style association point for the push registration neither app has yet.
249
+
250
+ *(Items 1-10 above are recommendations/opinion; sections 1-8 and the LicenseSeat section are observed fact except where marked as inference.)*
@@ -0,0 +1,261 @@
1
+ # rameerez ecosystem conventions & trackdown/footprinted integration spec
2
+
3
+ Research memo for the `sessions` gem (drop-in session & login-activity tracking, device management, Rails 8+).
4
+ Sources: read-only study of 10 local repos — `trackdown`, `footprinted` (deep dives), `usage_credits`,
5
+ `pricing_plans`, `api_keys`, `nondisposable`, `profitable`, `wallets` (mature, trust most), `moderate`, `chats`
6
+ (newer, best for UI-shipping + host hooks). All 10 repos present. Citations are `path:line` under `/Users/javi/GitHub/`.
7
+
8
+ ## Top findings
9
+
10
+ 1. **One config pattern everywhere**: memoized `Configuration.new` + `Gem.configure { |config| }` (`trackdown/lib/trackdown.rb:20-26`, `footprinted/lib/footprinted.rb:15-21`, `wallets/lib/wallets.rb:24-30`). Newer gems add **validating setters** that normalize input and raise plain-English errors at the assignment line — explicitly documented as "the ecosystem-wide convention" (`moderate/lib/moderate/configuration.rb:21-22,277-284`; `chats/lib/chats/configuration.rb:172-237`).
11
+ 2. **Macro grammar is consistent**: ownership = `has_*` (`has_credits`, `has_wallets`, `has_api_keys`, `has_trackable`, `has_reporting_and_blocking`); capability/role = `acts_as_*` (chats); per-field verb = `moderates :body`. There is **no `pays_with`** anywhere — pricing_plans uses `include PricingPlans::PlanOwner` (`pricing_plans/README.md:73-75`). moderate's docstring states the goal: declarations that "sit alongside the rest of a host's stack (`has_credits`, `has_wallets`, `has_api_keys`)" (`moderate/lib/moderate/macros.rb:24`).
12
+ 3. Macros are registered via `ActiveSupport.on_load(:active_record) { extend Gem::Macros }` in the engine (`moderate/lib/moderate/engine.rb:124-128`, `chats/lib/chats/engine.rb:68-72`), each macro a thin `include Concern` forwarder (`moderate/lib/moderate/macros.rb:44-46`). **Footprinted gets this wrong** (`extend Footprinted::Model` of an AS::Concern — ineffective, `footprinted/lib/footprinted/engine.rb:5-9`); its README requires explicit `include Footprinted::Model` (`footprinted/README.md:62-69`). Copy moderate/chats, not footprinted.
13
+ 4. **Install generator is sacred**: `rails g <gem>:install` creates (1) one **adaptive migration copied into the app** (uuid/bigint + jsonb/json detection — never engine-loaded), (2) a fully-annotated initializer, (3) an emoji + numbered-steps post-install message with a yellow "run migrations!" warning (`footprinted/lib/generators/footprinted/install_generator.rb:18-44`, `chats/lib/generators/chats/install_generator.rb:21-53`).
14
+ 5. **Trackdown's soft-probe API**: `Trackdown.locate(ip, request: nil) → LocationResult` (`trackdown/lib/trackdown.rb:32-34`) and `Trackdown.database_exists?` (`trackdown/lib/trackdown.rb:41-43`). There is **no `Trackdown.configured?`** — the sibling contract is `defined?(Trackdown)` + rescue-everything, exactly what footprinted does (`footprinted/lib/footprinted/model.rb:71-92`).
15
+ 6. **Trackdown is graceful in `:auto` mode** (returns `'Unknown'` result, `trackdown/lib/trackdown/providers/auto_provider.rb:59-61`) but **raises on invalid/private IPs** (`trackdown/lib/trackdown/ip_locator.rb:17-21`) and on forced `:maxmind` without a DB (`trackdown/lib/trackdown/providers/maxmind_provider.rb:40`). Geo lookups must always be rescue-wrapped (`footprinted/lib/footprinted/footprint.rb:51-53`).
16
+ 7. **Footprinted's killer async-geo trick**: pre-extract Cloudflare-header geo **at enqueue time** so background workers never need MaxMind (`footprinted/lib/footprinted/model.rb:71-92`, `footprinted/README.md:313-326`); the model's `before_save` geolocation skips if `country_code` is already present (`footprinted/lib/footprinted/footprint.rb:38-40`).
17
+ 8. **Host hooks = the moderate pattern**: `attr_accessor :audit, :notify, :on_block, :ban_handler`, all **no-op lambdas by default**; event-envelope hooks take 1 arg, action hooks take kwargs (`moderate/lib/moderate/configuration.rb:60,115-121`). This is the template for sessions' `on_new_device` / `on_suspicious_login`.
18
+ 9. **No rameerez gem ships a mailer.** Notification fan-out always goes through host hooks ("Sending the actual emails/push (that's goodmail / noticed — moderate just emits events)", `moderate/README.md:136`). Jobs ship inside the gem when internal (`footprinted/app/jobs/footprinted/track_job.rb`) and are **generated into `app/jobs/`** when the host must schedule them (`trackdown/lib/generators/trackdown/install_generator.rb:10-12`, `nondisposable/lib/generators/nondisposable/install_generator.rb:25-27`), documented with solid_queue `config/recurring.yml` snippets (`trackdown/README.md:130-138`).
19
+ 10. **Auth abstraction = configurable method-name symbols with Devise defaults**: `current_owner_method = :current_user` / `authenticate_owner_method = :authenticate_user!` (`api_keys/lib/api_keys/configuration.rb:160-161`), `current_messager_method`/`authenticate_method` (`chats/lib/chats/configuration.rb:136-137`), plus `config.parent_controller` indirection — "the same `parent_controller` indirection Devise and api_keys use" (`moderate/lib/moderate/configuration.rb:126-129`). **No existing gem handles Rails 8 native auth (`Current.session`) explicitly** — a gap `sessions` must close.
20
+ 11. **Table naming**: `<gem>_` prefix (`usage_credits_wallets`, `moderate_reports`, `chats_conversations`); wallets makes it configurable (`@table_prefix = "wallets_"`, `wallets/lib/wallets/configuration.rb:37`). Two unprefixed exceptions: `api_keys` and footprinted's bare `footprints` (`footprinted/lib/footprinted/footprint.rb:5`). Rails 8 omakase auth owns the bare `sessions` table → the gem **must** prefix.
21
+ 12. **UI shipping, two tiers**: full mounted engine UI (api_keys dashboard, profitable dashboard, chats inbox) vs primitives + BYOUI (moderate admin). UI gems ship Devise-style `rails g <gem>:views` ejection (`moderate/lib/moderate/views_generator.rb:7-17`) and chats ships no-build Stimulus via importmap-path `unshift` so host pins win (`chats/lib/chats/engine.rb:114-120`).
22
+ 13. **Models always live inside the gem** (`lib/<gem>/models/` or engine `app/models/`); only migrations are generated into the host. moderate/chats wire `lib/<gem>/models` into the **host's Zeitwerk loader** with `push_dir`/`collapse`/`ignore` (`moderate/lib/moderate/engine.rb:61-99`, `chats/lib/chats/engine.rb:29-52`).
23
+ 14. **Errors**: top-level `Error < StandardError` + meaningful subclasses (`Chats::BlockedError`, `Wallets::InsufficientBalance`); api_keys' `BaseError` (`api_keys/lib/api_keys/errors.rb:6`) is the lone deviation — don't copy.
24
+ 15. **2026-era targets** (moderate 1.0.0.beta1, chats 0.1.1 — the newest): ruby `>= 3.2.0`, deps on `activerecord`/`activesupport`/`railties >= 7.1, < 9.0` rather than full `rails` (`chats/chats.gemspec:15,41-45`, `moderate/moderate.gemspec:15,43-46`); Minitest + Appraisals (7.1/7.2/8.1) + `test/dummy` host app.
25
+
26
+ ## 1. Per-gem conventions table
27
+
28
+ | Gem | Model macro / entry API | Install generator creates | Engine | isolate_namespace | Tables | UI shipped | Jobs | Ruby / framework constraint |
29
+ |---|---|---|---|---|---|---|---|---|
30
+ | trackdown 0.3.1 | `Trackdown.locate(ip, request:)` module fn (`lib/trackdown.rb:32`) | initializer + `TrackdownDatabaseRefreshJob` into `app/jobs/` + `*.mmdb` gitignore; **no migration** (`lib/generators/trackdown/install_generator.rb:6-20`) | **none** (plain module + generator) | n/a | none | none | job template → app | ≥3.0; no rails dep; `countries ~>7.0` only (`trackdown.gemspec:15,38`) |
31
+ | footprinted 0.3.1 | `include Footprinted::Model` + `has_trackable :downloads` (`lib/footprinted/model.rb:12`) | migration + initializer (`lib/generators/footprinted/install_generator.rb:18-25`) | `Rails::Engine` | **no** (`lib/footprinted/engine.rb:4`) | bare `footprints` | none | `Footprinted::TrackJob` in gem | ≥3.0; rails ≥7.0; **trackdown ~>0.3 hard dep** (`footprinted.gemspec:15,38-39`) |
32
+ | usage_credits | `has_credits` (`lib/usage_credits/models/concerns/has_wallet.rb:54`); Kernel DSL `operation`/`credit_pack`/`subscription_plan` (`lib/usage_credits.rb:179-191`) | migration + initializer (`lib/generators/usage_credits/install_generator.rb:17-23`); + `upgrade` generator | Engine **+ Railtie** (view helper, `lib/usage_credits/railtie.rb:9-14`) | yes (`engine.rb:6`) | `usage_credits_*` ×5 | none | `FulfillmentJob` in gem, host schedules (`README.md:115-123`) | ≥3.1; rails ≥6.1 <9.0; pay ≥8.3 <12; wallets ~>0.1 (`usage_credits.gemspec:15,48-50`) |
33
+ | wallets | `has_wallets(**options)` → `user.wallet(:eur)` (`lib/wallets/models/concerns/has_wallets.rb:11,44`) | migration + initializer | Engine + Railtie (`lib/wallets/engine.rb:4-5`) | yes | `wallets_*` via config `table_prefix` (`lib/wallets/configuration.rb:37`) | none | none | ≥3.1; rails ≥6.1 <9.0 (`wallets.gemspec:15,54`) |
34
+ | pricing_plans | `include PricingPlans::PlanOwner`; `has_many :projects, limited_by_pricing_plans:`; `before_action :enforce_api_access!` (`README.md:73-94`) | migration + initializer (nested `lib/generators/pricing_plans/install/install_generator.rb`) | Engine (`lib/pricing_plans/engine.rb:4-5`) | yes | `pricing_plans_*` ×3 | none (view helpers only) | none | **≥3.2**; AR/AS ≥7.1 <9.0 (`pricing_plans.gemspec:15,38-39`) |
35
+ | api_keys | `has_api_keys do max_keys 10 end` kwargs+block DSL (`lib/api_keys/models/concerns/has_api_keys.rb:29-65`); `include ApiKeys::Controller` + `authenticate_api_key!` (`lib/api_keys/authentication.rb:45`) | migration + initializer (`lib/generators/api_keys/install_generator.rb:24-32`); + `add_key_types` generator | Engine, `config.parent_controller` (`lib/api_keys/engine.rb:8-13`) | yes | bare `api_keys` | **full dashboard**: `resources :keys` + revoke + `root keys#index` (`config/routes.rb:3-18`), mount `/settings/api-keys` (`README.md:63`) | `UpdateStatsJob`, `CallbacksJob` in gem | ≥3.1; rails ≥6.1; bcrypt, base58 (`api_keys.gemspec:17,39-41`) |
36
+ | nondisposable | `validates :email, nondisposable: true` (`lib/nondisposable/email_validator.rb:5`); `Nondisposable.disposable?` (`README.md:105`) | migration + initializer + job → `app/jobs/` (`lib/generators/nondisposable/install_generator.rb:17-27`) | Engine (`lib/nondisposable/engine.rb:4-5`) | yes | `nondisposable_disposable_domains` | none | job template → app | ≥3.0; rails ≥7.0 (`nondisposable.gemspec:15,36`) |
37
+ | profitable | module fns: `Profitable.mrr`, `.churn`, `.estimated_valuation(at: "3x")` (`README.md:73-113`) | **no generator** | Engine (`lib/profitable/engine.rb:2-3`) | yes | none | **mounted dashboard**, `root "dashboard#index"` (`config/routes.rb:1-3`); host wraps mount in `authenticate` block (`README.md:58-63`) | none | ≥3.0; pay ≥7.0 (`profitable.gemspec:15,36-37`) |
38
+ | moderate 1.0.0.beta1 | `has_reporting_and_blocking` / `has_reportable_content(*fields)` / `moderates(*fields, mode:, with:)` (`lib/moderate/macros.rb:44,55,77`) | migration + initializer (`lib/generators/moderate/install_generator.rb:18-24`); **+ views generator** | Engine, lib/-models Zeitwerk wiring (`lib/moderate/engine.rb:19-99`) | yes (`engine.rb:20`) | `moderate_*` ×4 | mountable **public forms only** (DSA notices/appeals/transparency); admin BYOUI (`README.md:63`) | `ClassifyJob` in gem; **events, never mailers** (`README.md:136`) | **≥3.2**; AR/AS/railties ≥7.1 <9.0 + globalid (`moderate.gemspec:15,43-46`) |
39
+ | chats 0.1.1 | `acts_as_messager` / `acts_as_chat_subject` (`lib/chats/macros.rb:20-27`); `alice.message!(bob, "hola!")` (`README.md:23`) | migration + initializer (`lib/generators/chats/install_generator.rb:21-27`); **+ views generator** | Engine, same Zeitwerk wiring (`lib/chats/engine.rb:10-52`) | yes (`engine.rb:11`) | `chats_*` ×4 | **full Hotwire UI**: views, 3 Stimulus controllers, CSS, Turbo Streams; `path: ""` mount trick (`config/routes.rb:4-7`) | none (host `config.notifier`) | **≥3.2**; AR/AS/railties ≥7.1 <9.0 + turbo-rails ≥2.0 (`chats.gemspec:15,41-45`) |
40
+
41
+ House-style notes that cut across the table:
42
+
43
+ - **Spine + autoload split** (current best practice): value objects (`version`, `errors`, `configuration`, `macros`, `engine`) are `require_relative`'d from `lib/<gem>.rb`; AR models/jobs live under `lib/<gem>/models|jobs/` and are registered on the host's main Zeitwerk loader (`push_dir(lib/<gem>, namespace: Gem)` + `collapse` + `ignore` of spine files) in an initializer `before: :set_autoload_paths` (`moderate/lib/moderate/engine.rb:61-99`). So `lib/moderate/models/report.rb → Moderate::Report`.
44
+ - Engine loaded conditionally: `require "<gem>/engine" if defined?(Rails)` (`footprinted/lib/footprinted.rb:28`, `wallets/lib/wallets.rb:43-44`).
45
+ - **Class names stored as strings**, constantized lazily, "so the initializer works no matter when the app loads" (`moderate/lib/moderate/configuration.rb:10-12`; `@user_class = "User"` `:84-85`; `@messager_class = "User"` `chats/lib/chats/configuration.rb:134`).
46
+ - Migrations belt-and-suspenders: generator copy is primary, engine also appends its `db/migrate` path (`moderate/lib/moderate/engine.rb:106-112`, `chats/lib/chats/engine.rb:59-65`).
47
+ - Error taxonomies: `Wallets::Error / InsufficientBalance / InvalidTransfer` (`wallets/lib/wallets.rb:17-19`); `UsageCredits::Error / InsufficientCredits / InvalidOperation / InvalidTransfer` (`usage_credits/lib/usage_credits.rb:65-68`); `PricingPlans::FeatureDenied` carries `feature_key`/`plan_owner` context (`pricing_plans/lib/pricing_plans.rb:10-18`); `Chats::Error / ConfigurationError / BlockedError / NotAllowedError` (`chats/lib/chats/errors.rb:6-19`).
48
+ - **README formula** — the section order is identical in all 10:
49
+ 1. Emoji + `` `gem` `` + plain-value title ("👣 `footprinted` - Simple event tracking for Rails apps", `footprinted/README.md:1`)
50
+ 2. Gem Version + Build Status badges (`:3`)
51
+ 3. RailsFast `> [!TIP]` plug (`:5-6`)
52
+ 4. 1-3 sentence pitch, sometimes a live-demo link (`usage_credits/README.md:14`, `api_keys/README.md:10`)
53
+ 5. **"## 👨‍💻 Example"** candy section — reads-like-English snippets before any setup (`usage_credits/README.md:25-84`, `moderate/README.md:18-60`, `chats/README.md:14-39`)
54
+ 6. Quickstart: `gem` → `bundle install` → `rails g <gem>:install` → `rails db:migrate` → macro → (mount) — ends "That's it." (`moderate/README.md:103`, `chats/README.md:69`)
55
+ 7. Feature deep-dives
56
+ 8. "What it does / doesn't do" with an explicit *Doesn't* list delegating to sibling gems (`moderate/README.md:124-139`, `chats/README.md:83-87`)
57
+ 9. "Why this gem exists" rant about DIY plumbing (`pricing_plans/README.md:138-158`, `moderate/README.md:107-122`)
58
+ 10. "Why the models?" schema-justification section (`moderate/README.md:365-376`, `pricing_plans/README.md:161-176`)
59
+ 11. Testing → Development → Contributing with the signature "just be nice and make your mom proud" line (`trackdown/README.md:378`, `chats/README.md:380`) → MIT.
60
+ - **Initializers are documentation**: every generated initializer is a fully-annotated, mostly-commented-out reference manual with `====` section banners (`moderate/lib/generators/moderate/templates/initializer.rb:3-198`, `trackdown/lib/generators/trackdown/templates/trackdown.rb:3-74`, `usage_credits/lib/generators/usage_credits/templates/initializer.rb:1-173`). Footprinted's is the minimal outlier (4 lines, `footprinted/lib/generators/footprinted/templates/footprinted.rb:1-4`). Sessions should ship the annotated kind.
61
+ - **Testing**: Minitest everywhere; Appraisals matrices (footprinted 7.2/8.1 `footprinted/Appraisals:3-9`; moderate & chats 7.1/7.2/8.1; wallets 6.1→8.0; usage_credits/profitable also appraise pay 7.3→11); `test/dummy` host app ("mounts the engine at /messages exactly like a real host", `chats/README.md:376`); chats asserts "every authorization negative … plain 404s; existence never leaks" (`chats/README.md:365`).
62
+ - **Release discipline**: footprinted documents a 3-place version-bump checklist (version.rb + appraisal lockfiles + a hardcoded version test) with CI failing on drift (`footprinted/README.md:383-389`).
63
+ - **gemspec**: authors `["rameerez"]`, email `rubygems@rameerez.com`, MIT, `rubygems_mfa_required`, files via `git ls-files` reject-list (`trackdown/trackdown.gemspec:8-32`).
64
+
65
+ ## 2. trackdown deep dive (our soft dependency)
66
+
67
+ ### Complete public API
68
+
69
+ ```ruby
70
+ Trackdown.configure { |config| ... } # trackdown/lib/trackdown.rb:24-26
71
+ Trackdown.locate(ip, request: nil) # → LocationResult trackdown/lib/trackdown.rb:32-34
72
+ Trackdown.update_database # MaxMind download trackdown/lib/trackdown.rb:37-39
73
+ Trackdown.database_exists? # File.exist?(db path) trackdown/lib/trackdown.rb:41-43
74
+ Trackdown.ensure_database_exists! # legacy raiser trackdown/lib/trackdown.rb:47-51
75
+ ```
76
+
77
+ `LocationResult` (`trackdown/lib/trackdown/location_result.rb:7-9`) returns:
78
+ `country_code` ("US"), `country_name`, `city`, `flag_emoji` ("🇺🇸"), `region` ("California"), `region_code` ("CA"),
79
+ `continent` ("NA"), `timezone` ("America/Los_Angeles"), `latitude`, `longitude`, `postal_code`, `metro_code`.
80
+ Aliases `country` / `emoji` / `emoji_flag` / `country_flag` (`:29-32`); `country_info` → `ISO3166::Country` (`:34-37`); `to_h` (`:39-55`).
81
+ Optional fields are `nil` when the provider lacks them (`trackdown/README.md:195`).
82
+
83
+ Config knobs (`trackdown/lib/trackdown/configuration.rb:23-33`): `provider :auto` (validated against `[:auto, :cloudflare, :maxmind]`, `:21,35-40`), `maxmind_account_id/license_key`, `database_path` default `db/GeoLite2-City.mmdb` (`:27`), `timeout` 3s, `pool_size` 5, `pool_timeout` 3s, `memory_mode MODE_MEMORY` (`:31`), `reject_private_ips true` (`:32`).
84
+
85
+ ### Providers
86
+
87
+ - **Cloudflare**: reads 10 `HTTP_CF_*` request-env headers (`trackdown/lib/trackdown/providers/cloudflare_provider.rb:14-23`); `available?` needs a request with a non-`XX` `CF-IPCountry` (`:31-36`); special codes `XX` unknown / `T1` Tor (`:26-27`). Zero overhead, zero deps.
88
+ - **MaxMind**: `maxmind-db` + `connection_pool` gems are **optional, conditionally required** (`maxmind_provider.rb:7-13`; documented as Gemfile add-ons in `trackdown/trackdown.gemspec:40-45`); `available?` = gem loaded AND db file exists (`:28-33`).
89
+ - **Auto** (default): Cloudflare first — but only if `CF-Connecting-IP` matches the passed IP (upstream-proxy detection, `auto_provider.rb:40-57,66-76`) — else MaxMind, else graceful `LocationResult.new(nil, 'Unknown', 'Unknown', '🏳️')` (`:59-61`).
90
+
91
+ ### Database lifecycle
92
+
93
+ `Trackdown.update_database` streams the GeoLite2-City tar.gz with HTTP basic auth and extracts the `.mmdb` to `database_path` (`trackdown/lib/trackdown/database_updater.rb:7-39`), mapping 401/403 to friendly messages (`:40-50`). The generator writes the initializer, a `TrackdownDatabaseRefreshJob` (7-line ApplicationJob calling `Trackdown.update_database`, `templates/trackdown_database_refresh_job.rb:1-7`), and gitignores `*.mmdb` (`install_generator.rb:14-20`). Scheduling: solid_queue `config/recurring.yml`, "every Saturday at 4am" (`trackdown/README.md:130-138`); Docker = persistent volume or download-on-boot (`README.md:302-355`).
94
+
95
+ ### Failure modes & thread-safety
96
+
97
+ - `:auto` with no providers → 'Unknown' + **once-per-process** warning behind `@@warn_mutex` (`auto_provider.rb:25-27,113-131`) — README: "Trackdown fails gracefully… so your app doesn't crash due to a missing geolocation provider" (`trackdown/README.md:49-50`).
98
+ - Forced `:maxmind` without DB → raises `Trackdown::Error` (`maxmind_provider.rb:40-41`).
99
+ - Invalid IP → `IpValidator::InvalidIpError` (`trackdown/lib/trackdown/ip_validator.rb:7-17`); **private/loopback IPs raise too** when `reject_private_ips` (`trackdown/lib/trackdown/ip_locator.rb:19-21`) — fires constantly in dev, so callers must rescue.
100
+ - Lookup timeout → `MaxmindProvider::TimeoutError`; DB problems → `DatabaseError` (`maxmind_provider.rb:20-21,76-83`).
101
+ - Perf: class-level `ConnectionPool` of `MaxMind::DB` readers built lazily under `@@pool_mutex` (`maxmind_provider.rb:23-24,85-99`); per-lookup `Timeout.timeout` (`:70-77`); DB in RAM by default. **Background jobs have no request → no CF headers → MaxMind or nothing** (`trackdown/README.md:356-368`).
102
+
103
+ ### The precise soft-integration contract for `sessions`
104
+
105
+ 1. **No hard dependency** (footprinted hard-depends, `footprinted/footprinted.gemspec:39` — sessions diverges by design). Guard call sites with `defined?(Trackdown)` exactly as `footprinted/lib/footprinted/model.rb:72` does (`return unless request && defined?(Trackdown)`), and rescue + log everything (`footprinted/lib/footprinted/footprint.rb:51-53`): a geo failure must never block a login write.
106
+ 2. **Always pass `request:` through** — `Trackdown.locate(ip.to_s, request: request)` (`footprinted/lib/footprinted/footprint.rb:42`) — so Cloudflare is used whenever present.
107
+ 3. **Sync vs async**: extract synchronously at request time when CF headers exist (free header read); when persisting async, pre-enrich attrs **before enqueue** (footprinted's `enrich_with_geo_data!`, `model.rb:31,56,71-92`) so workers don't need MaxMind. Skip lookup if `country_code` already present (`footprint.rb:39`).
108
+ 4. **Columns to store** — footprinted's proven set/types (`footprinted/lib/generators/footprinted/templates/create_footprinted_footprints.rb.erb:6-14`): `t.inet :ip`; `country_code` string limit 2 (indexed, `:30`); `country_name`; `city`; `region`; `continent` limit 2; `timezone`; `latitude`/`longitude` decimal precision 10 scale 7. Skip `postal_code`/`metro_code`/`region_code` (footprinted skips them too). Flag emoji is derivable at render time from `country_code`, no column needed (`Trackdown.locate(...).emoji`, or ISO3166).
109
+
110
+ ## 3. footprinted deep dive (closest sibling in shape)
111
+
112
+ ### API & macro mechanics
113
+
114
+ ```ruby
115
+ class Product < ApplicationRecord
116
+ include Footprinted::Model # base has_many :footprints, as: :trackable (model.rb:7-9)
117
+ has_trackable :downloads # scoped assoc + track_download (model.rb:12-43)
118
+ end
119
+ @product.track_download(ip: request.remote_ip, request: request,
120
+ performer: current_user, metadata: { v: "2.1" },
121
+ occurred_at: 2.hours.ago) # model.rb:21, README.md:121-129
122
+ @user.track(:signup, ip: request.remote_ip) # generic, model.rb:46-67
123
+ ```
124
+
125
+ `has_trackable :downloads` ⇒ association `.downloads` (where `event_type: "download"`) + method `track_download` (pluralize/singularize table, `footprinted/README.md:111-117`). Scopes: `by_event`, `by_country`, `recent`, `between`, `last_days`, `performed_by`; class methods `event_types`/`countries` (`footprinted/lib/footprinted/footprint.rb:17-30`).
126
+
127
+ ### Schema (`create_footprinted_footprints.rb.erb:5-31`)
128
+
129
+ ```ruby
130
+ create_table :footprints, id: primary_key_type do |t|
131
+ t.inet :ip
132
+ t.string :country_code, limit: 2; t.string :country_name; t.string :city
133
+ t.string :region; t.string :continent, limit: 2; t.string :timezone
134
+ t.decimal :latitude, precision: 10, scale: 7; t.decimal :longitude, precision: 10, scale: 7
135
+ t.references :trackable, polymorphic: true, null: false # the thing acted on
136
+ t.references :performer, polymorphic: true # who did it (optional)
137
+ t.string :event_type, null: false
138
+ t.jsonb :metadata, null: false, default: {} # GIN-indexed (:31)
139
+ t.datetime :occurred_at, null: false
140
+ t.timestamps
141
+ end
142
+ # + composite idx [trackable_type, trackable_id, event_type, occurred_at] (:26-27)
143
+ ```
144
+
145
+ Adaptive keys via in-migration `primary_and_foreign_key_types` reading `Rails.configuration.generators` (`:36-42`).
146
+
147
+ ### Capture & trackdown usage
148
+
149
+ - IP is **explicitly passed** (`ip: request.remote_ip`) — no controller hook or middleware. **No user_agent column**: device/OS/app-version data goes in JSONB `metadata` (`footprinted/README.md:193-204`), with a documented "promote hot metadata keys to real columns in the host app" recipe for scale (`footprinted/README.md:236-282`).
150
+ - Geolocation is a model concern: `before_save :set_geolocation_data` (`footprint.rb:15`) → guards (`country_code` blank, `ip` present) → `Trackdown.locate(ip.to_s, request: @_request)` → copy 8 fields → rescue + `Rails.logger.error` (`footprint.rb:38-53`). The request rides a transient ivar set by the track method (`model.rb:38`) — never persisted.
151
+ - Async (`config.async`, sole config knob, `footprinted/lib/footprinted/configuration.rb:5-9`): enqueue `Footprinted::TrackJob.perform_later(class_name, id, attrs)` with `occurred_at.iso8601` (`model.rb:30-35`); job re-parses and `create!`s (`footprinted/app/jobs/footprinted/track_job.rb:7-22`). If `country_code` is known, pass it and geolocation is skipped (`footprinted/README.md:345-349`).
152
+
153
+ ### Mirror vs diverge for `sessions`
154
+
155
+ **Mirror**: geo column set + adaptive migration; pass-`request:`-through; rescue-wrapped enrichment; pre-enqueue CF extraction; polymorphic owner; chainable scopes (`downloads.by_country("US").last_days(30).count`, `footprinted/README.md:20`); post-install message tone (`install_generator.rb:27-44`, "Happy tracking! 👣").
156
+
157
+ **Diverge**: footprints are **append-only events** — presence validations only, no state, no revoke (`footprint.rb:10-12`). Sessions are **stateful & revocable**: needs `last_seen_at`, `revoked_at`, token/device digests, uniqueness constraints, an `active` scope, `revoke!` verbs. Footprinted has no UI, no controller concern, no auth coupling — sessions needs all three. UA/device fields should be **first-class columns** (the devices page queries them), heeding footprinted's own promote-to-columns advice. And table naming cannot follow footprinted's bare-noun liberty (§5/implication 2).
158
+
159
+ ## 4. Cross-gem idioms: the candy
160
+
161
+ ```ruby
162
+ @user.give_credits(100, reason: "referral") # usage_credits/README.md:360
163
+ @user.spend_credits_on(:process_image, size: 5.megabytes) { ... } # usage_credits/README.md:259
164
+ subscription_plan(:pro) { gives 1_000.credits.every :month } # usage_credits/README.md:64-67
165
+ user.wallet(:mb).transfer_to(friend.wallet(:mb), 3_072) # wallets/README.md:33
166
+ current_user.block!(@other_user); current_user.blocks?(@other) # moderate/README.md:31-33
167
+ alice.message!(bob, "hola!") # chats/README.md:23
168
+ validates :email, nondisposable: true # nondisposable/README.md:13
169
+ Trackdown.locate('8.8.8.8').emoji # => '🇺🇸' # trackdown/README.md:169-171
170
+ @file.track_download(ip: request.remote_ip) # footprinted/README.md:17
171
+ @product.downloads.by_country("US").last_days(30).count # footprinted/README.md:20
172
+ before_action :enforce_api_access! # pricing_plans/README.md:94
173
+ Profitable.mrr.to_readable # => "$1,234" # profitable/README.md:119
174
+ ```
175
+
176
+ Shared grammar: bang verbs for state changes (`block!`, `resolve!`, `message!`), `?` predicates (`blocks?`, `has_enough_credits_to?`, `database_exists?`), kwargs over positionals, symbols for domain nouns, chainable scopes, numeric sugar where it reads like English (`1_000.credits.every :month` — with an honest "Kernel pollution" disclosure, `usage_credits/README.md:895`, `usage_credits/lib/usage_credits.rb:179-191`).
177
+
178
+ **Generator UX**: green emoji headline ("🎉 The `footprinted` gem has been successfully installed!"), "To complete the setup:" numbered steps, yellow `⚠️ You must run migrations before starting your app!`, inline code of the macro + mount line, green sign-off (`footprinted/lib/generators/footprinted/install_generator.rb:27-44`; `chats/.../install_generator.rb:29-53`; `api_keys/.../install_generator.rb:35-71`).
179
+
180
+ ## 5. Host hooks — the template for `on_new_device` / `on_suspicious_login`
181
+
182
+ moderate's `Configuration` is the leading pattern (`moderate/lib/moderate/configuration.rb:60,115-121`):
183
+
184
+ ```ruby
185
+ attr_accessor :audit, :notify, :on_block, :ban_handler # :60
186
+ # defaults — "every hook defaults to a no-op, so the gem works untouched" (:18-19)
187
+ @audit = ->(_event) {} # :118
188
+ @notify = ->(_event) {} # :119
189
+ @on_block = ->(blocker:, blocked:, at:) {} # :120 action hooks take kwargs
190
+ @ban_handler = ->(user:, by:, reason:) {} # :121
191
+ ```
192
+
193
+ What the host writes (`moderate/lib/generators/moderate/templates/initializer.rb:96,115-124,133,144`):
194
+
195
+ ```ruby
196
+ config.audit = ->(event) { AuditLog.record!(event_type: event.name, data: event.payload) }
197
+ config.notify = ->(event) do
198
+ case event.name
199
+ when :report_received, :report_decision
200
+ ModerationMailer.with(event: event).public_send(event.name).deliver_later # goodmail
201
+ when :content_flagged
202
+ Telegrama.send_message("🚩 #{event.payload[:summary]}") # admin alert
203
+ end
204
+ end
205
+ config.on_block = ->(blocker:, blocked:, at:) { CancelPendingInvites.call(blocker, blocked, at: at) }
206
+ config.ban_handler = ->(user:, by:, reason:) { user.suspend!(reason: reason) }
207
+ ```
208
+
209
+ Event envelope: `event.name / .subject / .actor / .recipients / .payload / .to_h` (`initializer.rb:88-94`); fixed event vocabulary listed in the initializer (`:106-110`: `:report_received`, `:report_decision`, `:user_blocked`, `:user_banned`, `:content_flagged`, `:content_removed`, …).
210
+
211
+ Variants across the ecosystem:
212
+
213
+ - **chats**: a single notifier `config.notifier = ->(event, **payload) {}` — README insists on `**payload` since payloads vary per event, and the hook is "error-isolated and logged" (`chats/README.md:209-227`, `chats/lib/chats/configuration.rb:162`).
214
+ - **usage_credits / wallets**: block-setters with a `ctx` struct — `config.on_low_balance_reached do |ctx|` where `ctx.owner / .wallet / .amount / .previous_balance / .new_balance / .transaction / .metadata / .to_h` (`usage_credits/README.md:283-346`). Full vocabulary: `on_credits_added`, `on_credits_deducted`, `on_low_balance_reached`, `on_balance_depleted`, `on_insufficient_credits`, `on_credit_pack_purchased`, `on_subscription_credits_awarded` (`usage_credits/README.md:294-302`); wallets mirrors with `on_balance_credited/debited`, `on_transfer_completed`, etc. (`wallets/lib/wallets/configuration.rb:20-25,88-110`).
215
+ - **pricing_plans**: per-key registration — `config.on_block(:projects) { |ctx| ... }`, `on_warning/on_grace_start/on_block(limit_key = nil, &block)` (`pricing_plans/lib/pricing_plans/configuration.rb:125-146`).
216
+ - **api_keys**: lifecycle callbacks enqueued **asynchronously** via `CallbacksJob` so they never block authentication (`api_keys/lib/api_keys/authentication.rb:48-49,84`).
217
+
218
+ Cross-gem seam to copy verbatim — one line wires two gems, "no hard dependency in either direction" (`chats/README.md:115-124`):
219
+
220
+ ```ruby
221
+ config.blocked_messager_ids = ->(user) { Moderate.blocked_ids_for(user) }
222
+ ```
223
+
224
+ Universal rules: no-op defaults so the gem works untouched; keep hooks fast / enqueue jobs inside (`usage_credits/README.md:308-309`); callback errors are isolated and never break the core operation (`usage_credits/README.md:306`).
225
+
226
+ ## 6. Auth abstraction: Devise + Rails 8 native auth
227
+
228
+ - **Configurable method symbols, Devise defaults**: `@current_owner_method = :current_user # Default to current_user for backward compatibility` / `@authenticate_owner_method = :authenticate_user! # Default … for Devise compatibility` (`api_keys/lib/api_keys/configuration.rb:160-161`); the engine controller invokes them dynamically (`api_keys/app/controllers/api_keys/application_controller.rb:16-25`). chats: `current_messager_method :current_user`, `authenticate_method :authenticate_user!` (`chats/lib/chats/configuration.rb:136-137`); "Devise works out of the box" (`chats/README.md:69`).
229
+ - **`parent_controller` indirection**: chats defaults `"::ApplicationController"` so the engine inherits host layout/auth/locale (`chats/lib/chats/configuration.rb:135`, `:29-31` "the same pattern api_keys uses"); moderate defaults `"::ActionController::Base"` for its public forms — "exactly the `config.parent_controller` trick Devise and `api_keys` use" — resolved at class-definition time with a NameError fallback for API-only apps (`moderate/app/controllers/moderate/application_controller.rb:8-35`).
230
+ - **Graceful viewer detection** in view helpers: `return current_user if respond_to?(:current_user); nil`, override point `moderate_current_viewer` (`moderate/app/helpers/moderate/engine_helper.rb:124-131`); admin actor `moderation_actor → current_user`, overridable in one method (`moderate/app/controllers/concerns/moderate/moderation.rb:85-91`).
231
+ - **Gap**: no gem mentions Rails 8 omakase auth (`Current.session`, the generated `Session` model, `authenticate` concern) or OAuth. moderate's "Doesn't" list literally says "Authentication / current-user (that's Devise — you tell `moderate` your user class)" (`moderate/README.md:135`).
232
+ - **Hotwire Native precedent**: per-request skip procs sniffing `request.user_agent.to_s.match?(/Hotwire Native/i)` (`moderate/lib/generators/moderate/templates/initializer.rb:155-161`) and host-owned native path-configuration guidance (`moderate/README.md:213`).
233
+
234
+ The complete host-integration block chats documents — the closest existing model for sessions' initializer (`chats/README.md:279-318`, defaults at `chats/lib/chats/configuration.rb:133-169`):
235
+
236
+ ```ruby
237
+ Chats.configure do |config|
238
+ config.messager_class = "User"
239
+ # Controller integration (Devise-compatible defaults)
240
+ config.parent_controller = "::ApplicationController"
241
+ config.current_messager_method = :current_user
242
+ config.authenticate_method = :authenticate_user!
243
+ config.layout = nil # nil inherits the parent controller's
244
+ # Feature flags, limits, policies (procs), ecosystem seams (no-op procs)…
245
+ config.blocked_messager_ids = ->(messager) { [] }
246
+ config.notifier = ->(event, **payload) {}
247
+ end
248
+ ```
249
+
250
+ ## Implications for the sessions gem
251
+
252
+ 1. **Macro**: `has_sessions` on the auth model (grammar of `has_credits`/`has_api_keys`); registered via `ActiveSupport.on_load(:active_record) { extend Sessions::Macros }`; macro = thin `include` forwarder with moderate-quality docstrings (`moderate/lib/moderate/macros.rb:35-46`). Optional kwargs/block DSL like api_keys if knobs emerge.
253
+ 2. **Hard naming constraint**: Rails 8 native auth owns the bare `sessions` table and the `Session` constant. Prefix everything: e.g. `sessions_logins` (append-only attempts, footprints-shaped) + `sessions_devices`/`sessions_records` (stateful). Avoid `Sessions::Session` ↔ host `::Session` confusion in docs. Migration must be adaptive (uuid/bigint + jsonb/json, `create_footprinted_footprints.rb.erb:36-42`).
254
+ 3. **Two-table shape**: (a) login-activity audit trail mirroring footprinted's schema (geo columns, polymorphic user, jsonb metadata, occurred_at, composite index, success/failure as event/status); (b) a revocable device/session table footprinted has no precedent for — `last_seen_at`, `revoked_at`, token digest, **first-class UA/platform/device columns** (per footprinted's promote-to-columns advice, `footprinted/README.md:236-247`).
255
+ 4. **Trackdown**: soft dependency per the §2 contract — `defined?(Trackdown)` guard, rescue-everything, `request:` pass-through, skip-if-`country_code`, CF-sync/MaxMind-async split, store the 9 footprinted geo fields. README gets footprinted's IMPORTANT call-out box pointing at trackdown setup (`footprinted/README.md:56-57`).
256
+ 5. **Config**: `Sessions.configure do |config|` with validating setters; `user_class = "User"` stored as string; Devise-compatible defaults (`current_user_method :current_user`, `authenticate_method :authenticate_user!`) **plus a Rails-8-native resolver chain** (configured symbol → `current_user` → `Current.session&.user`) — the first rameerez gem to close that gap; `parent_controller "::ApplicationController"` for the devices page.
257
+ 6. **Hooks**: `config.on_new_device = ->(user:, session:, request:) {}`, `config.on_suspicious_login = ->(user:, session:, reasons:) {}`, `config.on_session_revoked = ->(...) {}` — kwargs, no-op defaults, error-isolated, `ensure_callable` setters (`chats/lib/chats/configuration.rb:231-237`); optionally a 1-arg `notify`/`audit` envelope pair if the event vocabulary grows (moderate §5). Never ship mailers; point at goodmail/noticed.
258
+ 7. **UI**: ship the "your devices" page chats-style — isolated engine, `mount Sessions::Engine => "/settings/sessions"`, `path: ""` root resources, semantic `sessions-*` CSS classes + variables, `rails g sessions:views` ejection (`moderate/lib/generators/moderate/views_generator.rb:7-17`), any JS as importmap-unshifted Stimulus (`chats/lib/chats/engine.rb:114-129`). Admin audit trail = primitives + BYOUI/madmin recipe (`moderate/README.md:288-323`).
259
+ 8. **Generator**: `rails g sessions:install` = adaptive migration + annotated initializer + emoji/numbered/yellow-warning post-install ending green; if a geo-refresh or session-pruning job is needed, generate it into `app/jobs/` with a `config/recurring.yml` snippet in the README (trackdown/nondisposable pattern).
260
+ 9. **Gemspec/CI**: ruby ≥3.2; `activerecord`/`activesupport`/`railties` constraints (≥7.1 or ≥8.0 given the Rails-8-first goal — moderate/chats prove a 7.1 floor is cheap); Minitest + Appraisals + `test/dummy` mounting the engine; errors `Sessions::Error < StandardError` with `ConfigurationError` etc.
261
+ 10. **Hotwire Native device intelligence** is the differentiator: existing precedent is only UA-regex seams (`moderate/.../initializer.rb:155-161`) — sessions should make platform/native-app detection first-class (parsed columns + predicates), not a metadata afterthought.