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
data/docs/PRD.md ADDED
@@ -0,0 +1,743 @@
1
+ # `sessions` — Product Requirements Document
2
+
3
+ > **Status**: Draft v1 for review (Javi). **Date**: 2026-06-11.
4
+ > **Research base**: every claim and shape in this document is backed by the nine research memos in [`docs/research/`](research/) — read-only audits of CarHey, LicenseSeat, the rameerez gem ecosystem, rails/rails (v8.1.3 + main), Devise 5.0.4 + Warden + devise-security, OmniAuth + google_sign_in + webauthn-ruby, authtrail + authie + authentication-zero + Rodauth, the `browser`/`device_detector` parsers, hotwire-native-ios/android, plus live web research (all sources fetched 2026-06-10/11, exact URLs inline and in the memos). Citations below use the form `(→ research/NN §X)` plus direct URLs where load-bearing.
5
+
6
+ ---
7
+
8
+ ## 0. TL;DR
9
+
10
+ **`sessions` is the missing session layer for Rails.** Rails 8's omakase auth generator creates a database-backed `sessions` table with `ip_address` and `user_agent` on every row — and then ships zero UI, no session listing, no per-device revocation, no failed-login log, no device intelligence, and never touches a row after creation (→ research/03 §2). Devise — still growing at ~239k downloads/day (→ research/08 §Adoption) — stores even less: two sign-in slots on the `users` table, overwritten on every login. Meanwhile Laravel ships "Browser Sessions" in its starter kit, Phoenix's `mix phx.gen.auth` tracks every session token in a table, and OWASP ASVS makes "users can view and terminate any or all currently active sessions" a literal Level-2 requirement (ASVS 4.0.3 §3.3.4, 5.0 §7.5.2) (→ research/09).
11
+
12
+ `sessions` gives every Rails 8+ app, in one `bundle add` + one generator + one `has_sessions` macro:
13
+
14
+ 1. **A live device registry** — every active session, enriched with parsed device intelligence ("Chrome 137 on macOS", "CarHey 2.4.1 on iPhone 15 Pro (iOS 19.5)"), IP geolocation (via `trackdown`, soft dependency), auth method ("via Google", "via passkey"), and throttled last-seen tracking.
15
+ 2. **Per-session remote revocation** — "log out of that device", "sign out everywhere else" — that actually works on Rails 8 omakase auth (destroy the row), on Devise (token-per-row generalization of devise-security's proven `session_limitable` mechanism), and on remember-me cookies.
16
+ 3. **An append-only login-activity trail** — every successful *and failed* login attempt, logout, and revocation, with attempted identity, device, geo, and failure reason — linked to the live session it created (the linkage no prior art has, → research/06 §Improve).
17
+ 4. **A drop-in "Your devices" page** — engine-mounted, Tailwind-friendly, i18n'd (en + es), matching the GitHub/Google/Stripe UX contract — plus admin-grade scopes for fraud triage and a madmin recipe.
18
+ 5. **First-class citizenship for every 2026 login method**: Rails 8 native auth, Devise (4.x & 5.x), OmniAuth/OAuth (with failed-OAuth capture), Google One Tap (FedCM-era), Sign in with Apple, passkeys, magic links — via automatic classification plus a one-line `Sessions.tag` API for the flows that can't self-identify (→ research/05).
19
+ 6. **Hotwire Native device intelligence** — platform/OS/app-version/device-model detection out of the box, a documented UA convention + 3-line client snippets to make it perfect (→ research/07 §B).
20
+
21
+ The gem **decorates the session of record; it never becomes it** (the #1 lesson from prior art: authie owned auth-session storage and died at 245 stars; authtrail decorated Devise and got 4.1M downloads, → research/06). Tracking is error-isolated and can never break login. Privacy is a feature: configurable retention with a purge job (CNIL 6–12 months), optional IP truncation, no fingerprinting ever (→ research/09 §Privacy).
22
+
23
+ **The name `sessions` is unregistered on RubyGems** (API 404, 2026-06-10), and no living competitor occupies the space (→ research/09 §Demand).
24
+
25
+ ---
26
+
27
+ ## 1. Why now: the 2026 Rails auth landscape
28
+
29
+ ### 1.1 Rails 8 omakase auth: a substrate, deliberately unfinished
30
+
31
+ - Rails 8.0 (2024-11-07) shipped `bin/rails generate authentication`: a `Session < ApplicationRecord` model (2 lines), a `sessions` table (`user:references ip_address:string user_agent:string`), an `Authentication` concern, and a signed **permanent** cookie holding the Session row id — `cookies.signed.permanent[:session_id]`, httponly, SameSite=Lax, 20-year expiry ([authentication.rb.tt](https://github.com/rails/rails/blob/main/railties/lib/rails/generators/rails/authentication/templates/app/controllers/concerns/authentication.rb.tt), → research/03 §1).
32
+ - **Every request resolves the row** (`Session.find_by(id: cookies.signed[:session_id])`), so *destroying a row is instant remote revocation*. Rails built the revocation primitive and shipped no product on top of it: the only generated route is singular `resource :session` — no index, no devices page; ip/user_agent are written once and never read again; **no code path ever UPDATEs a session row**, so `updated_at == created_at` forever (grep-verified, → research/03 §2).
33
+ - The punchline: Rails' own security guide recommends an `updated_at`-based `Session.sweep` for expiry ([security guide §sessions](https://guides.rubyonrails.org/security.html)) **that the generated code can never satisfy because nothing touches `updated_at`** (→ research/03 §5). The framework documents the hole this gem fills.
34
+ - The gaps are **policy, not backlog**. DHH on the PR: *"This is not intended to be an all-singing, all-dancing answer to every possible authentication concern… do not expect magic links or passkeys or 2FA. That's not going to happen with this generator"* ([rails/rails#52328](https://github.com/rails/rails/pull/52328)). Rails 8.1 (2025-10-22) added zero auth features (only: password reset now `sessions.destroy_all`s, and passwords#create got `rate_limit`); 8.2 edge adds only Argon2 + Sec-Fetch-Site CSRF. The `Authentication` concern is **byte-identical from 8.0.5 through 8.1.3 to main** — an exceptionally stable instrumentation target (→ research/03 §4, research/08 §Timeline).
35
+
36
+ ### 1.2 Devise: the compounding installed base
37
+
38
+ - Devise is **not dying — it's growing**: 280.9M total downloads, v5.0.4 (2026-05-08), actively maintained (HEAD committed 2026-06-10), daily installs up ~20–25% from mid-2024 to mid-2026 (BestGems API, → research/08 §Adoption). Realistic 2026 model: new apps start on the Rails 8 generator, the Devise base keeps compounding — **two large installed bases, both lacking session/device management**.
39
+ - Devise's `:trackable` stores exactly two sign-ins (current + last) *on the users row*; history is destroyed on every login. Its cookie sessions are server-side unenumerable and unrevocable (Devise issues [#5262](https://github.com/heartcombo/devise/issues/5262), [#5027](https://github.com/heartcombo/devise/issues/5027), [#4607](https://github.com/heartcombo/devise/issues/4607) — users have asked for a decade, → research/09 §Demand).
40
+ - The attachment surface is proven and frozen-stable: Warden 1.2.9 (unchanged since 2020) class-level hooks; authtrail (4.1M downloads) runs entirely on two of them; devise-security's `session_limitable` is a complete 55-line revocation mechanism whose only structural flaw is one-token-per-user — moving the token to a row generalizes it to N devices (→ research/04 §5).
41
+
42
+ ### 1.3 OAuth and modern login methods
43
+
44
+ - OmniAuth 2.x deliberately stops at `env['omniauth.auth']` and lets the app create the session — **OAuth logins always land in the same app-side seam the gem already hooks**; failures funnel through a swappable `OmniAuth.config.on_failure` we can compose-wrap to capture provider + error type + IP/UA for every failed OAuth attempt (→ research/05 §1).
45
+ - Google One Tap went **FedCM-mandatory in August 2025** (even though Chrome kept third-party cookies after the April 2025 reversal); the One Tap POST includes a `select_by` field that tells us exactly *how* the user signed in (`fedcm_auto` vs `btn` vs …) — recordable gold (→ research/05 §4).
46
+ - Apple's guideline 4.8 still effectively forces a privacy-preserving login option on any iOS app with third-party login — `apple` must be a first-class provider (→ research/05 §5).
47
+ - Passkeys: `webauthn-ruby` is healthy; the omakase story (`webauthn-rails`) funnels into `start_new_session_for` — our hook fires automatically, only the method label needs tagging. Devise still has no built-in passkeys. Rails core has none planned (→ research/05 §6).
48
+ - Conclusion: **no flow self-identifies at the session-row level** → the gem needs automatic classification (omniauth env, warden strategy class) *plus* an explicit `Sessions.tag` annotate API (→ research/05 §Implications).
49
+
50
+ ### 1.4 Hotwire Native
51
+
52
+ - Hotwire Native UA construction is deterministic and documented in SDK source: iOS *appends* `"Hotwire Native iOS; Turbo Native iOS; bridge-components: […]"`; Android *prepends* its segment to the stock Chromium WebView UA. `turbo_native_app?` matches `/(Turbo|Hotwire) Native/` (→ research/07 §B).
53
+ - **Android WebView is exempt from Chrome's UA reduction** — native Android UAs still carry real device model + OS version for free. iOS carries real OS version but never the hardware model; app version appears nowhere by default. CarHey's native HTTP clients already use a richer convention (`"CarHey Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)"` + validated `X-Client-*` headers) that the gem should formalize (→ research/07 §B, research/01 §3).
54
+
55
+ ### 1.5 The gap, in one table
56
+
57
+ Condensed from the full prior-art matrix (→ research/06 §5):
58
+
59
+ | Capability | rails8 gen | devise +trackable | authtrail | authie | auth-zero | rodauth |
60
+ |---|---|---|---|---|---|---|
61
+ | Live session registry | rows, no UI | ✗ | ✗ (log only) | ✓ | ✓ | ✓ (no UA/IP) |
62
+ | Per-session remote revocation | primitive only | ✗ | ✗ | ✓ | ✓ | ✓ |
63
+ | Failed-attempt log w/ identity | ✗ | ✗ | ✓ | ✗ | ✗ | partial |
64
+ | Log ↔ live-session linkage | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
65
+ | Device/UA parsing | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
66
+ | Geolocation | ✗ | ✗ | ✓ | partial | ✗ | ✗ |
67
+ | Last-active touching | ✗ | partial | n/a | every request (!) | ✗ | ✓ |
68
+ | End-user devices UI | ✗ | ✗ | ✗ | ✗ | ✓ (static) | ✗ |
69
+ | New-device notification | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
70
+ | Multi-auth-system support | own only | devise only | warden only | own only | own only | own only |
71
+ | Hotwire Native awareness | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
72
+ | Automated retention | ✗ | n/a | ✗ | partial | ✗ | partial |
73
+
74
+ **Nobody owns the middle ground** of registry + trail + revocation + UI over *existing* auth. The bottom three rows are uncontested everywhere.
75
+
76
+ ---
77
+
78
+ ## 2. Market validation
79
+
80
+ ### 2.1 Cross-framework precedent (the strongest signal)
81
+
82
+ - **Laravel**: the default starter migration creates a sessions table with `user_id`, `ip_address`, `user_agent`, `last_activity`; Jetstream ships "Browser Sessions" ("logout other browser sessions"); the framework itself ships `Auth::logoutOtherDevices($password)` ([laravel.com/docs/12.x/session](https://laravel.com/docs/12.x/session), [jetstream.laravel.com/features/browser-sessions.html](https://jetstream.laravel.com/features/browser-sessions.html), → research/09 §Laravel).
83
+ - **Phoenix**: `mix phx.gen.auth` tracks every session token in a `users_tokens` table — "you could even expose this information to users if desired"; password change deletes all tokens ([hexdocs.pm/phoenix/mix_phx_gen_auth.html](https://hexdocs.pm/phoenix/mix_phx_gen_auth.html)).
84
+ - **Django**: `django-user-sessions` (Jazzband) proved demand, then went stale (last release 2020) — the maintenance gap a fresh gem avoids.
85
+ - Rails ships the schema; **nobody ships the product**.
86
+
87
+ ### 2.2 Every serious Rails app hand-rolled it
88
+
89
+ - **GitLab**: `ActiveSession` (Redis), 100-session cap, per-row revoke that *also revokes remember-me tokens*, device parsing via a hardened `DeviceDetector` subclass (UA truncated at 1024) (→ research/09 §GitLab).
90
+ - **Mastodon**: `SessionActivation` (Postgres) — `session_id`, `ip`, `user_agent`, browser/platform via the **`browser` gem**, `exclusive(id)` = sign out everywhere else (→ research/09 §Mastodon).
91
+ - **Discourse**: `UserAuthToken` — rotating SHA-1-hashed tokens (raw token never persisted), 60-session cap, companion `UserAuthTokenLog` audit table, "Recently Used Devices" UI (→ research/09 §Discourse).
92
+ - Three flagship codebases independently built and maintain the exact feature set; none is extractable. Textbook gem opportunity.
93
+
94
+ ### 2.3 The UX bar end-users already expect
95
+
96
+ GitHub (Settings → Sessions + security log), Google ("Your devices" + the canonical "Was this you?" new-device email), Slack, Stripe ("Login sessions" + sign-out-all), Shopify (Devices + per-device logout). The standardized contract: **device label (parsed UA) + approximate location (IP) + last-active + per-row revoke + "sign out everywhere" + new-device alert** (→ research/09 §UX bar). The gem's default views replicate exactly this, nothing more.
97
+
98
+ ### 2.4 Demand signals in the Rails ecosystem
99
+
100
+ - **authtrail: 4.11M downloads**, 1.0.0 on 2026-04-04, ~1.3k downloads/day — the market already pays (in installs) for login-activity tracking alone, with no live-session features at all (→ research/09 §Demand).
101
+ - A decade-deep tutorial cottage industry (SupeRails twice — once per auth stack; rails.substack multi-device tracking; Jon Leighton 2013 → PentesterLab 2025) keeps re-teaching the same hand-rolled build; WorkOS's 2026 Rails auth guide frames our literal pitch: *"You're logged in on iPhone, MacBook, and Windows PC – sign out others?"* (→ research/08 §Demand, research/09 §Demand).
102
+ - GoRails' impersonation episode extends the generated `Session` model — proof the community treats it as *the* extension point (→ research/08 §Demand).
103
+
104
+ ### 2.5 Compliance drivers (this is a requirement, not a nice-to-have)
105
+
106
+ - **OWASP ASVS 4.0.3 §3.3.4 (L2)**: "users are able to view and (having re-entered login credentials) log out of any or all currently active sessions and devices" — carried into **ASVS 5.0 §7.5.2**. Terminate-others on password change: 3.3.3 / 7.4.3 (→ research/09 §Compliance).
107
+ - **OWASP Session Management Cheat Sheet**: recommends user-facing session controls outright; mandates server-side invalidation; **never log raw session IDs** ("log a salted-hash… instead").
108
+ - **NIST 800-63B-4**: named reauth timeouts (AAL2 ≤24h absolute / ≤1h idle) — exposed as config presets.
109
+ - **SOC 2 CC6.x/CC7.x**: queryable login-attempt evidence + admin revocation is standard audit material.
110
+
111
+ ### 2.6 Competitive scan & naming
112
+
113
+ `sessions` → RubyGems API **404 (available)**, as are `active_sessions`, `user_sessions`, `login_activity`, `sessionable`. `authtrail` = events-only; `devise-security` (20.8M downloads, policy modules) = one-session-per-user limiting, no listing; `session_tracker` dead since 2021 (→ research/09 §Demand). One discoverability bonus: DHH's PR was literally titled "Add basic **sessions** generator" — the word now means "DB-backed auth session rows" in Rails mindshare (→ research/08 §Implications). Docs must disambiguate from `ActionDispatch::Session` (the Rack cookie store) early and clearly.
114
+
115
+ ---
116
+
117
+ ## 3. Product vision & design principles
118
+
119
+ 1. **Decorate the session of record. Never become it.** We enhance the host's existing auth (Rails 8 rows, Devise cookies); we never replace session storage or authentication. Authie replaced and died; authtrail decorated and won (→ research/06 §Avoid).
120
+ 2. **Omakase-maximalist.** Where Rails has a shape, we adopt it: the Rails 8 `sessions` table *is* our registry when present; when absent (Devise apps), we generate the same Rails-8-shaped table and model — `sessions` makes every Rails app converge on the omakase shape, so a future Devise→omakase migration finds its sessions table already in place. Precedent for extending host-owned tables via copied migrations: Devise itself (`add_devise_to_users`).
121
+ 3. **Tracking must never break login.** Every hook body is error-isolated (authtrail's `safely` pattern; ecosystem rule: "callback errors are isolated and never break the core operation", → research/02 §5). A bug in `sessions` may lose a log row; it may never 500 a sign-in.
122
+ 4. **Zero-config magic, with explicit escape hatches.** Install generator auto-detects the auth stack and adapts. Auto-classification covers password/OAuth/devise-passwordless; `Sessions.tag` covers the rest. Every default is overridable in a fully-annotated initializer (ecosystem convention, → research/02 §1).
123
+ 5. **DX is candy.** `has_sessions`, `session.device_name`, `session.revoke!`, `user.revoke_other_sessions!` — bang verbs, `?` predicates, kwargs, chainable scopes, reads like English (→ research/02 §4).
124
+ 6. **Soft dependencies everywhere.** `trackdown` (geo), `device_detector` (parser upgrade), Devise/Warden, OmniAuth — all optional, detected with `defined?()`, rescued everywhere. Only hard runtime deps: Rails frameworks + `browser` (MIT, zero-dep, 15 KB) (→ research/07 §A, research/02 §2).
125
+ 7. **Privacy as a feature.** Bounded retention + purge job, optional IP truncation, optional AR encryption, no client-side fingerprinting ever (WP224 consent trap), lat/lng precision reduction (→ research/09 §Privacy).
126
+ 8. **Incubated in production.** Built against CarHey (Devise 5 + Hotwire Native + Cloudflare + Spanish UI) and LicenseSeat (Devise 4.9 + MaxMind trackdown + api_keys), extracted only when the shapes survive contact with both (→ research/01).
127
+
128
+ ---
129
+
130
+ ## 4. Personas & jobs-to-be-done
131
+
132
+ | Persona | Job | What v1 gives them |
133
+ |---|---|---|
134
+ | **End user** | "Is my account safe? What's logged in? Kick that device." | `/settings/sessions` devices page: friendly device names, location, last seen, current-session badge, per-row Log out, "Sign out everywhere else", and a "Was this you?" email on new-device logins. |
135
+ | **Developer** | "Give me GitHub-style session security without building it for the third time." | `bundle add sessions` → `rails g sessions:install` → `has_sessions` → done. Works identically on Rails 8 auth and Devise; OAuth/native just work; one initializer of lambdas to integrate mailers/audit (goodmail, noticed, AuditLog…). |
136
+ | **Admin / T&S** | "Who tried to brute-force us last night? Is this account being taken over? Kill every session this user has." | `Sessions::Event` scopes (failed logins, by IP, by identity, velocity), per-user session admin + `revoke_all!`, new-device/new-country signals, madmin resource recipe, optional tee into the host's AuditLog (CarHey pattern). |
137
+
138
+ Three installed bases to serve from day one: (a) Rails 8 generator apps, (b) Devise apps (CarHey, LicenseSeat, RailsFast), (c) any of the above with OAuth/One Tap/passkeys/magic links layered on (→ research/08 §Implications).
139
+
140
+ ---
141
+
142
+ ## 5. Scope
143
+
144
+ ### 5.1 v1.0 (the launchable core)
145
+
146
+ - Session registry (adopt-or-generate Rails-8-shaped `sessions` table) + extension columns (device, geo, auth method, `last_seen_at`).
147
+ - Append-only `sessions_events` trail: `login`, `failed_login`, `logout`, `revoked`, `expired` — successes *and* failures with attempted identity, linked to the session row they created.
148
+ - Adapters: Rails 8 omakase (model callbacks + controller prepend), Devise/Warden (4 hooks), OmniAuth (classification + failure composer), explicit APIs (`Sessions.tag`, `Sessions.record_failed_attempt`, `Sessions.track_login`).
149
+ - Revocation: `session.revoke!`, `user.revoke_other_sessions!`, revoke-on-password-change (default on), Devise remember-me invalidation on revoke (default on).
150
+ - Device intelligence: native matcher (Hotwire Native + UA convention + `X-Client-*` headers) → `browser` gem → optional `device_detector` adapter; raw UA + client hints always stored; `Accept-CH` opt-in.
151
+ - Geolocation via `trackdown` soft dep (CF-headers sync / MaxMind async, footprinted enqueue-time enrichment pattern).
152
+ - Throttled `last_seen_at` touch (single conditional UPDATE; default every 5 minutes).
153
+ - Hooks: `on_new_device`, `on_session_revoked`, plus a catch-all `config.events` tee (for AuditLog/telegrama/goodmail wiring). **No mailers shipped** (house rule, → research/02 §Top 9) — README recipes for goodmail/noticed instead.
154
+ - "Your devices" engine page (mountable), partials, `rails g sessions:views` ejection, i18n (en, es).
155
+ - Retention: `config.events_retention` (default 12 months, CNIL) + generated `Sessions::SweepJob` (+ `config/recurring.yml` snippet); session cap per user with oldest-eviction (default 100, GitLab-style).
156
+ - Admin scopes + madmin resource recipe; optional idle/absolute timeout enforcement with NIST presets (default **off** — see §12).
157
+ - Privacy: `config.ip_mode = :full | :truncated`, AR-encryption recipe, lat/lng rounding.
158
+
159
+ ### 5.2 v1.x fast-follows
160
+
161
+ - `sessions:reparse` rake task (re-run UA parsing with newer parsers over stored raw UAs).
162
+ - Browser-continuity cookie (authie's `browser_id`) to re-link remember-me re-auths to a stable "device" identity.
163
+ - First-party Google One Tap + webauthn-rails integration generators (auto-`Sessions.tag` injection).
164
+ - Authtrail migration recipe (`INSERT … SELECT` mapping LoginActivity → `sessions_events`).
165
+ - Suspicious-activity primitives: `first_session_for_ip?`, new-country flag, failed-attempt velocity scope; `on_suspicious_login` hook.
166
+ - Avo/Administrate recipes; ActiveSupport::Notifications instrumentation of all lifecycle events.
167
+
168
+ ### 5.3 v2+ / explicit non-goals
169
+
170
+ - **Non-goals (forever)**: authentication itself (passwords, 2FA, lockout — that's Rails/Devise/rodauth); rate limiting (Rails `rate_limit` / rack-attack); client-side fingerprinting (WP224 consent trap); push-notification token management (we leave a clean per-device row to hang tokens off later); API/JWT token auth tracking (that's `api_keys`' lane — we explicitly skip non-cookie auth, → research/01 §LicenseSeat).
171
+ - **v2 candidates**: impossible-travel detection (Entra model — the data model already stores geo+timestamps to make it computable), org/team-level admin dashboards, WebSocket presence ("online now"), session notes/labels.
172
+
173
+ ---
174
+
175
+ ## 6. Architecture
176
+
177
+ ### 6.1 Two primitives, linked
178
+
179
+ ```
180
+ ┌─────────────────────────────┐ ┌──────────────────────────────────┐
181
+ │ sessions (registry) │ │ sessions_events (trail) │
182
+ │ host-owned, Rails-8-shaped │◄────────│ gem-owned, append-only │
183
+ │ LIVE state: one row = │ session │ HISTORY: logins (ok+failed), │
184
+ │ one signed-in device. │ _id │ logouts, revocations, expiry. │
185
+ │ Destroyed on revoke/logout │ │ Survives session destruction. │
186
+ └─────────────────────────────┘ └──────────────────────────────────┘
187
+ ```
188
+
189
+ - **One mental model**: *rows = active sessions; events = history.* `revoke!` destroys the registry row (instant logout — omakase semantics, same as Rails 8.1's own password-reset `destroy_all`) and writes a `revoked` event. "Expired sessions" in the UI come from the trail, not from tombstone rows. No soft-delete state machine to maintain.
190
+ - The trail's `session_id` column (plain id, deliberately **no FK constraint** — the registry row it points at gets destroyed) is the linkage no prior art has: a suspicious login event is one click away from revoking the live session it created (→ research/06 §Improve).
191
+
192
+ ### 6.2 Adopt-or-generate: the registry is always the Rails 8 shape
193
+
194
+ | Host situation | What `rails g sessions:install` does |
195
+ |---|---|
196
+ | **Rails 8 omakase auth** (`Session` model + `sessions` table exist) | Adopts them. One migration **adds columns to the existing `sessions` table** (precedent: Devise's `add_devise_to_users`). Model decorated via concern at `to_prepare` — app code untouched. |
197
+ | **Devise (no sessions table)** | Generates the Rails-8-shaped `sessions` table (+ our columns, with `token_digest` populated) and a 3-line app-owned shell model: `class Session < ApplicationRecord; include Sessions::Model; end`. Devise stays the authenticator; Warden hooks maintain the rows. The app is now omakase-shaped — a future Devise→Rails-auth migration finds its table waiting. |
198
+ | **Existing conflicting `sessions` table** (e.g. legacy `activerecord-session_store`) | Generator detects, aborts with guidance: `config.session_class = "SessionRecord"` + `--table=session_records` escape hatch. |
199
+ | **No auth at all** | Generator says: run `bin/rails generate authentication` (or install Devise) first, then re-run. We never generate authentication. |
200
+
201
+ All gem logic lives in `Sessions::Model` (the concern) and `Sessions::Event` (gem-owned model under `lib/sessions/models`, wired into the host's Zeitwerk loader exactly like moderate/chats do, → research/02 §1). The generated `Session` file is a 3-line shell, so it never goes stale — dodging authentication-zero's "generated code will not be updated" trap (→ research/06 §Avoid).
202
+
203
+ ### 6.3 The adapter layer
204
+
205
+ ```
206
+ ┌──────────────────────────────────────────┐
207
+ │ Sessions.record │
208
+ │ (one internal pipeline: classify auth │
209
+ │ method → parse device → resolve IP → │
210
+ │ geo-enrich → persist row + event → │
211
+ │ detect new device → fire hooks) │
212
+ └──────────────────────────────────────────┘
213
+ ▲ ▲ ▲
214
+ ┌─────────────────────┤ │ ├──────────────────────┐
215
+ ┌───────┴────────┐ ┌─────────┴────────┐ ┌─┴──────────┐ ┌┴─────────────────────┐
216
+ │ Rails8 adapter │ │ Devise adapter │ │ OmniAuth │ │ Explicit API │
217
+ │ Session model │ │ 4 Warden hooks │ │ on_failure │ │ Sessions.tag │
218
+ │ callbacks + │ │ (class-level, │ │ composer + │ │ Sessions.track_login │
219
+ │ controller │ │ live-read, load- │ │ env sniff │ │ Sessions.record_ │
220
+ │ prepend │ │ order safe) │ │ │ │ failed_attempt │
221
+ └────────────────┘ └──────────────────┘ └────────────┘ └──────────────────────┘
222
+ ```
223
+
224
+ Both first-class adapters activate automatically and independently (an app can have both — e.g. Devise app that later adds omakase auth for a second scope). Activation is duck-typed and guarded:
225
+
226
+ - **Rails 8 adapter** (→ research/03 §Implications): `Rails.application.config.to_prepare` → if `defined?(::Session)` && table has `ip_address`/`user_agent` → `Session.include(Sessions::Model)`; if `ApplicationController.private_method_defined?(:start_new_session_for)` → `ApplicationController.prepend(Sessions::OmakaseControllerHooks)`. The prepend sits in front of the included `Authentication` concern in the ancestor chain, so `super`-wrapping `resume_session` (throttled touch) and `terminate_session` (logout event) is clean, name-stable-since-8.0, and requires zero app edits. Login/revocation events ride **model callbacks** — `after_create_commit` (login: ip/UA already on the row) and `after_destroy_commit` (revoked/logout) — which capture 100% of the generated lifecycle including 8.1's password-reset `destroy_all` (it instantiates and runs callbacks, → research/03 §Top 6).
227
+ - **Devise adapter** (→ research/04 §Implications): registered from a Railtie initializer guarded by `defined?(::Warden::Manager)` (Bundler.require precedes initializers; hooks live on the Manager *class* and are read live per request — no load-order coupling, no `require "warden"`). The four hooks:
228
+ 1. `after_set_user except: :fetch` — any fresh login (form, remember-me, OmniAuth, sign-up auto-login, post-password-reset). Guards: `warden.authenticated?(scope)` && `opts[:store] != false` (**critical**: token/HTTP-Basic auth fires this hook *every request* with `store: false` — without the guard we'd mint a session row per API call) && our skip flags. Creates the registry row, stores `[row_id, raw_token]` in `warden.session(scope)` (survives Warden's `:renew` SID rotation; auto-deleted by Warden on logout).
229
+ 2. `after_set_user only: :fetch` — per-request resume: look up row by id, `secure_compare` token digest; missing/revoked → `warden.raw_session.clear; warden.logout(scope); throw :warden, scope:, message: :session_revoked` (the proven session_limitable sequence); else throttled touch.
230
+ 3. `before_failure` — failed logins: persist scope/action/message/attempted_path from `env['warden.options']`; attempted identity from `request.params[scope]` **only when `request.post?` && credentials hash present** (filters out plain 401 page-hits and timeouts); never read the password key. Store Devise's failure symbol verbatim (`:invalid` under paranoid mode — don't infer account existence).
231
+ 4. `before_logout` — mark row destroyed + `logout` event (fires once per scope; also on forced logouts like timeout).
232
+ - **OmniAuth integration** (→ research/05 §1): no hook needed for successes (the callback lands in the same controller seam either adapter already covers; we classify by sniffing `env['omniauth.auth']`). Failures: compose-wrap `OmniAuth.config.on_failure` in an after-Devise initializer — record `omniauth.error.type` + provider + origin + IP/UA, then call the original endpoint.
233
+ - **Explicit API**: the universal seam for everything else — CarHey's manual native sign-in branch renders 422s without touching Warden's failure app, so it needs `Sessions.record_failed_attempt` (→ research/01 §Implications 3); One Tap/passkey/magic-link controllers call `Sessions.tag(request, method: :passkey, detail: {...})` before signing in.
234
+
235
+ ### 6.4 Auth-method classification pipeline
236
+
237
+ At session-creation time, first match wins (→ research/05 §Implications b):
238
+
239
+ 1. Explicit `Sessions.tag(request, …)` (stored in `request.env["sessions.auth"]`).
240
+ 2. `env['omniauth.auth']` present → `method: :oauth`, `provider:` normalized strategy name (`google_oauth2` → `google`), detail: `{origin:, scopes:, email_verified:, hd:}`.
241
+ 3. Warden `winning_strategy` class → mapping table: `DatabaseAuthenticatable → :password`, `Rememberable → :password` (+ `detail: {remembered: true}`), `MagicLinkAuthenticatable → :magic_link` (devise-passwordless auto-detected), extensible via `config.strategy_methods`.
242
+ 4. `flash[:google_sign_in]` present → `:oauth` / `google` (Basecamp google_sign_in gem).
243
+ 5. Omakase `SessionsController#create` via `authenticate_by` → `:password`.
244
+ 6. Fallback `:unknown` (never guess).
245
+
246
+ Taxonomy (two indexed columns + one JSON): `auth_method` ∈ `password, oauth, google_one_tap, passkey, magic_link, otp, sso, token, unknown`; `auth_provider` (nullable: `google`, `github`, `apple`, IdP entity…); `auth_detail` (JSON: `select_by` for One Tap, UV/BS flags + `sign_count` for passkeys, etc.). Apple is `oauth` + `provider: "apple"` — method values are reserved for transport-distinct flows so the enum stays stable (→ research/05 §Implications a).
247
+
248
+ ### 6.5 Device intelligence pipeline
249
+
250
+ Raw-first, three layers (→ research/07 §Implications):
251
+
252
+ 1. **Persist raw**: `user_agent` as `text` (no 255 truncation — authie's footgun; Hotwire Native UAs with bridge-components exceed 255), plus interesting headers (`Sec-CH-UA*`, `X-Client-*`) in a `client_hints` JSON column. Parsing is a projection; `sessions:reparse` (v1.x) can re-run it as parsers improve.
253
+ 2. **Native matcher first** (the moat — no third-party parser does this): `/(Turbo|Hotwire) Native (iOS|Android)/` (same contract as turbo-rails' `turbo_native_app?`) → platform; then the documented prefix convention `AppName/1.2.3 (iPhone15,2; iOS 19.5; build 241);` → app name/version/build/model/OS; CarHey's legacy shapes (`"CarHey Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)"`) and validated `X-Client-Platform/Version/Build/OS` headers accepted as input too; on Android, fall back to the embedded WebView UA for model/OS (UA-reduction-exempt).
254
+ 3. **Web parser**: `browser` gem (hard dep: MIT, zero-dep, ~15 KB, maintained, ships `ios_app?`/`android_app?` webview heuristics — also what Mastodon uses) for browser name/version, OS family, device type, bot flag. **`device_detector` auto-upgrade adapter** when the host bundles it (better Android device names, Client-Hints-native, 108 KB bot list — but LGPL, 1.5 MB data, stale since 2024-07, which is also why it's not the default; GitLab wraps it with a 1024-char truncation we mirror). `config.ua_parser` accepts `:browser`, `:device_detector`, or a lambda.
255
+
256
+ **Honest display names** (frozen-UA reality, → research/07 §C): never render frozen tokens as facts — "Chrome 137 on macOS" (no version), "Safari on iOS 19.5 · iPhone", "CarHey 2.4.1 on Pixel 8 (Android 16)". iPads on Safari display as "Safari on macOS" (no server-side tell; documented). Optional `config.request_client_hints = true` sets `Accept-CH` to recover real platform versions + Android models on Chromium; login POSTs are rarely first-navigations, so hints are reliably present exactly when we need them.
257
+
258
+ ### 6.6 Geolocation via `trackdown` (soft dependency)
259
+
260
+ The contract, lifted verbatim from footprinted's proven integration (→ research/02 §2):
261
+
262
+ - Guard every call with `defined?(Trackdown)`; rescue **everything** and log (trackdown raises on private/loopback IPs in dev — a geo failure must never block a login write).
263
+ - Always `Trackdown.locate(ip.to_s, request: request)` so Cloudflare headers win when present (CarHey mode: zero-config, free, synchronous header read).
264
+ - MaxMind mode (LicenseSeat shape): geolocate in the gem's enrichment job, and **pre-extract CF-header geo at enqueue time** so workers never need the MaxMind DB.
265
+ - Skip lookup when `country_code` already present. Store footprinted's proven column set (country_code/name, city, region; lat/lng on events only, precision-reduced). Flag emoji derives from `country_code` at render time — no column.
266
+
267
+ ### 6.7 Touch, expiry & lifecycle
268
+
269
+ - **Throttled touch**: `last_seen_at` updated at most every `config.touch_every` (default 5 minutes) via one conditional `update_all` statement (hot-row-safe, callback-free — CarHey's `IS DISTINCT FROM` pattern generalized; Rodauth's validate+touch-in-one-UPDATE proves the shape; authie's touch-every-request and devise-security's per-request `update_column` are the documented anti-patterns) (→ research/01 §Implications 6, research/06 §Steal).
270
+ - This finally makes the Rails security guide's own `Session.sweep` recommendation implementable (→ research/03 §5).
271
+ - **Expiry**: `config.idle_timeout` / `config.max_session_lifetime` (both default `nil` — a tracking gem must not silently change login lifetimes; see §12) with `config.timeout_preset = :nist_aal2` sugar (24h absolute / 1h idle). Enforced inline at resume (both adapters) and by the generated `Sessions::SweepJob` (also prunes per-user overflow beyond `config.max_sessions_per_user`, default 100 — GitLab's number — and purges trail rows past retention).
272
+
273
+ ### 6.8 Performance budget (hard requirements)
274
+
275
+ - **Unauthenticated requests**: zero overhead.
276
+ - **Authenticated resume**: Rails 8 mode — zero extra queries (we piggyback the host's own `Session.find_by`; touch adds ≤1 UPDATE per 5 min per session). Devise mode — exactly one indexed PK lookup per request (row id rides in the warden session next to the token; digest compared in Ruby with `secure_compare`) + the same throttled touch.
277
+ - **Login**: +1 INSERT (event) +1 INSERT/row-create (registry, omakase already does it) + UA parse (µs–ms, memoized) + optional geo job enqueue. All hook bodies error-isolated.
278
+ - No model callbacks on the host's hot path beyond what we register; no `before_action` injected into every controller (authie's mistake).
279
+
280
+ ---
281
+
282
+ ## 7. Data model
283
+
284
+ Two migrations, both **copied into the app** (adaptive: uuid/bigint PKs, jsonb/json, detected from the host — ecosystem convention, → research/02 §1). Column types chosen for cross-DB portability (`ip_address` is `string limit: 45` everywhere — `inet` is PG-only; the generator may upgrade to `:inet` on Postgres, → research/07 §D).
285
+
286
+ ### 7.1 Registry: extend (or create) `sessions`
287
+
288
+ ```ruby
289
+ # When the Rails 8 table exists → add_column calls on the existing table.
290
+ # In Devise mode → create_table with the Rails 8 base (user:references,
291
+ # ip_address:string, user_agent:text, timestamps) plus all of the below.
292
+
293
+ t.string :token_digest # Devise/Warden mode only: SHA-256 of a random 32-byte
294
+ # token; raw token lives ONLY in the user's Rack session.
295
+ # Unique index. NULL for omakase rows (cookie holds row id;
296
+ # nothing secret to store — OWASP: never persist raw IDs).
297
+ t.string :scope # warden scope ("user"); multi-scope Devise apps
298
+ t.string :auth_method # §6.4 taxonomy — indexed
299
+ t.string :auth_provider # "google", "apple", "github"… — indexed
300
+ t.json :auth_detail # select_by, oauth scopes, passkey UV/BS flags…
301
+ t.string :browser_name, :browser_version
302
+ t.string :os_name, :os_version
303
+ t.string :device_type # desktop / smartphone / tablet / native_ios /
304
+ # native_android / bot / unknown
305
+ t.string :device_model # "iPhone15,2", "Pixel 8" (when knowable, §6.5)
306
+ t.string :app_name, :app_version, :app_build # Hotwire Native
307
+ t.json :client_hints # raw Sec-CH-UA* + X-Client-* headers
308
+ t.string :country_code, limit: 2 # via trackdown — indexed
309
+ t.string :country_name, :city, :region
310
+ t.datetime :last_seen_at # indexed; the column the security guide's sweep needs
311
+ t.string :last_seen_ip, limit: 45 # refreshed with touch (roaming devices)
312
+ ```
313
+
314
+ Notes: Rails 8's generated `ip_address`/`user_agent` are kept as-is (login-time values; `user_agent` is `string` there — MySQL 255-char truncation risk documented; our Devise-mode table uses `text`). `user_id` stays a plain FK for omakase parity; multi-scope/polymorphic owners are an install flag (`--polymorphic`), while the **events** table is polymorphic always.
315
+
316
+ ### 7.2 Trail: `sessions_events` (gem-owned, append-only)
317
+
318
+ ```ruby
319
+ create_table :sessions_events do |t|
320
+ t.string :event, null: false # login / failed_login / logout / revoked / expired
321
+ t.references :authenticatable, polymorphic: true # nullable: unknown-identity failures
322
+ t.string :scope
323
+ t.bigint :session_id # ← the linkage. Plain column, NO FK constraint:
324
+ # registry rows get destroyed on revoke; history must survive.
325
+ t.string :identity # email-as-typed (normalized), even for unknown accounts
326
+ t.string :auth_method, :auth_provider
327
+ t.json :auth_detail
328
+ t.string :failure_reason # devise message symbol / omniauth error type, verbatim
329
+ t.string :revoked_reason # user_revoked / admin_revoked / password_change /
330
+ # logout_everywhere / expired / pruned
331
+ t.string :ip_address, limit: 45
332
+ t.text :user_agent
333
+ t.json :client_hints
334
+ t.string :browser_name, :browser_version, :os_name, :os_version,
335
+ :device_type, :device_model, :app_name, :app_version
336
+ t.string :country_code, limit: 2
337
+ t.string :country_name, :city, :region
338
+ t.decimal :latitude, precision: 10, scale: 7 # rounded per config.geo_precision
339
+ t.decimal :longitude, precision: 10, scale: 7 # (default 2 decimals ≈ 1km) — privacy + future
340
+ # impossible-travel math (Entra model)
341
+ t.string :request_id, :context # X-Request-Id, "controller#action"
342
+ t.json :metadata # transform-hook extras
343
+ t.datetime :occurred_at, null: false # append-only: no updated_at
344
+ end
345
+ # Indexes: [authenticatable_type, authenticatable_id, occurred_at], [event, occurred_at],
346
+ # :identity, :ip_address, :session_id, :occurred_at
347
+ ```
348
+
349
+ Schema lineage, deliberately: authtrail's LoginActivity (scope/strategy/identity/success/failure_reason/context — its 4.1M downloads validate the trail schema; a v1.x migration recipe maps it 1:1) + footprinted's geo column set + Discourse's `UserAuthTokenLog` + our device columns (→ research/06 §1.4, research/02 §3, research/09 §Discourse). Events are written through one tolerant pipeline (authtrail's `try("#{k}=")` pattern) so hosts can add/drop columns without gem releases.
350
+
351
+ ---
352
+
353
+ ## 8. Public API (the candy)
354
+
355
+ ### 8.1 Install (any Rails 8+ app)
356
+
357
+ ```ruby
358
+ # Gemfile
359
+ gem "sessions"
360
+ ```
361
+
362
+ ```bash
363
+ rails generate sessions:install # detects Rails 8 auth vs Devise, writes the right migration
364
+ rails db:migrate # + annotated initializer + SweepJob + recurring.yml snippet
365
+ ```
366
+
367
+ ```ruby
368
+ class User < ApplicationRecord
369
+ has_sessions # that's it — on Rails 8 apps this enriches the existing has_many :sessions;
370
+ end # on Devise apps it also declares it
371
+ ```
372
+
373
+ Post-install message: ecosystem house style (emoji headline, numbered steps, yellow migration warning, the mount line, green sign-off) (→ research/02 §4).
374
+
375
+ ### 8.2 The model API
376
+
377
+ ```ruby
378
+ current_user.sessions.active # live devices, most recent first
379
+ current_user.sessions.inactive # stale > 30 days (UI grouping, not enforcement)
380
+
381
+ session = current_user.sessions.find(params[:id])
382
+ session.device_name # => "Chrome 137 on macOS"
383
+ # => "CarHey 2.4.1 on iPhone 15 Pro (iOS 19.5)"
384
+ session.location # => "Madrid, Spain" (+ session.country_flag => "🇪🇸")
385
+ session.last_seen_at # => 3 minutes ago
386
+ session.current? # => true for the request's own session
387
+ session.hotwire_native? # session.native_ios? / session.native_android? / session.web?
388
+ session.via_oauth? # session.auth_method / .auth_provider / "Signed in with Google"
389
+ session.suspicious? # v1.x: new-IP/new-country heuristics
390
+
391
+ session.revoke!(reason: :user_revoked, by: current_user) # destroys row + writes event
392
+ current_user.revoke_other_sessions! # GitHub's "sign out everywhere else"
393
+ current_user.revoke_all_sessions! # admin hammer (account takeover response)
394
+
395
+ current_user.session_events.recent # the trail
396
+ current_user.session_events.failed_logins.last_24_hours
397
+ Sessions::Event.failed_logins.for_ip("203.0.113.7") # admin: brute-force triage
398
+ Sessions::Event.for_identity("j@example.com") # admin: ATO investigation
399
+ Sessions::Event.by_country("RU").logins # admin: geo filtering
400
+ ```
401
+
402
+ ### 8.3 Request-side API
403
+
404
+ ```ruby
405
+ Sessions.current(request) # the registry row for this request (both adapters)
406
+ Sessions.tag(request, method: :passkey, detail: { user_verified: true }) # before sign-in
407
+ Sessions.record_failed_attempt(request, scope: :user, identity: params[:email],
408
+ reason: :invalid_password) # manual seams (CarHey's native branch)
409
+ Sessions.track_login(user, request, method: :sso) # fully manual integrations
410
+ ```
411
+
412
+ ### 8.4 Configuration (annotated initializer, abridged)
413
+
414
+ ```ruby
415
+ Sessions.configure do |config|
416
+ # — Behavior —
417
+ config.touch_every = 5.minutes # last_seen_at throttle (nil = never touch)
418
+ config.max_sessions_per_user = 100 # oldest-eviction (GitLab's number); nil = unlimited
419
+ config.idle_timeout = nil # opt-in enforcement; or config.timeout_preset = :nist_aal2
420
+ config.max_session_lifetime = nil
421
+ config.revoke_on_password_change = true # ASVS 3.3.3 / 7.4.3 (omakase 8.1 already does this)
422
+ config.revoke_remember_me = true # Devise: revoking a session also invalidates
423
+ # remember-me cookies (GitLab semantics — see §9.2)
424
+ # — Device intelligence —
425
+ config.ua_parser = :browser # :device_detector | ->(ua, headers) { ... }
426
+ config.request_client_hints = false # set Accept-CH for real platform versions / Android models
427
+ config.native_app_names = ["CarHey"] # extra UA prefixes to recognize (auto-learned from convention)
428
+ # — IP & geo —
429
+ config.ip_resolver = ->(request) { request.remote_ip } # CF-Connecting-IP setups override
430
+ config.ip_mode = :full # :truncated → zero last IPv4 octet / last 80 v6 bits (GA precedent)
431
+ config.geolocate = :auto # :auto (trackdown if present; CF sync, MaxMind async) | :off
432
+ config.geo_precision = 2 # lat/lng decimals stored on events
433
+ # — Retention (CNIL: 6–12 months for security logs) —
434
+ config.events_retention = 12.months # SweepJob purges older trail rows
435
+ # — Hooks (kwargs, no-op defaults, error-isolated — never break login) —
436
+ config.on_new_device = ->(user:, session:, event:) {} # wire goodmail/noticed here
437
+ config.on_session_revoked = ->(session:, by:, reason:) {}
438
+ config.events = ->(event) {} # catch-all tee → AuditLog.log / Telegrama / analytics
439
+ # — Integration —
440
+ config.parent_controller = "::ApplicationController" # devices page inherits host auth/layout
441
+ config.current_user_method = :current_user # resolver chain: configured → current_user → Current.session&.user
442
+ config.authenticate_method = :authenticate_user!
443
+ config.require_reauthentication = nil # ->(controller) { ... } sudo gate for destructive actions (ASVS 3.3.4)
444
+ end
445
+ ```
446
+
447
+ Every knob follows the house rules: validating setters with plain-English errors, class names as strings, no-op lambdas, hooks isolated (→ research/02 §1, §5).
448
+
449
+ ### 8.5 The "Your devices" page
450
+
451
+ ```ruby
452
+ # config/routes.rb
453
+ authenticate :user do # host's own auth gate (profitable's mount pattern)
454
+ mount Sessions::Engine => "/settings/sessions"
455
+ end
456
+ ```
457
+
458
+ - chats-style isolated engine: `path: ""` root resources, semantic `sessions-*` CSS classes with minimal default styles (Tailwind-friendly, themeable), no JS beyond Turbo (`button_to` + `turbo_confirm`), `rails g sessions:views` ejection for full control (→ research/02 §7).
459
+ - Alternatively, render the partials inside an existing settings shell (CarHey's `<section>`/`setting_row` pages; LicenseSeat's `settings` namespace): `render "sessions/devices", user: current_user`.
460
+ - Contents per the standardized UX contract (§2.3): device icon by `device_type`, `device_name`, approximate location (labeled approximate), `last_seen_at` in words, "This device" badge, per-row **Log out** button, **Sign out of all other sessions** button, link to login history. i18n: `en` + `es` shipped (CarHey's UI is Spanish, → research/01 §5).
461
+
462
+ ### 8.6 Generators
463
+
464
+ - `sessions:install` — adaptive migration(s) + initializer + SweepJob into `app/jobs/` + `recurring.yml` snippet (trackdown/nondisposable pattern).
465
+ - `sessions:views` — eject engine views/partials.
466
+ - v1.x: `sessions:madmin` (resource files mirroring CarHey's `audit_log_resource.rb`), `sessions:one_tap`, `sessions:passkeys` integration injectors.
467
+
468
+ ---
469
+
470
+ ## 9. Integration specs per stack
471
+
472
+ ### 9.1 Rails 8 omakase (zero-touch)
473
+
474
+ - **Login events**: `Session.after_create_commit` — ip/UA already on the row from `start_new_session_for`; classification pipeline runs against `Sessions::Current.request` (set by a tiny gem middleware that stores the request reference per-request — needed because model callbacks lack request context; reset automatically by the executor).
475
+ - **Logout/revocation events**: `after_destroy_commit` (covers `terminate_session`, our `revoke!`, and 8.1's password-reset `destroy_all` — all instantiate and run callbacks, → research/03 §Top 6).
476
+ - **Touch**: prepend-wrap `resume_session` → after `super`, throttled touch of `Current.session`.
477
+ - **Failed logins**: two automatic layers + one manual: (a) prepend `SessionsController#create` when the generated duck-shape is detected — after `super`, if no new session was started and the request was a credentials POST, record `failed_login` with the permitted identity param; (b) subscribe to the `rate_limit.action_controller` notification (8.1+) for brute-force-threshold events — free signal, no code; (c) `Sessions.record_failed_attempt` for custom controllers. Opt-out: `config.track_failed_logins = false`.
478
+ - **Edge cases encoded** (→ research/03 §Implications 8): the generated `sign_in_as` test helper creates rows with nil ip/UA — all parsing is nil-tolerant; `--api` apps lack `helper_method` — engine helpers are Base/API-aware; signed-not-encrypted cookie exposes sequential row ids (informational; revocation security rests on the signature); 20-year permanent cookie means our sweep + optional idle timeout is the only real expiry; `ip_address` truthfulness depends on `trusted_proxies` (README "Behind Cloudflare" section: cloudflare-rails / replacement-semantics warning / CF-Connecting-IP only when origin-locked).
479
+
480
+ ### 9.2 Devise / Warden (4.x and 5.x — CarHey is 5.0.4, LicenseSeat 4.9.4)
481
+
482
+ - The four hooks of §6.3, with the full guard set lifted from Devise's own hooks: skip flags (`env['sessions.skip']`, per-call `sign_in(user, sessions_skip: true)`, sticky session flag — mirroring session_limitable's three layers), `opts[:store] != false`, `warden.authenticated?(scope)`.
483
+ - **Fixation-safe by construction**: Warden's `set_user` renews the Rack SID but keeps session *data* — our token, stored in `warden.session(scope)`, survives login rotation and is deleted by Warden on logout. We never key on the Rack SID (→ research/04 §Top 3).
484
+ - **Remember-me**: a cookie re-auth is a real `:authentication` event (strategy `Rememberable`) arriving with a fresh Rack session → new registry row; stale rows get swept (v1) or re-linked via the browser-continuity cookie (v1.x). **Revocation closes the remember-me hole**: with `config.revoke_remember_me = true` (default), `revoke!` also rotates the user's remember credentials (Devise `forget_me!`/salt semantics — user-wide, documented: other devices keep their live sessions but cannot auto-revive after those end; GitLab does exactly this). CarHey's silent 1-year native remember-me makes this non-negotiable (→ research/01 §8 gap 4).
485
+ - **Multi-scope**: rows carry `scope`; `Devise.sign_out_all_scopes` fires `before_logout` once per scope — handled.
486
+ - **Coexistence**: `:trackable` keeps working (we never set `devise.skip_trackable`); `has_sessions` simply supersedes it — README suggests dropping trackable columns when ready. `bypass_sign_in` runs no callbacks → same session continues, token stays valid (documented). Timeoutable may `throw` on the same `:fetch` event — we tolerate both hook orders. CarHey's read-only `warden.user(run_callbacks: false)` peeks and its stale-remember-cookie native bootstrap never fire our hooks (correct — no login happens) (→ research/01 §3, research/04 §Implications).
487
+
488
+ ### 9.3 OAuth / OmniAuth
489
+
490
+ Successes auto-classified (§6.4) on whichever adapter is active. Failures: the `on_failure` composer records `failed_login` with `auth_method: :oauth`, `auth_provider`, `failure_reason` (`:invalid_credentials`, `:access_denied` = user hit Cancel, `:authenticity_error` = CSRF), `omniauth.origin`, IP/UA — then delegates to the original endpoint (Devise's or OmniAuth's). Not capturable (documented): which local user, and abandonments at the provider (→ research/05 §1.3).
491
+
492
+ ### 9.4 One Tap, passkeys, magic links, OTP
493
+
494
+ - **Google One Tap** (FedCM-era): `Sessions.tag(request, method: :google_one_tap, detail: { select_by: params[:select_by] })` in the app's credential endpoint — README ships the full verified pattern (GIS script → POST → `googleauth`'s `Google::Auth::IDTokens.verify_oidc` → tag → sign in). `select_by` distinguishes auto-sign-in from explicit taps (→ research/05 §4).
495
+ - **Passkeys**: webauthn-rails funnels into `start_new_session_for` → row exists automatically; tag adds the label + `{user_verified:, backed_up:, sign_count:}`. AAGUID is registration-time-only — belongs on the app's credential record, joined at display (→ research/05 §6). `SignCountVerificationError` rescues should call `record_failed_attempt(method: :passkey)` — a possible-cloning signal.
496
+ - **Magic links**: devise-passwordless = warden strategy → fully automatic. Omakase tutorial pattern (`generates_token_for` → controller → `start_new_session_for`) → row automatic, tag the method.
497
+ - **OTP/SSO**: explicit tag; `config.strategy_methods` maps custom warden strategies.
498
+
499
+ ### 9.5 Hotwire Native
500
+
501
+ - Detection/parsing per §6.5. **Device identity = the cookie jar, never the UA**: Hotwire Native shares one session cookie between WebView navigations and native HTTP calls while presenting two different UAs — CarHey's NativeHttpClient explicitly syncs cookies — so one device = one registry row, with `client_hints` capturing the richer native-client headers (→ research/01 §3 "One cookie = one session across surfaces").
502
+ - README ships the 3-line iOS/Android `applicationUserAgentPrefix` snippets (app version everywhere, hardware model on iOS — Android model is free). Without them, the gem still yields platform + OS (+ Android model) from SDK defaults (→ research/07 §B).
503
+ - The signed `session_id` cookie is readable from middleware (`request.cookie_jar.signed[:session_id]` — the generated ActionCable Connection uses the same trick) for native-API contexts outside controllers (→ research/03 §Implications 5).
504
+
505
+ ### 9.6 trackdown modes
506
+
507
+ - **CarHey mode** (zero config, Cloudflare): synchronous CF-header read at request time — free.
508
+ - **LicenseSeat mode** (MaxMind initializer + refresh job): geo enrichment in `Sessions::GeolocateJob` with CF pre-extraction at enqueue.
509
+ - No trackdown → geo columns stay nil; UI omits location cleanly; README points to trackdown setup (footprinted's call-out box pattern).
510
+
511
+ ### 9.7 API-only & token auth
512
+
513
+ Out of scope by design: requests authenticated via `api_keys`/bearer tokens (`store: false` in warden, or no session cookie) are never tracked as sessions (→ research/01 §Implications 9). The `store: false` guard makes this automatic in Devise; omakase API apps simply have no session rows for token requests.
514
+
515
+ ---
516
+
517
+ ## 10. End-user UI spec ("Your devices")
518
+
519
+ Layout (one page, two sections — the GitHub/Google contract, → research/09 §UX bar):
520
+
521
+ ```
522
+ Your devices
523
+ ────────────────────────────────────────────────────────────
524
+ 🖥 Chrome on macOS — This device [badge]
525
+ Madrid, Spain · Active now · Signed in May 2 via Google
526
+
527
+ 📱 CarHey 2.4.1 on iPhone 15 Pro (iOS 19.5) [Log out]
528
+ Madrid, Spain · Active 3 minutes ago · Signed in Apr 28
529
+
530
+ 🖥 Firefox on Windows [Log out]
531
+ Lisbon, Portugal · Active 12 days ago · Signed in Apr 12 via password
532
+
533
+ [ Sign out of all other sessions ]
534
+
535
+ Login history (last 90 days) [see all]
536
+ ────────────────────────────────────────────────────────────
537
+ ✓ Signed in · Chrome on macOS · Madrid, ES · today 09:12
538
+ ✗ Failed attempt (wrong password) · Lisbon, PT · yesterday 23:48
539
+ ⊘ Session revoked (you signed out everywhere) · Apr 30
540
+ ```
541
+
542
+ Requirements:
543
+
544
+ - Current session always first, never revocable from this page (GitLab rule — prevents foot-guns; "log out" of the current device is the app's normal sign-out).
545
+ - Locations labeled approximate ("based on IP address"); omitted cleanly when geo unavailable.
546
+ - Destructive actions: `button_to` + `data: { turbo_confirm: }`; optional sudo gate via `config.require_reauthentication` (ASVS 3.3.4's "having re-entered login credentials" — host wires its own password-confirm flow; README recipe for both stacks).
547
+ - Revocation responses handle the self-race: if a user revokes the session a stale tab is on, next request signs out gracefully (already inherent in both adapters).
548
+ - All copy through i18n (`sessions.devices.*`); `en` + `es` shipped; relative times via `time_ago_in_words`.
549
+ - Default markup = semantic classes + tiny optional stylesheet; looks decent unstyled inside any Tailwind app (chats precedent).
550
+
551
+ ## 11. Admin & fraud toolkit
552
+
553
+ - **Scopes are the product** (BYOUI, moderate's posture): `Sessions::Event.failed_logins.last_24_hours.group(:ip_address).count`, `.for_identity`, `.for_ip`, `.by_country`, `.new_devices`, `user.sessions.active`, `Sessions::Event.velocity(identity:, within: 10.minutes)` (v1.x).
554
+ - **Admin verbs**: `user.revoke_all_sessions!(by: admin, reason: :admin_revoked)` — the account-takeover response; every admin action lands in the trail with `by`.
555
+ - **madmin recipe** (`sessions:madmin` generator, v1.x): SessionResource + EventResource mirroring CarHey's `audit_log_resource.rb` (menu parent "Security", recent-first, RelativeTimeField) (→ research/01 §5).
556
+ - **AuditLog tee**: `config.events = ->(event) { AuditLog.log(event_type: "session.#{event.name}", data: event.to_h, user: event.user, request: event.request) }` — one line wires CarHey's hash-chained ledger; same envelope works for Telegrama alerts (→ research/01 §2, research/02 §5).
557
+ - **New-device detection** (v1, powers `on_new_device`): a login is a *new device* when no prior session/event for that user matches on (device_type, os_name, app/browser identity) — deliberately coarse UA+IP-derived matching, never fingerprinting. New-country flag rides the same check when geo is present.
558
+
559
+ ## 12. Security & privacy requirements (hard, each cited)
560
+
561
+ 1. **Never persist a usable session credential.** Devise-mode tokens: random 32-byte, stored as SHA-256 digest, raw value only in the user's Rack session (OWASP Cheat Sheet "log a salted-hash"; Discourse/Rodauth precedent; high-entropy random ⇒ plain SHA-256 suffices — no pepper KDF theater). Omakase mode stores nothing secret (cookie signature is the credential). Never log raw tokens or cookie values anywhere (→ research/09 §Compliance, research/06 §Steal).
562
+ 2. **Tracking is error-isolated** — every adapter body wrapped (authtrail `safely` pattern), failures logged at `warn`, login proceeds. A geo/parser/DB hiccup must never 500 a sign-in (→ research/02 §5).
563
+ 3. **Revocation is server-side and immediate** (ASVS 7.4.1): row destruction is checked on the very next request in both adapters; Devise revoke also invalidates remember-me by default (§9.2).
564
+ 4. **Terminate-others on password change** default-on (ASVS 3.3.3/7.4.3; Phoenix/Laravel/omakase-8.1 precedent). In Devise, the salt-embedded session value already kills cookie sessions on password change — our rows follow via the `:fetch` validation; we also emit the events.
565
+ 5. **Failed-attempt logging is enumeration-safe**: store Devise's message symbol verbatim (paranoid mode stays `:invalid`); never echo whether the identity exists in any UI; never store the password or its length; identity normalized (`strip.downcase`) for correlation (→ research/04 §3).
566
+ 6. **Data minimization** (GDPR Art. 5(1)(c)): no request bodies, no referrer trails (drop authtrail's `referrer` column), nullable IP, UA + IP + derived columns only. IPs and UAs are personal data (*Breyer* C-582/14, Recital 30) processed under Art. 6(1)(f)/Recital 49 (network security) — stated in README with a balancing-test note (→ research/09 §Privacy).
567
+ 7. **Bounded retention**: trail default 12 months (CNIL 6–12), purge job generated and scheduled; registry rows die with their sessions (+ cap eviction). Right-to-erasure: `dependent: :destroy`/`delete_all` wiring + `Sessions.forget(user)` helper that also nulls identity on retained failure rows.
568
+ 8. **Optional hardening**: `config.ip_mode = :truncated` (GA precedent: zero last IPv4 octet / last 80 v6 bits, applied *before* persistence); AR-encryption recipe (`encrypts :ip_address, deterministic: true` — deterministic needed for equality queries, tradeoff documented per the Rails guide; non-deterministic for `user_agent`); lat/lng precision reduction default-on (→ research/09 §Privacy).
569
+ 9. **No client-side fingerprinting, ever** — scope guardrail (WP224: consent-gated under ePrivacy 5(3)). Server-observed UA + IP only — the GitLab/Mastodon/Discourse line (→ research/09 §Fraud).
570
+ 10. **Timeout enforcement is opt-in** — a tracking gem must not silently shorten anyone's sessions; presets (`:nist_aal2` etc.) make opting in one line. Defaults documented loudly. The 20-year omakase cookie means *our* sweep is the only expiry most apps will have — the README says so.
571
+ 11. **UA input hardening**: parse at most 1024 chars (GitLab's `SafeDeviceDetector` bound) while storing the full raw text; `IPAddr`-normalize and validate IPs before persistence.
572
+ 12. **Fixation interplay**: we change nothing about Rack session rotation (Warden `:renew` stays; omakase auth doesn't use the Rack session for auth) and never key state on the Rack SID (→ research/04 §Top 3).
573
+
574
+ ## 13. Packaging & compatibility
575
+
576
+ - **Gem name**: `sessions` (RubyGems 404 verified 2026-06-10). Tagline: *"Every session, every device, every login — tracked, revocable, visible. The missing session layer for Rails."*
577
+ - **Structure**: `Rails::Engine` with `isolate_namespace Sessions`; spine files `require_relative`'d; models/jobs under `lib/sessions/{models,jobs}` wired into the host Zeitwerk loader (`push_dir`/`collapse`/`ignore` before `:set_autoload_paths` — moderate/chats pattern). Never defines a top-level `Session` constant in the gem itself (→ research/02 §1, research/03 §Implications 7).
578
+ - **Dependencies**: `activerecord`/`activesupport`/`actionpack`/`railties >= 7.1, < 9.0` (Rails-8-first, 7.1 floor is cheap per moderate/chats); `browser >= 6` (hard); everything else soft (`trackdown`, `device_detector`, Devise/Warden, OmniAuth). Ruby `>= 3.2`. MIT. `rubygems_mfa_required`. Authors/email per house gemspec (→ research/02 §1).
579
+ - **Errors**: `Sessions::Error < StandardError`, `Sessions::ConfigurationError`, `Sessions::UnknownAuthSystemError` (generator-time).
580
+ - **DB support**: PostgreSQL, MySQL, SQLite via portable column types + adaptive migration (uuid/bigint, jsonb/json, optional `:inet` upgrade on PG). Both CarHey and LicenseSeat are uuid-PK Postgres apps; dummy apps test bigint+SQLite too.
581
+
582
+ ## 14. Testing & quality strategy
583
+
584
+ - **Minitest 6**, TDD-style: write the candy-API tests first (define the DX we wish existed), then implement (project rule, `.cursor/rules/0-overview.mdc`).
585
+ - **Appraisals matrix**: Rails 7.1 / 7.2 / 8.0 / 8.1 (+ edge allowed-failure lane), Devise 4.9 + 5.0 lanes, with/without trackdown + device_detector + omniauth (soft-dep lanes assert graceful absence).
586
+ - **Two dummy apps** (the novel requirement vs sibling gems): `test/dummy_omakase` (generated `rails g authentication` code vendored in, pinned per Rails version — asserts our prepend/duck-detection against the *real* templates) and `test/dummy_devise` (Warden hook integration, multi-scope, remember-me, timeoutable interplay, `store: false` API guard). Engine UI tested mounted in both.
587
+ - **Edge-case suite** (each from a cited memo finding): nil ip/UA rows (`sign_in_as` helper), 2000-char native UAs, `bypass_sign_in`, paranoid-mode failures, `sign_out_all_scopes`, rate-limit notification capture, password-reset `destroy_all` events, revoked-session stale-tab race, CF header vs MaxMind geo, private-IP trackdown raise, MySQL 255-char UA truncation.
588
+ - **Security tests**: token digest never round-trips, no raw token in logs (log scrubber assertion), enumeration-safety of failure rows, purge job respects retention, `ip_mode: :truncated` truly truncates pre-persistence.
589
+ - Release discipline: version-bump checklist + CI drift check (footprinted pattern, → research/02 §1).
590
+
591
+ ## 15. Rollout plan
592
+
593
+ 1. **Phase 0 — Gem core** (this repo): registry + trail + both adapters + device intel + UI, built TDD against the two dummy apps. README written early in the house formula (emoji title → candy example → quickstart → deep dives → "what it doesn't do" → "why the models").
594
+ 2. **Phase 1 — Incubate in CarHey** (Devise 5 + native + Cloudflare + Spanish): point Gemfile at the sibling checkout (`moderate` precedent); wire `config.events → AuditLog`, goodmail new-device recipe, madmin resources; "Sesiones y dispositivos" section in `/settings`; native UA-prefix snippets into carhey-ios/android. Validates: Warden adapter, manual-branch failure seam, remember-me revocation, CF geo, native parsing, i18n.
595
+ 3. **Phase 2 — Validate in LicenseSeat** (Devise 4.9, MaxMind, api_keys, English): validates devise 4.x, MaxMind async geo, token-auth exclusion, `settings` namespace UI fit.
596
+ 4. **Phase 3 — Omakase proof**: a RailsFast-adjacent demo app on `rails g authentication` (zero-touch story, screenshots for README/launch post).
597
+ 5. **Phase 4 — Extract & launch**: cut 0.1.0 → rubygems; launch content timed to the ecosystem calendar: Planet Argon 2026 survey results land **July 2026** (fresh auth-share data to cite) and **Rails World Austin is 2026-09-23/24** (→ research/08 §Implications 6).
598
+
599
+ ## 16. Risks & mitigations
600
+
601
+ | Risk | Mitigation |
602
+ |---|---|
603
+ | Rails core ships session management itself | Exclusions are stated policy (PR #52328 quotes); 8.1/8.2 added nothing; concern byte-stable since 8.0. If core ever moves, our omakase adapter rides the same shapes — and adoption by then is our moat. |
604
+ | Prepend/duck-detection breaks on a host's customized auth code | Detection is capability-based (`private_method_defined?`), every layer degrades to the explicit API; integration test lane against vendored generator output per Rails version catches upstream drift early. |
605
+ | Extending the host-owned `sessions` table feels invasive | Devise-extends-`users` precedent; migration is copied (visible, editable); escape hatch `config.session_class` + `--table=`. PRD Open Question 1 keeps this reviewable. |
606
+ | Devise per-request row lookup adds latency | PK lookup on an indexed id riding the warden session + Ruby digest compare; measured budget in §6.8; touch throttled. |
607
+ | Trail table grows unbounded on big apps | Indexed append-only writes, default 12-month purge, documented `INSERT`-heavy posture; events pipeline is `tolerant-assign`, so hosts can prune columns. |
608
+ | `device_detector` staleness / `browser` mis-parses new UAs | Raw UA always stored; `sessions:reparse`; parser pluggable; honest display-name rules avoid over-claiming. |
609
+ | Gem name squatting before launch | Reserve `sessions` on RubyGems with a 0.0.1 placeholder **immediately** (Open Question 7). |
610
+ | Remember-me revocation (user-wide) surprises Devise apps | Default documented loudly; `config.revoke_remember_me = false` opt-out; per-device remember tokens explored in v1.x (browser-continuity cookie). |
611
+
612
+ ## 17. Open questions for Javi
613
+
614
+ 1. **Registry model ownership in Devise mode**: PRD recommends generating an app-owned 3-line `Session` shell (omakase convergence; gem concern carries all logic). Alternative: gem-namespaced `Sessions::Record` + `sessions_records` table (ecosystem "models live in the gem" purity, zero collision risk, but two shapes forever and no convergence story). **Recommendation: app-owned shell.** Confirm.
615
+ 2. **Macro name**: `has_sessions` (recommended; matches `has_credits`/`has_api_keys` grammar and literally declares the `has_many`) vs `tracks_sessions` (verb-y, avoids implying it's just an association). Confirm `has_sessions`.
616
+ 3. **Devices page default**: mounted engine at `/settings/sessions` (recommended, chats precedent) — or partials-first with the engine optional? Both ship either way; question is what the README leads with.
617
+ 4. **New-device email**: house rule is hooks-only, no mailers (→ research/02 §Top 9). PRD follows it (`on_new_device` + goodmail/noticed recipes). OK, or does `sessions` warrant breaking the rule with an optional built-in mailer (Google-style "Was this you?" out of the box would be the single most magical default)?
618
+ 5. **Trail writes: inline vs async**: PRD says inline INSERT (simple, ordered, authtrail-proven) with async geo enrichment only. footprinted-style `config.async = true` for the whole event write could be a v1.x knob. OK?
619
+ 6. **`Sessions::Event` vs `Sessions::Login` naming** for the trail model (events include logouts/revocations — `Event` recommended). Confirm.
620
+ 7. **Reserve the gem name now?** Push a 0.0.1 stub to RubyGems this week to lock `sessions` (and optionally `sessionable`/`login_activity` as redirect-stubs? — probably unnecessary). Recommended: yes, immediately.
621
+ 8. **Sudo mode**: ship only the `require_reauthentication` hook (recommended for v1) or build a first-party password-confirm flow (auth-zero-style `sudo_at` on the session row is cheap and we have the column budget — could be the v1.x headline)?
622
+ 9. **Scope of `signup` enrichment**: CarHey's 22 `signup_*` columns overlap heavily with what every session row now carries. Should the gem also expose a `Sessions.attribution_for(user)` (first-session) helper so CarHey can eventually drop SignupAttribution, or is that CarHey's own cleanup later?
623
+ 10. **License/positioning detail**: any appetite for a paid `sessions_pro` tier later (impossible travel, org dashboards)? Affects how much fraud tooling lands in the open core roadmap.
624
+
625
+ ## 18. Research appendix
626
+
627
+ | Memo | Covers |
628
+ |---|---|
629
+ | [research/01-carhey.md](research/01-carhey.md) | CarHey + LicenseSeat audit: Devise setup, native UA/header contracts, AuditLog pattern, trackdown modes, UI conventions, gaps |
630
+ | [research/02-ecosystem.md](research/02-ecosystem.md) | rameerez house style: macros, config, generators, hooks, UI shipping, trackdown/footprinted deep dives |
631
+ | [research/03-rails-core.md](research/03-rails-core.md) | Rails 8.1.3 + main auth generator internals, verbatim templates, supporting APIs, integration points |
632
+ | [research/04-devise-warden.md](research/04-devise-warden.md) | Warden hook ABI, Devise sign-in flow, session_limitable revocation template, edge cases, Devise 2026 state |
633
+ | [research/05-oauth.md](research/05-oauth.md) | OmniAuth mechanics, One Tap/FedCM 2026, Apple 4.8, passkeys, auth-method taxonomy + interception matrix |
634
+ | [research/06-prior-art.md](research/06-prior-art.md) | authtrail/authie/authentication-zero/Rodauth dissections, feature matrix, Steal/Improve/Avoid |
635
+ | [research/07-device-detection.md](research/07-device-detection.md) | browser vs device_detector, Hotwire Native UA construction, UA convention + client snippets, client hints, IP capture |
636
+ | [research/08-rails8-landscape.md](research/08-rails8-landscape.md) | Rails 8 auth timeline, DHH quotes with receipts, community tutorials, adoption data 2026 |
637
+ | [research/09-market-security.md](research/09-market-security.md) | Laravel/Phoenix/Django precedent, GitLab/Mastodon/Discourse internals, SaaS UX bar, ASVS/NIST/SOC2/GDPR requirements |
638
+
639
+ **Reading order for implementation**: 03 + 04 (the two adapters) → 06 (what to steal) → 07 (device pipeline) → 02 (house style) → 01 (incubation targets) → 05 (classification) → 09 (security requirements as acceptance criteria).
640
+
641
+
642
+ ---
643
+
644
+ ## Is there a real market gap for this gem? Or existing projects already solve all the needs?
645
+
646
+ Honest answer: **yes, the gap is real — but it's narrower and differently-shaped than the PRD's enthusiasm might suggest.** Let me give you the unvarnished version.
647
+
648
+ **Where the gap is genuinely empty.** Go need by need and check who solves it today:
649
+
650
+ | Need | Best existing answer | Honest verdict |
651
+ | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
652
+ | List + revoke individual sessions on **Devise** | Nothing. devise-security only does "one session per user"; cookie sessions are unenumerable | **Truly unsolved** — and the DIY (warden hooks, token-in-session, races, remember-me hole) is the trickiest code in this whole domain |
653
+ | Devices page on **Rails 8 auth** | Hand-roll (~a day for basics) | Solvable DIY, but nobody packages it |
654
+ | Login trail incl. failures | **authtrail** — 4.1M downloads, ankane, alive | **Solved for Devise apps.** Be honest: if all you want is the log, authtrail is fine. Our wedge is *not* the log |
655
+ | Trail ↔ live session linkage, revoke-from-alert | Nobody | Empty |
656
+ | Device intelligence (parsed names, Hotwire Native, app versions) | Nobody — every gem stores raw UA strings | Empty, and it's the demo-able magic |
657
+ | New-device "was this you?" email | Nobody in gem form | Empty |
658
+ | All of the above on *both* auth stacks | Nobody | Empty — SupeRails hand-rolled it twice, once per stack |
659
+
660
+ So "existing projects already solve all the needs" is false — but "authtrail already solves the most-validated single need for the biggest segment" is *true*, and we should respect that: our reason to exist is the registry + revocation + UI + multi-stack layer, with the trail as a component, not the headline.
661
+
662
+ **The bear case, stated plainly:**
663
+
664
+ 1. **This is a vitamin, not a painkiller, for most apps.** Sessions pages are security hygiene — rarely visited, never revenue. Unlike `usage_credits` or `pricing_plans` (which touch money), nobody's launch is blocked on this. Demand concentrates in serious production apps: B2B SaaS facing SOC2/ASVS checklists, consumer apps fighting fraud/ATO (CarHey's exact case), and anyone selling upmarket. Hobby apps will skip it.
665
+ 2. **The ceiling is authtrail-magnitude, not Devise-magnitude.** Millions of downloads over years, not hundreds of millions. That's a successful rameerez gem, not a breakout — *unless* distribution changes the math (shipping it default-on in RailsFast, where a devices page becomes a template selling point, is genuinely your unfair advantage).
666
+ 3. **Part of the value is thin on Rails 8.** "Sign out everywhere" is literally `user.sessions.destroy_all` there. On omakase our value is breadth and polish (failure capture has no hook, touch has write-amplification traps, retention/GDPR, native parsing), not capability. On Devise the value is capability. The technical moat is Devise; the marketing wedge is Rails 8.
667
+ 4. **Teams who want zero effort already have an answer: hosted auth** (Clerk/WorkOS sell session management as a feature). Our market is specifically the own-your-auth majority that the Rails 8 wave is actively growing — which is the right side of the trend, but it's a real boundary.
668
+
669
+ **What makes me land on "build it" despite all that:** the validation isn't vibes — Laravel and Phoenix ship this *by default*, OWASP ASVS makes it a literal L2 requirement, GitLab/Mastodon/Discourse each pay ongoing maintenance on hand-rolled versions, and authtrail proves people install adjacent tooling at scale while leaving the harder 70% of the feature unserved. And your economics are unusually favorable: **CarHey needs this regardless** (it currently has unrevocable 1-year native remember-me cookies and zero login history — a genuine security gap), so the incubation cost is sunk; gem-ifying is marginal cost on top.
670
+
671
+ If I had to bet: solid, durable, "obvious in retrospect" ecosystem gem with authtrail-or-better adoption — not a rocket. The two things that would most change the odds upward: RailsFast default inclusion, and nailing the 60-second zero-config demo on a fresh Rails 8 app. The one thing that would kill it: scope creep into auth itself.
672
+
673
+ ---
674
+
675
+ ## How do we win? How do we become better than the competition?
676
+
677
+ You don't beat authtrail head-on — you make its category obsolete. authtrail is a log table; ankane gems win by being tiny, frozen, and perfect, and you will not out-minimal ankane at his own game. The winning move is making "login activity log" a *feature inside* a category he structurally cannot follow you into. Here's the playbook, in order of how much each move actually matters:
678
+
679
+ **1. Win the Rails 8 cohort — the ground authtrail literally cannot stand on.** authtrail's first line is `require "warden"`; every omakase app is unreachable for it, and ankane's 1.0.0 was a freeze, not a foundation (zero features added, → research/06). Meanwhile every `rails new` since Nov 2024 starts on the generator, and the "what do I do after `rails g authentication`" content wave is cresting *right now*. Defaults in gem-land are set by what new-app tutorials reach for: be the answer in that cohort for 2026–2027 and you own the installed base of 2030. This is a timing window, not a permanent condition — ship while it's open.
680
+
681
+ **2. Be a strict superset with a one-command exit.** `rails g sessions:migrate_from_authtrail` — schema maps 1:1 by design, data preserved, delete the gem. The 5-years-from-now quote happens when the answer to "why would you even use authtrail?" is mechanical: *"sessions does everything it does, migrated my data in one command, and gave me the devices page, revocation, and Rails 8 support authtrail will never have."* Make switching cost zero and the comparison becomes unfair.
682
+
683
+ **3. Win the 60-second demo.** Fresh Rails 8 app → `bundle add sessions` → install → sign in from laptop + phone → devices page shows "Safari on iPhone 15 Pro · Madrid 🇪🇸 · Active now" → click **Log out** → the phone visibly dies. That GIF at the top of the README is the whole sales pitch. Nobody can GIF a log table. Device intelligence (the parsed names, the Hotwire Native awareness) isn't a feature here — it's what makes the demo *feel* like magic, and demos set defaults.
684
+
685
+ **4. Never, ever break login.** This gem sits on the auth hot path, and the failure mode that ends you permanently is one HN thread titled "sessions gem logged out all our users." Five-year defaults are built on "it never once hurt us" — that's how Sidekiq became infrastructure. Concretely: provable error-isolation in the test suite, an appraisal lane that tests against *vendored real generator output per Rails version* (so upstream drift breaks CI, not production), boring API stability, instant CVE response. Trust compounds slower than features and is worth more.
686
+
687
+ **5. Rig distribution — templates set defaults more than READMEs do.** RailsFast ships it default-on: every RailsFast app instantly has a devices page, which is both a showcase and a template selling point. Then pitch the others: Jumpstart Pro and Bullet Train are obvious targets — Chris Oliver has *already* done GoRails episodes extending the generated Session model, and SupeRails has hand-rolled this twice; these people have demonstrated demand and audiences. One GoRails episode titled "Device management in 5 minutes with the sessions gem" replaces the entire hand-roll tutorial corpus with you as the answer.
688
+
689
+ **6. Own the compliance search result.** When a SOC 2 questionnaire or pen-test report says "users must be able to view and revoke active sessions," the dev who googles it must land on you. That's literal demand — ASVS 3.3.4 is the requirement, and the search queries already exist ("rails list active sessions", "devise sign out all devices"). Write the canonical page for each; the gem is the punchline. Compliance-driven installs are the highest-intent, least price-sensitive adopters.
690
+
691
+ **7. Let data gravity do the retention.** After a year, `sessions_events` *is* the app's security history — ripping the gem out means losing the audit trail. Paradoxically, you get this stickiness by being maximally un-locked-in: plain ActiveRecord tables in their DB, readable schema, no SaaS callback. "Your data, your tables" lowers the adoption barrier *and* raises the exit cost. Then deepen it: future things hang off session rows (push tokens per device, fraud signals, your `moderate` gem consuming login events). The moment other tools build on your rows, you're infrastructure, not a feature.
692
+
693
+ **8. The new-device email is the viral surface.** Almost nobody visits a devices page; *everybody* reads "New sign-in from Chrome on Windows — was this you?" Every such email an app sends is invisible marketing that the app takes security seriously — and devs notice which gem powered it. This is why I'd revisit open question #4 and consider shipping the Google-grade email as a one-config-line default (even if via a goodmail/noticed recipe), not just a hook: it's the single feature end users ever see.
694
+
695
+ **How we lose, so we don't:** scope creep into authentication itself (the moment you're an auth framework, you're competing with Rails core and Devise instead of complementing both — that's rodauth's cautionary tale); shipping late and letting someone else catch the Rails 8 wave; one login-breaking incident; or ankane shipping a Rails 8 adapter first (low probability given his freeze pattern, but the mitigation is the same: speed + superset + migration path means even then you're ahead on everything except his name).
696
+
697
+ If I compress it to one sentence: **be the obvious answer in every fresh Rails 8 tutorial, be a free upgrade from authtrail, demo like magic, and never break login once — do those four for three consecutive years and the 2031 default is arithmetic.**
698
+
699
+ ---
700
+
701
+ ## Do we provide views? Do we provide an engine? How does this all work, exactly?
702
+
703
+ Both — it's a four-layer stack where each layer is optional and built on the one below. This is the synthesis of how your own gems already do it (chats = engine + ejection, moderate = primitives + BYOUI, api_keys = mounted dashboard, profitable = authenticated mount), picked per layer for this gem's reality: CarHey wants Spanish `setting_row` sections inside its existing settings page, LicenseSeat wants a page in its `settings` namespace — so one fixed page can't be the only offering (→ research/01 §5).
704
+
705
+ **Layer 0 — Model API, no UI at all.** Everything works headless: `user.sessions.active`, `session.device_name`, `session.revoke!`, `Sessions::Event` scopes. A host can build 100% custom UI in ~20 lines of their own controller. This layer is the contract; everything above is convenience.
706
+
707
+ **Layer 1 — Partials, renderable inside the host's own pages.** Rails automatically appends every engine's `app/views` to the host's view lookup path, so the gem ships partials that any host view can render directly with locals:
708
+
709
+ ```erb
710
+ <%# inside CarHey's app/views/settings/show.html.erb, in its own <section> %>
711
+ <%= render "sessions/devices", user: current_user %>
712
+ <%= render "sessions/history", user: current_user, limit: 10 %>
713
+ ```
714
+
715
+ No mount, no routes from us — the revoke buttons are `button_to`s pointing at engine routes *or* host-provided routes (configurable URL helper, so CarHey can route revocation through its own controller if it wants). This is the layer CarHey actually uses.
716
+
717
+ **Layer 2 — The mounted engine (the README headline).** A complete drop-in page for apps that just want it done:
718
+
719
+ ```ruby
720
+ # Devise apps — wrap the mount, profitable-style:
721
+ authenticate :user do
722
+ mount Sessions::Engine => "/settings/sessions"
723
+ end
724
+
725
+ # Rails 8 omakase apps — just mount it:
726
+ mount Sessions::Engine => "/settings/sessions"
727
+ ```
728
+
729
+ Mechanics, exactly:
730
+
731
+ - `isolate_namespace Sessions`, with the chats `path: ""` trick inside the engine's routes (`root "devices#index"`, `resources :devices, path: "", only: :destroy`, `delete :others`, `get :history`) so the mount point *is* the page — `GET /settings/sessions`, `DELETE /settings/sessions/:id`, etc.
732
+ - The engine's `ApplicationController` inherits from `config.parent_controller` (default `"::ApplicationController"`, the Devise/api_keys/chats indirection, resolved lazily with an API-only fallback). This is the magic that makes auth free on *both* stacks: an omakase host's `ApplicationController` already has `before_action :require_authentication` from the generated concern, and a typical Devise host has `authenticate_user!` — inheriting gets you their layout, auth, locale, and flash handling without us knowing which stack we're on. The current-user lookup inside the engine uses the resolver chain (configured method → `current_user` → `Current.session&.user`).
733
+ - Scoping is enforced regardless of auth: every query goes through `resolved_user.sessions.find(...)` — you can never revoke a row you don't own, even if the host's mount is misconfigured.
734
+ - Zero JS shipped. The page is forms: `button_to` + `data: { turbo_confirm: }`. Semantic `sessions-*` classes + one tiny optional stylesheet so it looks decent unstyled in a Tailwind app (chats precedent). No Stimulus, no importmap pins to manage in v1.
735
+ - All copy via I18n (`sessions.devices.*`), `en` + `es` shipped; hosts override keys in their own locale files like any Rails app.
736
+
737
+ **Layer 3 — Ejection, exactly Devise/chats-style.** `rails g sessions:views` copies the engine's views into `app/views/sessions/` — and because application view paths take precedence over engine view paths, the copies shadow the gem's automatically. Edit freely; gem updates never touch them (the documented tradeoff, same as `devise:views`). Controllers stay ours and deliberately trivial — if you need custom controller behavior, you've graduated to Layer 0/1 rather than us supporting a controller-override matrix (Devise's `scoped_views`/custom-controllers surface is a maintenance tarpit we're explicitly not replicating).
738
+
739
+ **Admin is *not* a layer** — that stays primitives + recipes (moderate's posture): `Sessions::Event` scopes plus the `sessions:madmin` generator producing resource files modeled on CarHey's `audit_log_resource.rb`. No shipped admin UI; admin frameworks vary too much and madmin/Avo/Administrate all want to own that surface.
740
+
741
+ So the gem tree looks like: `app/views/sessions/` (partials + engine pages), `app/controllers/sessions/` (two small controllers), `config/routes.rb` (engine routes), `lib/sessions/models/` (Zeitwerk-wired models), `lib/generators/sessions/{install,views,madmin}`. The README leads with Layer 2 (the 60-second demo needs the mount), then immediately shows Layer 1 ("or render it inside your own settings page") — because that's the path both of your production apps will actually take.
742
+
743
+ ---