sessions 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c9417a89572ea4f860d64648e8d085b9074dd4914d3078dbdf6e116c5087d10
4
- data.tar.gz: fda35041ba991b3dbfd4c4789eedd16f17c42764805f6fba633e956920796e6b
3
+ metadata.gz: 10c90937b69181aefb74b594f97b4f0142d75148aac755cb3e180866df74e249
4
+ data.tar.gz: 94219c51f790720e0bff485db1242f288b48161ffb5adab0bf066be22d189a30
5
5
  SHA512:
6
- metadata.gz: 428832edcb252f835b89a351f30459e9a884a288622d988df050356e5cd4322eafb70f070f9ae91b418451bdd4adfad111c2ab984eb9e2fe86950651266b7a23
7
- data.tar.gz: d56946f36346faa79f2c27c12bd69fa954018425ba85d852e98abce8b69c79d472f973c71a73e33a324598f1ddd5bb1ec058c5a5a6d34778748d0c4fc7f03111
6
+ metadata.gz: 978aea94d42c6259b8add5117692fbfd28ef071b5c9a078ec45900173bc76041481cba36433d7ce6eaf2d6dafa2d7cf1365e92c2b55a6c0946f19ca212d3c8dd
7
+ data.tar.gz: 4d956731a73bbc7750f67161f6a69acc9e7befa96c0ad8b5e02bfb7ccedbac4002cc2fa39b43ab0643c07a062c0d3e84315757d72ccac0a291ca1a0ad3cb09a2
data/CHANGELOG.md CHANGED
@@ -1,11 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 (2026-06-21)
4
+
5
+ Lifecycle-row rewrite and auth-safety correction for the Hotwire Native / Devise remember-me hardening introduced in 0.1.3.
6
+
7
+ - **Session rows are now the lifecycle source of truth.** 0.1.x used "row missing + event tombstone" as the revocation signal. That made Warden infer security intent from absence, which crossed the gem's own boundary: `sessions` decorates Devise/Rails auth, it does not own auth. 0.2 adds `ended_at`, `ended_reason`, `ended_by`, and `ended_metadata`; `sessions.live` is the revocable device set, and `sessions_events` is audit history.
8
+ - **Tracking can no longer turn quiet housekeeping into logout.** Warden fetch now kicks only when a token-backed row exists and has an explicit kicking lifecycle reason (`logout`, `expired`, `user_revoked`, `admin_revoked`, `password_change`, `logout_everywhere`, `pruned`, `unknown`). Quiet `superseded`, token mismatch, database lookup errors, stale tracking keys, and missing rows fail open and clear only the gem's tracking key. Legacy v0.1.x destroyed rows still kick when an old `revoked`/`expired` event proves explicit action.
9
+ - **Remembered Warden restores stay quiet.** Same-device remember-me restores still reattach to the existing row without a duplicate login event, preserving the 0.1.3 noise reduction. The tokenless `[row_id, nil]` value is now only a tracking hint: if the signed browser-continuity cookie matches, the row may be touched; if it does not, tracking is dropped for that request and Devise/Rails continue to own authentication.
10
+ - **Explicit revocation is transactional.** `revoke!` now ends the row and persists the matching `revoked`/`expired` audit event in one transaction. If the event cannot be written, the row is left live and the explicit action fails loudly.
11
+ - **Tokenless remembered restores still log out cleanly.** The quiet known-device hint introduced in 0.1.3 now participates in the Warden logout hook when the signed browser-continuity cookie still matches the row, so a user sign-out ends the device row and writes a `logout` event without needing a raw Warden token.
12
+ - **Upgrade migration added.** `rails generate sessions:upgrade` now adds the 0.2 lifecycle columns to existing installs. Fresh installs include them in the base sessions table.
13
+
3
14
  ## 0.1.3 (2026-06-21)
4
15
 
5
16
  Production-found hardening for Hotwire Native / Devise remember-me startup flows, plus compatibility and generated admin-schema fixes.
6
17
 
7
18
  - **Remembered Warden logins on non-document requests are deferred until the first document navigation.** A native iOS path-configuration fetch like `/native/configurations/ios/v1.json` can carry the remember-me cookie before the WebView entry page loads; the gem now keeps that login pending instead of recording a misleading `RailsFast/... CFNetwork/...` row/event, then records the real WebView/native device on the HTML request with the original `remembered` auth detail.
8
- - **Remembered restores for an already-live device now reattach to the existing row.** iOS WebView startup bursts can run several remembered document requests before the app settles; if the signed device cookie already names a live row for the same user/scope, the gem reuses it, writes no duplicate login event, and validates the tokenless Warden session against that same signed device cookie on later fetches. A destroyed row still kicks normally.
19
+ - **Remembered restores for an already-live device now reattach to the existing row.** iOS WebView startup bursts can run several remembered document requests before the app settles; if the signed device cookie already names a live row for the same user/scope, the gem reuses it, writes no duplicate login event, and validates the tokenless Warden session against that same signed device cookie on later fetches. Explicitly ended legacy rows still kick normally.
9
20
  - **Internal same-device superseding is quiet housekeeping again.** Replacing an abandoned same-browser row no longer writes a user-facing `revoked` / `superseded` event; remote revocations, password-change revocations, expiry and logout still write their normal trail entries.
10
21
  - **Pre-gem adoption also waits for a document request.** Existing authenticated sessions no longer get adopted from background JSON/native HTTP requests before the actual browser/WebView request can name the device.
11
22
  - **Trackdown integration duck-types optional fields.** `sessions` now tolerates older `trackdown` releases that do not expose `region` or coordinate methods, preserving valid country/city data instead of swallowing the whole geo result.
@@ -43,7 +54,7 @@ Plus a full-codebase audit pass:
43
54
  First release — the missing session layer for Rails. 🔐
44
55
 
45
56
  - **Live device registry** on the session table your app already has: the Rails 8 `sessions` table is adopted and enriched in place (zero app-code edits), and Devise apps get the same Rails-8-shaped table generated for them.
46
- - **Per-session remote revocation** that actually works on both stacks: `session.revoke!`, `user.revoke_other_sessions!`, `user.revoke_all_sessions!` — row destroyed, device signed out on its very next request. On Devise this generalizes the proven `session_limitable` mechanism into token-per-row (N devices, each individually revocable) and rotates remember-me credentials on revoke.
57
+ - **Per-session remote revocation** that actually works on both stacks: `session.revoke!`, `user.revoke_other_sessions!`, `user.revoke_all_sessions!` — the device is signed out on its very next request. On Devise this generalizes the proven `session_limitable` mechanism into token-per-row (N devices, each individually revocable) and rotates remember-me credentials on revoke.
47
58
  - **Append-only login-activity trail** (`sessions_events`): every successful *and failed* login, logout, revocation and expiry — with the attempted identity (even for unknown accounts), the device, the geo, and the trail↔registry linkage (`session_id`) no prior art has.
48
59
  - **Device dedup via browser continuity**: a signed, long-lived `sessions_device_id` cookie (minted only at login — never a pre-login tracker) identifies the browser install, so a repeat login from the same browser *supersedes* its old row instead of stacking duplicate "Firefox on macOS" entries. Robust to browser updates (identity is the cookie, not the UA); private windows and other users on the same machine stay separate; superseding is quiet housekeeping (no revocation hook, no remember-me rotation, and the surviving trail prevents false new-device alerts).
49
60
  - **The "Last used" badge, server-side**: `Sessions.last_login(request)` returns the most recent login event from THIS browser — on the login page, signed out — because the continuity cookie survives logout and login events carry the device id. One lookup powers the "Last used" pill next to your OAuth/passkey/password buttons; no JavaScript, no localStorage.
data/README.md CHANGED
@@ -14,7 +14,7 @@ And it's built for how people actually sign in now: password, OAuth (Google, App
14
14
  ## 👨‍💻 Example
15
15
 
16
16
  ```ruby
17
- current_user.sessions.active # every live device, most recent first
17
+ current_user.sessions.live # every live device, most recent first
18
18
 
19
19
  session = current_user.sessions.first
20
20
  session.device_name # => "Chrome 137 on macOS"
@@ -105,14 +105,14 @@ rails generate sessions:upgrade
105
105
  rails db:migrate
106
106
  ```
107
107
 
108
- This adds `sessions.adoption_key` (for installs before 0.1.2), the portable unique guard that makes pre-gem session adoption atomic under concurrent native-app request bursts, and `sessions_events.app_build` (for installs before 0.1.3), which keeps the event trail and generated madmin resource in sync. Fresh installs already get both from `sessions:install`.
108
+ This adds `sessions.adoption_key` (for installs before 0.1.2), `sessions_events.app_build` (for installs before 0.1.3), and the 0.2 lifecycle columns (`sessions.ended_at`, `ended_reason`, `ended_by`, `ended_metadata`). Fresh installs already get all of them from `sessions:install`.
109
109
 
110
110
  ## What `sessions` does (and doesn't) do
111
111
 
112
112
  **Does:**
113
113
 
114
114
  - **Live device registry** — one row per signed-in device on the (Rails-8-shaped) `sessions` table, enriched with parsed device intelligence, geolocation, auth method, and a throttled `last_seen_at`.
115
- - **Remote revocation that actually works** — destroy the row, and that device is logged out on its very next request, on both auth stacks. Revoking a Devise session also rotates remember-me credentials so a stolen long-lived cookie can't quietly revive it.
115
+ - **Remote revocation that actually works** — mark the row ended, and that device is logged out on its very next matching request, on both auth stacks. Revoking a Devise session also rotates remember-me credentials so a stolen long-lived cookie can't quietly revive it.
116
116
  - **Append-only login trail** — logins, *failed* logins (with the typed identity, even for accounts that don't exist), logouts, revocations, expirations. Each trail row links to the live session it created: a suspicious login is one lookup away from the kill switch.
117
117
  - **Every 2026 login method** — password and OAuth classify automatically (OmniAuth failures get captured too, via a composed `on_failure`); One Tap / passkeys / magic links / SSO take one `Sessions.tag` line.
118
118
  - **Hotwire Native device intelligence** — platform, OS version, and (on Android) device model work with zero setup; add the [UA prefix convention](#-hotwire-native) for app versions and iOS hardware models.
@@ -305,7 +305,7 @@ config.on_new_device = ->(user:, session:, event:) do
305
305
  end
306
306
  ```
307
307
 
308
- Pass the **event** to your mailer, not the session: the event is a persisted, GlobalID-able record that survives revocation (the session row may already be destroyed by the time an async job runs), and it carries everything the email needs — `event.user`, `event.device_name`, `event.location`, `event.country_flag`, `event.source_line`, `event.occurred_at`:
308
+ Pass the **event** to your mailer, not the session: the event is a persisted, GlobalID-able record that survives revocation and account-erasure cleanup, and it carries everything the email needs — `event.user`, `event.device_name`, `event.location`, `event.country_flag`, `event.source_line`, `event.occurred_at`:
309
309
 
310
310
  ```ruby
311
311
  class SecurityMailer < ApplicationMailer
@@ -353,7 +353,7 @@ Scopes are the admin product — `Sessions::Event.failed_logins.last_24_hours.gr
353
353
  rails generate sessions:madmin
354
354
  ```
355
355
 
356
- …generates the two resources (the live registry with a per-row **Revoke session** action, and the login trail with its triage scopes as filters) plus their controllers, with madmin's two namespacing footguns pre-solved. The generated files use only stock madmin APIs and are yours to restyle. For a per-user security panel (devices + trail on the user's show page), load `user.sessions.by_recency` and `user.session_events.recent` in a member action — including the user's *failed* attempts by matching `Sessions::Event.where(identity: Sessions::Event.normalize_identity(user.email))` (failures never link to accounts; matching the signed-in user's own identity is the safe way to show them).
356
+ …generates the two resources (the lifecycle registry with a per-row **Revoke session** action, and the login trail with its triage scopes as filters) plus their controllers, with madmin's two namespacing footguns pre-solved. The generated files use only stock madmin APIs and are yours to restyle. For a per-user security panel (devices + trail on the user's show page), load `user.sessions.live.by_recency` and `user.session_events.recent` in a member action — including the user's *failed* attempts by matching `Sessions::Event.where(identity: Sessions::Event.normalize_identity(user.email))` (failures never link to accounts; matching the signed-in user's own identity is the safe way to show them).
357
357
 
358
358
  ## 🧹 Retention & the sweep
359
359
 
@@ -372,7 +372,7 @@ It purges trail rows past `config.events_retention` (12 months by default — CN
372
372
  ## 🔏 Security & privacy posture
373
373
 
374
374
  - **Tracking never breaks login.** Every adapter path, parser, geo lookup and hook is error-isolated; the test suite includes a chaos test that detonates every pipeline stage at once and asserts sign-in still works.
375
- - **Tracking outages fail OPEN.** A revoked session is a row that's *gone*; an *errored* lookup (sessions table unreachable, a migration mid-deploy, a timeout) is an outage — the request proceeds untracked instead of logging anyone out. Kicks are scope-precise, too: revoking a user session never touches an admin scope riding the same rack session, or your cart/locale data.
375
+ - **Tracking outages fail OPEN.** A revoked session is a row with an explicit lifecycle end (`ended_reason`); an *errored* lookup (sessions table unreachable, a migration mid-deploy, a timeout) is an outage — the request proceeds untracked instead of logging anyone out. Kicks are scope-precise, too: revoking a user session never touches an admin scope riding the same rack session, or your cart/locale data.
376
376
  - **The trail rejects rewrites.** Normal Active Record mutations on events are blocked — `update`/`destroy` raise `ActiveRecord::ReadOnlyRecord`. The callback-bypassing APIs (`update_columns`, `delete_all`) remain available, because the gem's own sanctioned paths use them: async geo backfill, `Sessions.forget`'s GDPR scrub, the retention sweep. Append-only at the model-contract level — not a database constraint, and a host determined to rewrite history still can.
377
377
  - **No usable credential is ever persisted.** Devise-mode session tokens are random 32-byte values stored as SHA-256 digests; the raw token lives only in the user's own session. Rails-8-mode rows store nothing secret (the signed cookie is the credential). Nothing secret is ever logged.
378
378
  - **Revocation is server-side and immediate** (checked on the very next request, both stacks) — OWASP ASVS 7.4.1; "view and terminate any or all currently active sessions" is literally ASVS 3.3.4 / 7.5.2, the requirement this gem exists to satisfy.
@@ -427,10 +427,10 @@ end
427
427
 
428
428
  ## 🧱 Why the models?
429
429
 
430
- Two primitives, linked — **rows are active sessions; events are history**:
430
+ Two primitives, linked — **rows are lifecycle state; events are history**:
431
431
 
432
- - **`sessions`** (the registry — *your* table, Rails-8-shaped on both stacks): one row = one signed-in device. Destroyed on logout/revocation/expiry, which is what makes revocation instant both adapters resolve the row on every request, so a missing row *is* a remote logout. No soft-delete state machine.
433
- - **`sessions_events`** (the trail — gem-owned, append-only): what happened and from where, surviving the rows it describes. Its `session_id` is a plain column with no foreign key *on purpose*: history must outlive the registry.
432
+ - **`sessions`** (the registry — *your* table, Rails-8-shaped on both stacks): one row = one signed-in device lifecycle. `ended_at: nil` means live; logout, revocation, expiry, pruning, and supersede mark the row ended in place with `ended_reason`. Adapters only disconnect a device after the explicit lifecycle state says they should, so a tracking failure cannot silently delete auth state.
433
+ - **`sessions_events`** (the trail — gem-owned, append-only): what happened and from where. Events describe the lifecycle transition; the row itself remains the liveness source of truth. Its `session_id` is a plain column with no foreign key *on purpose*: history and retention must survive row purges, account erasure, and older host tables.
434
434
 
435
435
  On Rails 8 auth, the gem **adopts** the generated table and model: one migration adds columns (the `add_devise_to_users` precedent), and the 2-line `Session` model is decorated via a concern at boot — your generated code stays byte-identical. On Devise, the install generator creates the same Rails-8-shaped table and a 3-line shell model — so if you ever migrate Devise → Rails auth, your sessions table is already exactly where Rails expects it.
436
436
 
@@ -141,9 +141,9 @@ module Sessions
141
141
  def sessions_owner_sessions
142
142
  user = sessions_current_user
143
143
  if user.respond_to?(:sessions)
144
- user.sessions
144
+ user.sessions.live
145
145
  else
146
- Sessions.session_model.where(user: user)
146
+ Sessions.session_model.live.where(user: user)
147
147
  end
148
148
  end
149
149
 
@@ -7,7 +7,7 @@
7
7
  <%
8
8
  current_session = local_assigns[:current_session] || Sessions.current(request)
9
9
  sessions = local_assigns[:sessions] ||
10
- local_assigns[:user]&.sessions&.by_recency&.to_a ||
10
+ local_assigns[:user]&.sessions&.live&.by_recency&.to_a ||
11
11
  []
12
12
  routes = sessions_engine_routes if respond_to?(:sessions_engine_routes)
13
13
  %>
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