sessions 0.1.3 → 0.2.2

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -2
  3. data/README.md +26 -16
  4. data/app/controllers/sessions/application_controller.rb +4 -3
  5. data/app/helpers/sessions/engine_helper.rb +23 -0
  6. data/app/views/sessions/_devices.html.erb +2 -2
  7. data/app/views/sessions/_history.html.erb +2 -2
  8. data/docs/PRD.md +52 -52
  9. data/docs/research/{01-carhey.md → 01-host-app.md} +23 -23
  10. data/docs/research/02-ecosystem.md +1 -1
  11. data/docs/research/07-device-detection.md +13 -13
  12. data/lib/generators/sessions/install_generator.rb +1 -1
  13. data/lib/generators/sessions/madmin_generator.rb +23 -7
  14. data/lib/generators/sessions/templates/add_lifecycle_to_sessions.rb.erb +37 -0
  15. data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +28 -0
  16. data/lib/generators/sessions/templates/create_sessions.rb.erb +20 -5
  17. data/lib/generators/sessions/templates/create_sessions_events.rb.erb +4 -4
  18. data/lib/generators/sessions/templates/initializer.rb +9 -8
  19. data/lib/generators/sessions/templates/madmin/session_resource.rb +24 -13
  20. data/lib/generators/sessions/templates/madmin/sessions_controller.rb +11 -7
  21. data/lib/generators/sessions/templates/madmin/user_panel/madmin_user_panel.rb +43 -0
  22. data/lib/generators/sessions/templates/madmin/user_panel/sessions.html.erb +133 -0
  23. data/lib/generators/sessions/upgrade_generator.rb +4 -1
  24. data/lib/sessions/adapters/omakase.rb +62 -18
  25. data/lib/sessions/adapters/warden.rb +172 -48
  26. data/lib/sessions/configuration.rb +14 -4
  27. data/lib/sessions/current.rb +4 -4
  28. data/lib/sessions/end_reason.rb +67 -0
  29. data/lib/sessions/models/concerns/has_sessions.rb +3 -3
  30. data/lib/sessions/models/concerns/model.rb +124 -59
  31. data/lib/sessions/models/event.rb +56 -21
  32. data/lib/sessions/version.rb +1 -1
  33. data/lib/sessions.rb +68 -14
  34. metadata +9 -5
data/docs/PRD.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # `sessions` — Product Requirements Document
2
2
 
3
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.
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 HostApp, 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
5
 
6
6
  ---
7
7
 
@@ -11,8 +11,8 @@
11
11
 
12
12
  `sessions` gives every Rails 8+ app, in one `bundle add` + one generator + one `has_sessions` macro:
13
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.
14
+ 1. **A live device registry** — every active session, enriched with parsed device intelligence ("Chrome 137 on macOS", "HostApp 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 (end the lifecycle row), on Devise (token-per-row generalization of devise-security's proven `session_limitable` mechanism), and on remember-me cookies.
16
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
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
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).
@@ -29,7 +29,7 @@ The gem **decorates the session of record; it never becomes it** (the #1 lesson
29
29
  ### 1.1 Rails 8 omakase auth: a substrate, deliberately unfinished
30
30
 
31
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).
32
+ - **Every request resolves the row** (`Session.find_by(id: cookies.signed[:session_id])`), so a lifecycle row can be made server-revocable without changing Rails' cookie shape. Rails built the substrate 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
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
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
35
 
@@ -50,7 +50,7 @@ The gem **decorates the session of record; it never becomes it** (the #1 lesson
50
50
  ### 1.4 Hotwire Native
51
51
 
52
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).
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. HostApp's native HTTP clients already use a richer convention (`"HostApp 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
54
 
55
55
  ### 1.5 The gap, in one table
56
56
 
@@ -123,7 +123,7 @@ GitHub (Settings → Sessions + security log), Google ("Your devices" + the cano
123
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
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
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).
126
+ 8. **Incubated in production.** Built against HostApp (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
127
 
128
128
  ---
129
129
 
@@ -133,9 +133,9 @@ GitHub (Settings → Sessions + security log), Google ("Your devices" + the cano
133
133
  |---|---|---|
134
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
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). |
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 (HostApp pattern). |
137
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).
138
+ Three installed bases to serve from day one: (a) Rails 8 generator apps, (b) Devise apps (HostApp, LicenseSeat, RailsFast), (c) any of the above with OAuth/One Tap/passkeys/magic links layered on (→ research/08 §Implications).
139
139
 
140
140
  ---
141
141
 
@@ -180,14 +180,14 @@ Three installed bases to serve from day one: (a) Rails 8 generator apps, (b) Dev
180
180
  ┌─────────────────────────────┐ ┌──────────────────────────────────┐
181
181
  │ sessions (registry) │ │ sessions_events (trail) │
182
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.
183
+ LIFECYCLE state: one row = │ session │ HISTORY: logins (ok+failed), │
184
+ │ one signed-in device; │ _id │ logouts, revocations, expiry. │
185
+ ended_at NULL means live. │ │ Survives cleanup/erasure.
186
186
  └─────────────────────────────┘ └──────────────────────────────────┘
187
187
  ```
188
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).
189
+ - **One mental model**: *rows = session lifecycle state; events = audit history.* `revoke!` ends the registry row in place (`ended_at`/`ended_reason`) and writes a `revoked` event in the same transaction. Devise/Warden never infers security intent from a missing row; it kicks only when a token-backed row has an explicit kicking lifecycle reason.
190
+ - The trail's `session_id` column (plain id, deliberately **no FK constraint** — account erasure and legacy host deletes may still remove the registry row) 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
191
 
192
192
  ### 6.2 Adopt-or-generate: the registry is always the Rails 8 shape
193
193
 
@@ -223,14 +223,14 @@ All gem logic lives in `Sessions::Model` (the concern) and `Sessions::Event` (ge
223
223
 
224
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
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).
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/expiry/end-state refusal) and `terminate_session` (logout lifecycle end) is clean, name-stable-since-8.0, and requires zero app edits. Login events ride **model callbacks** (`after_create_commit`: ip/UA already on the row); explicit logout/revocation uses `end!`; host-side `destroy_all` remains covered by the compatibility callback (→ research/03 §Top 6).
227
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
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
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
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).
231
+ 4. `before_logout` — mark row ended + `logout` event (fires once per scope; also on forced logouts like timeout).
232
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.
233
+ - **Explicit API**: the universal seam for everything else — HostApp'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
234
 
235
235
  ### 6.4 Auth-method classification pipeline
236
236
 
@@ -250,23 +250,23 @@ Taxonomy (two indexed columns + one JSON): `auth_method` ∈ `password, oauth, g
250
250
  Raw-first, three layers (→ research/07 §Implications):
251
251
 
252
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).
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; HostApp's legacy shapes (`"HostApp 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
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
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.
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", "HostApp 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
257
 
258
258
  ### 6.6 Geolocation via `trackdown` (soft dependency)
259
259
 
260
260
  The contract, lifted verbatim from footprinted's proven integration (→ research/02 §2):
261
261
 
262
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).
263
+ - Always `Trackdown.locate(ip.to_s, request: request)` so Cloudflare headers win when present (HostApp mode: zero-config, free, synchronous header read).
264
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
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
266
 
267
267
  ### 6.7 Touch, expiry & lifecycle
268
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).
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 — HostApp'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
270
  - This finally makes the Rails security guide's own `Session.sweep` recommendation implementable (→ research/03 §5).
271
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
272
 
@@ -321,7 +321,7 @@ create_table :sessions_events do |t|
321
321
  t.references :authenticatable, polymorphic: true # nullable: unknown-identity failures
322
322
  t.string :scope
323
323
  t.bigint :session_id # ← the linkage. Plain column, NO FK constraint:
324
- # registry rows get destroyed on revoke; history must survive.
324
+ # lifecycle rows can be erased; history must survive.
325
325
  t.string :identity # email-as-typed (normalized), even for unknown accounts
326
326
  t.string :auth_method, :auth_provider
327
327
  t.json :auth_detail
@@ -375,12 +375,12 @@ Post-install message: ecosystem house style (emoji headline, numbered steps, yel
375
375
  ### 8.2 The model API
376
376
 
377
377
  ```ruby
378
- current_user.sessions.active # live devices, most recent first
379
- current_user.sessions.inactive # stale > 30 days (UI grouping, not enforcement)
378
+ current_user.sessions.live # live devices, most recent first
379
+ current_user.sessions.inactive # stale live rows > 30 days (UI grouping, not enforcement)
380
380
 
381
381
  session = current_user.sessions.find(params[:id])
382
382
  session.device_name # => "Chrome 137 on macOS"
383
- # => "CarHey 2.4.1 on iPhone 15 Pro (iOS 19.5)"
383
+ # => "HostApp 2.4.1 on iPhone 15 Pro (iOS 19.5)"
384
384
  session.location # => "Madrid, Spain" (+ session.country_flag => "🇪🇸")
385
385
  session.last_seen_at # => 3 minutes ago
386
386
  session.current? # => true for the request's own session
@@ -388,7 +388,7 @@ session.hotwire_native? # session.native_ios? / session.native_android? / ses
388
388
  session.via_oauth? # session.auth_method / .auth_provider / "Signed in with Google"
389
389
  session.suspicious? # v1.x: new-IP/new-country heuristics
390
390
 
391
- session.revoke!(reason: :user_revoked, by: current_user) # destroys row + writes event
391
+ session.revoke!(reason: :user_revoked, by: current_user) # ends row + writes event
392
392
  current_user.revoke_other_sessions! # GitHub's "sign out everywhere else"
393
393
  current_user.revoke_all_sessions! # admin hammer (account takeover response)
394
394
 
@@ -405,7 +405,7 @@ Sessions::Event.by_country("RU").logins # admin: geo filteri
405
405
  Sessions.current(request) # the registry row for this request (both adapters)
406
406
  Sessions.tag(request, method: :passkey, detail: { user_verified: true }) # before sign-in
407
407
  Sessions.record_failed_attempt(request, scope: :user, identity: params[:email],
408
- reason: :invalid_password) # manual seams (CarHey's native branch)
408
+ reason: :invalid_password) # manual seams (HostApp's native branch)
409
409
  Sessions.track_login(user, request, method: :sso) # fully manual integrations
410
410
  ```
411
411
 
@@ -424,7 +424,7 @@ Sessions.configure do |config|
424
424
  # — Device intelligence —
425
425
  config.ua_parser = :browser # :device_detector | ->(ua, headers) { ... }
426
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)
427
+ config.native_app_names = ["HostApp"] # extra UA prefixes to recognize (auto-learned from convention)
428
428
  # — IP & geo —
429
429
  config.ip_resolver = ->(request) { request.remote_ip } # CF-Connecting-IP setups override
430
430
  config.ip_mode = :full # :truncated → zero last IPv4 octet / last 80 v6 bits (GA precedent)
@@ -456,14 +456,14 @@ end
456
456
  ```
457
457
 
458
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).
459
+ - Alternatively, render the partials inside an existing settings shell (HostApp'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 (HostApp's UI is Spanish, → research/01 §5).
461
461
 
462
462
  ### 8.6 Generators
463
463
 
464
464
  - `sessions:install` — adaptive migration(s) + initializer + SweepJob into `app/jobs/` + `recurring.yml` snippet (trackdown/nondisposable pattern).
465
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.
466
+ - v1.x: `sessions:madmin` (resource files mirroring HostApp's `audit_log_resource.rb`), `sessions:one_tap`, `sessions:passkeys` integration injectors.
467
467
 
468
468
  ---
469
469
 
@@ -472,18 +472,18 @@ end
472
472
  ### 9.1 Rails 8 omakase (zero-touch)
473
473
 
474
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).
475
+ - **Logout/revocation events**: `end!`/`revoke!` writes lifecycle state plus audit event transactionally; `after_destroy_commit` remains only as a compatibility hook for host-side raw deletes and Rails-generated `destroy_all` paths (→ research/03 §Top 6).
476
476
  - **Touch**: prepend-wrap `resume_session` → after `super`, throttled touch of `Current.session`.
477
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
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
479
 
480
- ### 9.2 Devise / Warden (4.x and 5.x — CarHey is 5.0.4, LicenseSeat 4.9.4)
480
+ ### 9.2 Devise / Warden (4.x and 5.x — HostApp is 5.0.4, LicenseSeat 4.9.4)
481
481
 
482
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
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).
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). HostApp's silent 1-year native remember-me makes this non-negotiable (→ research/01 §8 gap 4).
485
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).
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. HostApp'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
487
 
488
488
  ### 9.3 OAuth / OmniAuth
489
489
 
@@ -498,13 +498,13 @@ Successes auto-classified (§6.4) on whichever adapter is active. Failures: the
498
498
 
499
499
  ### 9.5 Hotwire Native
500
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").
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 — HostApp'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
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
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
504
 
505
505
  ### 9.6 trackdown modes
506
506
 
507
- - **CarHey mode** (zero config, Cloudflare): synchronous CF-header read at request time — free.
507
+ - **HostApp mode** (zero config, Cloudflare): synchronous CF-header read at request time — free.
508
508
  - **LicenseSeat mode** (MaxMind initializer + refresh job): geo enrichment in `Sessions::GeolocateJob` with CF pre-extraction at enqueue.
509
509
  - No trackdown → geo columns stay nil; UI omits location cleanly; README points to trackdown setup (footprinted's call-out box pattern).
510
510
 
@@ -524,7 +524,7 @@ Your devices
524
524
  🖥 Chrome on macOS — This device [badge]
525
525
  Madrid, Spain · Active now · Signed in May 2 via Google
526
526
 
527
- 📱 CarHey 2.4.1 on iPhone 15 Pro (iOS 19.5) [Log out]
527
+ 📱 HostApp 2.4.1 on iPhone 15 Pro (iOS 19.5) [Log out]
528
528
  Madrid, Spain · Active 3 minutes ago · Signed in Apr 28
529
529
 
530
530
  🖥 Firefox on Windows [Log out]
@@ -552,19 +552,19 @@ Requirements:
552
552
 
553
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
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).
555
+ - **madmin recipe** (`sessions:madmin` generator, v1.x): SessionResource + EventResource mirroring HostApp'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 HostApp's hash-chained ledger; same envelope works for Telegrama alerts (→ research/01 §2, research/02 §5).
557
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
558
 
559
559
  ## 12. Security & privacy requirements (hard, each cited)
560
560
 
561
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
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).
563
+ 3. **Revocation is server-side and immediate** (ASVS 7.4.1): lifecycle end state is checked on the very next request in both adapters; Devise revoke also invalidates remember-me by default (§9.2).
564
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
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
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.
567
+ 7. **Bounded retention**: trail default 12 months (CNIL 6–12), purge job generated and scheduled; live registry rows end with their sessions, then retention/account-erasure cleanup can remove them. Right-to-erasure: `dependent: :destroy`/`delete_all` wiring + `Sessions.forget(user)` helper that also nulls identity on retained failure rows.
568
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
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
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.
@@ -577,7 +577,7 @@ Requirements:
577
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
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
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.
580
+ - **DB support**: PostgreSQL, MySQL, SQLite via portable column types + adaptive migration (uuid/bigint, jsonb/json, optional `:inet` upgrade on PG). Both HostApp and LicenseSeat are uuid-PK Postgres apps; dummy apps test bigint+SQLite too.
581
581
 
582
582
  ## 14. Testing & quality strategy
583
583
 
@@ -591,7 +591,7 @@ Requirements:
591
591
  ## 15. Rollout plan
592
592
 
593
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.
594
+ 2. **Phase 1 — Incubate in HostApp** (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 hostapp-ios/android. Validates: Warden adapter, manual-branch failure seam, remember-me revocation, CF geo, native parsing, i18n.
595
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
596
  4. **Phase 3 — Omakase proof**: a RailsFast-adjacent demo app on `rails g authentication` (zero-touch story, screenshots for README/launch post).
597
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).
@@ -619,14 +619,14 @@ Requirements:
619
619
  6. **`Sessions::Event` vs `Sessions::Login` naming** for the trail model (events include logouts/revocations — `Event` recommended). Confirm.
620
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
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?
622
+ 9. **Scope of `signup` enrichment**: HostApp'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 HostApp can eventually drop SignupAttribution, or is that HostApp's own cleanup later?
623
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
624
 
625
625
  ## 18. Research appendix
626
626
 
627
627
  | Memo | Covers |
628
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 |
629
+ | [research/01-host-app.md](research/01-host-app.md) | HostApp + LicenseSeat audit: Devise setup, native UA/header contracts, AuditLog pattern, trackdown modes, UI conventions, gaps |
630
630
  | [research/02-ecosystem.md](research/02-ecosystem.md) | rameerez house style: macros, config, generators, hooks, UI shipping, trackdown/footprinted deep dives |
631
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
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 |
@@ -661,12 +661,12 @@ So "existing projects already solve all the needs" is false — but "authtrail a
661
661
 
662
662
  **The bear case, stated plainly:**
663
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.
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 (HostApp's exact case), and anyone selling upmarket. Hobby apps will skip it.
665
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.
666
+ 3. **Part of the value is thinner on Rails 8 than Devise.** Rails has a first-party session row and signed cookie; on omakase our value is breadth and polish (failure capture has no hook, touch has write-amplification traps, lifecycle state, retention/GDPR, native parsing), not inventing the primitive. On Devise the value is capability. The technical moat is Devise; the marketing wedge is Rails 8.
667
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
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.
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: **HostApp 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
670
 
671
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
672
 
@@ -700,19 +700,19 @@ If I compress it to one sentence: **be the obvious answer in every fresh Rails 8
700
700
 
701
701
  ## Do we provide views? Do we provide an engine? How does this all work, exactly?
702
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).
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: HostApp 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
704
 
705
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
706
 
707
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
708
 
709
709
  ```erb
710
- <%# inside CarHey's app/views/settings/show.html.erb, in its own <section> %>
710
+ <%# inside HostApp's app/views/settings/show.html.erb, in its own <section> %>
711
711
  <%= render "sessions/devices", user: current_user %>
712
712
  <%= render "sessions/history", user: current_user, limit: 10 %>
713
713
  ```
714
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.
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 HostApp can route revocation through its own controller if it wants). This is the layer HostApp actually uses.
716
716
 
717
717
  **Layer 2 — The mounted engine (the README headline).** A complete drop-in page for apps that just want it done:
718
718
 
@@ -736,7 +736,7 @@ Mechanics, exactly:
736
736
 
737
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
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.
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 HostApp'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
740
 
741
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
742
 
@@ -1,6 +1,6 @@
1
- # CarHey audit: auth, sessions & device detection
1
+ # HostApp audit: auth, sessions & device detection
2
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.
3
+ Read-only audit of `/path/to/repos/hostapp` (Rails 8.1.1, RailsFast, Devise), its native shells `/path/to/repos/hostapp-ios` + `/path/to/repos/hostapp-android`, and (addendum) `/path/to/repos/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
4
 
5
5
  ## Top findings
6
6
 
@@ -8,9 +8,9 @@ Read-only audit of `/Users/javi/GitHub/carhey` (Rails 8.1.1, RailsFast, Devise),
8
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
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
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.
11
+ - HostApp 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
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`).
13
+ - Native shells announce themselves via load-bearing UA tokens: Android WebView prefix `"HostApp Android;"` (`hostapp-android .../HostAppApplication.kt:169`), iOS `"HostApp iOS; RailsFast Native iOS;"` (`hostapp-ios RailsFast/Core/AppConfiguration.swift:10-12`). Server matches `/\b(?:HostApp\s+Android|Hotwire Native Android|Turbo Native Android)\b/i` (`app/services/signup_attribution.rb:315-324`).
14
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
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
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`).
@@ -127,7 +127,7 @@ end
127
127
 
128
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
129
 
130
- **iOS WebView UA** — prefix built at `carhey-ios/RailsFast/Core/AppConfiguration.swift:10-12`:
130
+ **iOS WebView UA** — prefix built at `hostapp-ios/RailsFast/Core/AppConfiguration.swift:10-12`:
131
131
 
132
132
  ```swift
133
133
  static var userAgentPrefix: String {
@@ -135,29 +135,29 @@ static var userAgentPrefix: String {
135
135
  }
136
136
  ```
137
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).
138
+ `applicationName` = `CFBundleDisplayName` = `HostApp` (`hostapp-ios/project.yml:63`), set via `Hotwire.config.applicationUserAgentPrefix` (`hostapp-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 `HostApp 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
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:
140
+ **iOS native-JSON UA + headers** — `hostapp-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
141
 
142
142
  ```swift
143
143
  return "\(applicationName) iOS \(version) (build \(build); iOS \(osVersion); \(resolvedModel))"
144
144
  ```
145
145
 
146
- **Android WebView UA** — `carhey-android/.../CarHeyApplication.kt:169`: `Hotwire.config.applicationUserAgentPrefix = "CarHey Android;"`.
146
+ **Android WebView UA** — `hostapp-android/.../HostAppApplication.kt:169`: `Hotwire.config.applicationUserAgentPrefix = "HostApp Android;"`.
147
147
 
148
- **Android OkHttp UA + headers** — `carhey-android/.../ClientHeaders.kt:25-28,66-77`:
148
+ **Android OkHttp UA + headers** — `hostapp-android/.../ClientHeaders.kt:25-28,66-77`:
149
149
 
150
150
  ```kotlin
151
- return "CarHey Android $versionName (build $versionCode; Android $osRelease; sdk $sdkInt; $device)"
151
+ return "HostApp Android $versionName (build $versionCode; Android $osRelease; sdk $sdkInt; $device)"
152
152
  ```
153
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`.
154
+ stamped by an application-level interceptor with `.header()` replace-not-append semantics (`hostapp-android/.../NativeHttpClient.kt:47-80`). `ClientHeaders.kt:17-21` documents the **critical contract**: the UA must contain the literal space-separated token `HostApp Android` because the server matches `/\bHostApp\s+Android\b/i` — `SignupAttribution#detect_native_platform` (`app/services/signup_attribution.rb:315-324`) accepts `HostApp Android|Hotwire Native Android|Turbo Native Android` and `HostApp iOS|iPhone|iPad|Hotwire Native iOS|Turbo Native iOS`.
155
155
 
156
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
157
 
158
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
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.
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 (`hostapp-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
161
 
162
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
163
 
@@ -180,7 +180,7 @@ stamped by an application-level interceptor with `.header()` replace-not-append
180
180
 
181
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
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.
183
+ - **Project specifics** (`3-project-specifics.mdc`): mobile-first Hotwire Native hybrid; minimize native code, maximize Rails-rendered surface; sister repos `../hostapp-android`, `../hostapp-ios`; design parity native↔web.
184
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
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
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`).
@@ -224,26 +224,26 @@ stamped by an application-level interceptor with `.header()` replace-not-append
224
224
 
225
225
  ## LicenseSeat (second target app: plain-web RailsFast SaaS)
226
226
 
227
- `/Users/javi/GitHub/licenseseat` — Rails 8 licensing SaaS, same RailsFast template, **no Hotwire Native shell**, UI in English.
227
+ `/path/to/repos/licenseseat` — Rails 8 licensing SaaS, same RailsFast template, **no Hotwire Native shell**, UI in English.
228
228
 
229
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).
230
+ - **Leaner users table**: trackable columns + `signup_ip/_city/_country/_country_code` only — none of HostApp'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 HostApp's zero-config CF-header mode).
232
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
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.
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 HostApp's setting_row sections. A sessions page here would naturally be `settings/sessions` inside that namespace.
235
235
  - No `moderate`/`AuditLog` (moderate commented at `Gemfile:184`); audit needs are gem-owned (`license_seat_audit_events`, `db/schema.rb:214`).
236
236
 
237
237
  ## Implications for the sessions gem (opinion)
238
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.
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 HostApp'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 + HostApp'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; HostApp'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
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)".
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 `HostApp Android`, plus `bridge-components:` parsing) and honor `X-Client-Platform/Version/Build/OS` with `ClientVersionInfo`-style validation. That turns HostApp's signup-only intelligence into every-session intelligence and lets the gem name devices "HostApp en Pixel 7 (Android 14)".
244
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`.
245
+ 7. **Geo = trackdown soft dependency, both modes**: sync CF-header path when free (HostApp), 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 (HostApp's `setting_row` sections vs LicenseSeat's `settings` namespace prove one fixed page won't fit); i18n-first (HostApp 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
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
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
249
 
@@ -3,7 +3,7 @@
3
3
  Research memo for the `sessions` gem (drop-in session & login-activity tracking, device management, Rails 8+).
4
4
  Sources: read-only study of 10 local repos — `trackdown`, `footprinted` (deep dives), `usage_credits`,
5
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/`.
6
+ (newer, best for UI-shipping + host hooks). All 10 repos present. Citations are `path:line` under `/path/to/repos/`.
7
7
 
8
8
  ## Top findings
9
9