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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c9417a89572ea4f860d64648e8d085b9074dd4914d3078dbdf6e116c5087d10
4
- data.tar.gz: fda35041ba991b3dbfd4c4789eedd16f17c42764805f6fba633e956920796e6b
3
+ metadata.gz: 6ead91af5a7b6b9818df58d3c068ce4d59e09a5b865e80373ba2e7b7c5854e75
4
+ data.tar.gz: 6ad52d4780745c525de40a76cb462e5cedc87b3f54dfff7f9d803f69ad9f0033
5
5
  SHA512:
6
- metadata.gz: 428832edcb252f835b89a351f30459e9a884a288622d988df050356e5cd4322eafb70f070f9ae91b418451bdd4adfad111c2ab984eb9e2fe86950651266b7a23
7
- data.tar.gz: d56946f36346faa79f2c27c12bd69fa954018425ba85d852e98abce8b69c79d472f973c71a73e33a324598f1ddd5bb1ec058c5a5a6d34778748d0c4fc7f03111
6
+ metadata.gz: f32fb15dbbef609551071da2de77a4b6c0f9117e496a2405548d87a5fcddad11bb14b1e7c738e0bdd70112634ad92d7b5a1ecf3fb041b4b2d3420e8d0fba3e3b
7
+ data.tar.gz: fffa0d52b6c6353b1d326f0a76c7a3e49ea8bac0f3bf8b160923675c99bfa9d7a12db4f85e3120a532b677e4539fedb90b5aa80917eb88f6c87455699d52726e
data/CHANGELOG.md CHANGED
@@ -1,11 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2 (2026-07-06)
4
+
5
+ Closes the revocation-semantics gaps found auditing the 0.2.x lifecycle model against production data.
6
+
7
+ - **Explicit revocations now kick tokenless remembered devices too.** Remember-me restores reattach to their device row as a tokenless tracking hint (0.1.3 semantics). Hints still never turn absence or housekeeping into a logout — but when the authenticated owner, the signed browser-continuity cookie, and the row all agree it is the same device AND that row was explicitly ended (`admin_revoked`, `user_revoked`, `logout_everywhere`, …), the next request now kicks, exactly as the admin/devices UI promises. Previously such devices kept their live host session until it expired naturally (remember-me revival was already blocked; the active rack session was not).
8
+ - **`Sessions.current` resolves hint-tracked rows.** Remembered devices get their "this device" badge back on the devices page — and the page's refuse-to-revoke-current guard (plus `revoke_other_sessions!(current:)`) works for them again. The hint is corroborated against the signed browser-continuity cookie before being trusted.
9
+ - **Sweep expiry no longer masquerades as revocation.** `Sessions.sweep!` ends idle/over-age sessions with `end!(reason: :expired)` (audit event written, kicks on resume) instead of `revoke!` — no more account-wide remember-me rotation or `on_session_revoked` fire for timeout policy, matching the adapter's inline expiry and Devise's own timeoutable semantics.
10
+ - **Plain callable-object hooks receive `on_repeated_failed_logins` again.** 0.2.1's keyword-slicing dispatch introspected `#parameters`, which only Procs and Methods expose; a class-based hook (`def call(identity:, count:, event:)`) silently lost the alert. Dispatch now introspects `#method(:call)` for such objects.
11
+
12
+ ## 0.2.1 (2026-07-03)
13
+
14
+ - Prevent fresh-install and adopted-table migrations from creating the `ended_by` polymorphic index twice.
15
+ - Preserve custom string/ULID primary-key types in the 0.2 lifecycle upgrade migration.
16
+ - Make generated Madmin revocation controls lifecycle-aware so ended rows cannot report a second successful revoke.
17
+ - Correct the 0.2 documentation for `event.session`: it resolves retained lifecycle rows after logout/revocation and becomes `nil` only after hard deletion.
18
+ - Document that Devise's `bypass_sign_in` skips the Warden callbacks required to register a new session.
19
+ - Bound the device parser dependency to the tested `browser` 6.x API line.
20
+ - Let `config.layout` be a callable (`->(controller) { ... }`) for hosts that switch layouts between web and native shells.
21
+ - Pass `user:` (resolved best-effort, never persisted on the failed event) to `on_repeated_failed_logins` when the hook opts into the keyword.
22
+ - Add `sessions_devices_for(user)` / `sessions_history_for(user)` helpers so ejected/custom views keep live-device and enumeration-safe history semantics in one place.
23
+ - Add `rails generate sessions:madmin --user-panel` for the repeated per-user admin security panel concern/view.
24
+
25
+ ## 0.2.0 (2026-06-21)
26
+
27
+ Lifecycle-row rewrite and auth-safety correction for the Hotwire Native / Devise remember-me hardening introduced in 0.1.3.
28
+
29
+ - **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.
30
+ - **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.
31
+ - **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.
32
+ - **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.
33
+ - **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.
34
+ - **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.
35
+
3
36
  ## 0.1.3 (2026-06-21)
4
37
 
5
38
  Production-found hardening for Hotwire Native / Devise remember-me startup flows, plus compatibility and generated admin-schema fixes.
6
39
 
7
40
  - **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.
41
+ - **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
42
  - **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
43
  - **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
44
  - **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 +76,7 @@ Plus a full-codebase audit pass:
43
76
  First release — the missing session layer for Rails. 🔐
44
77
 
45
78
  - **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.
79
+ - **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
80
  - **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
81
  - **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
82
  - **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"
@@ -98,21 +98,21 @@ That's it. Every sign-in from now on lands on the devices page and in the trail
98
98
 
99
99
  ### Upgrading
100
100
 
101
- Existing apps upgrading from 0.1.0, 0.1.1, or 0.1.2 should copy the upgrade migrations and run them:
101
+ Existing apps upgrading from any 0.1.x release should copy the upgrade migrations and run them:
102
102
 
103
103
  ```bash
104
104
  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.
@@ -141,7 +141,7 @@ Three ways to ship it, pick your layer:
141
141
  (Revoke buttons render when the engine is mounted; without it you get the read-only registry.)
142
142
  3. **Eject and restyle** — `rails generate sessions:views` copies every template into `app/views/sessions/`, where your copies shadow the gem's automatically (the Devise move).
143
143
 
144
- The engine inherits from your `ApplicationController` (configurable via `config.parent_controller`), so your layout, auth and locale apply automatically. The current session is resolved on both stacks; destructive actions can be gated behind your own sudo/password-confirm flow with `config.require_reauthentication`. One heads-up: if your app layout leans heavily on host route helpers, isolated-engine rendering means those resolve through `main_app.*` — rendering the partials in your own page (layer 2) sidesteps the whole topic.
144
+ The engine inherits from your `ApplicationController` (configurable via `config.parent_controller`), so your layout, auth and locale apply automatically. `config.layout` can be a string/symbol or a callable (`->(controller) { controller.hotwire_native_app? ? "app" : "web" }`) when native and browser shells differ. The current session is resolved on both stacks; destructive actions can be gated behind a sudo/password-confirm flow with `config.require_reauthentication`. One heads-up: if your app layout leans heavily on host route helpers, isolated-engine rendering means those resolve through `main_app.*` — rendering the partials in your own page (layer 2) sidesteps the whole topic.
145
145
 
146
146
  A hard rule the page enforces: **you can never touch a session you don't own** (foreign ids 404 — existence never leaks), and the current session is never revocable from the page (that's what sign-out is for).
147
147
 
@@ -152,7 +152,7 @@ A hard rule the page enforces: **you can never touch a session you don't own** (
152
152
  ```ruby
153
153
  Sessions::Event.logins / .failed_logins / .logouts / .revocations / .expirations
154
154
  Sessions::Event.recent.last_days(90).for_ip("203.0.113.7")
155
- event.session # the live row it created nil once revoked (that's the point)
155
+ event.session # the lifecycle row it created (live or explicitly ended)
156
156
  event.new_device? # flagged when the login matched no prior device
157
157
  ```
158
158
 
@@ -212,6 +212,8 @@ Sessions.record_failed_attempt(request, scope: :user, identity: params[:email],
212
212
 
213
213
  Custom Warden strategies map with `config.strategy_methods = { "OtpAuthenticatable" => :otp }`. Everything else is `unknown` — the gem never guesses.
214
214
 
215
+ Devise's `bypass_sign_in` writes directly to its session serializer and deliberately skips Warden callbacks. Do not use it to establish a new login that must appear in the registry; use `sign_in` instead. It remains appropriate for refreshing the serialization of an already-tracked login.
216
+
215
217
  ### Two-factor flows (TOTP apps, security keys, Touch ID)
216
218
 
217
219
  Every mainstream Ruby 2FA setup creates the session at **full** authentication — we verified each one against its source — so the registry and trail stay correct without configuration. What varies is the labeling, and where a recipe is needed it's one line:
@@ -278,13 +280,13 @@ Per-attempt alerts are notification fatigue *and* an abuse vector (an attacker h
278
280
 
279
281
  ```ruby
280
282
  config.repeated_failed_logins = { threshold: 5, within: 15.minutes }
281
- config.on_repeated_failed_logins = ->(identity:, count:, event:) do
282
- user = User.find_by(email: identity) or next # identity is AS TYPED may match no account
283
+ config.on_repeated_failed_logins = ->(identity:, count:, event:, user:) do
284
+ next unless user # nil when the typed identity matches no account
283
285
  SecurityMailer.with(user: user, event: event).repeated_failed_logins.deliver_later
284
286
  end
285
287
  ```
286
288
 
287
- The `event` is the attempt that tripped the threshold — IP, location and device included. This complements (not replaces) Devise's `:lockable` and Rails 8's `rate_limit`: they *stop* the attacker; this tells the *user*.
289
+ The `event` is the attempt that tripped the threshold — IP, location and device included. `user:` is resolved in memory for the hook but still not persisted on failed events, preserving the no-enumeration data model. Existing hooks that only accept `identity:, count:, event:` keep working. This complements (not replaces) Devise's `:lockable` and Rails 8's `rate_limit`: they *stop* the attacker; this tells the *user*.
288
290
 
289
291
  ## 🌍 Geolocation (via `trackdown`, soft dependency)
290
292
 
@@ -305,7 +307,7 @@ config.on_new_device = ->(user:, session:, event:) do
305
307
  end
306
308
  ```
307
309
 
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`:
310
+ 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
311
 
310
312
  ```ruby
311
313
  class SecurityMailer < ApplicationMailer
@@ -353,7 +355,15 @@ Scopes are the admin product — `Sessions::Event.failed_logins.last_24_hours.gr
353
355
  rails generate sessions:madmin
354
356
  ```
355
357
 
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).
358
+ …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.
359
+
360
+ For the per-user security panel that most admin dashboards want (live devices + that user's login trail), run:
361
+
362
+ ```bash
363
+ rails generate sessions:madmin --user-panel
364
+ ```
365
+
366
+ That adds a reusable `Sessions::MadminUserPanel` concern and `app/views/madmin/users/sessions.html.erb`. Include the concern in `Madmin::UsersController`, add the printed member routes, and add a `member_action` link from your `UserResource`.
357
367
 
358
368
  ## 🧹 Retention & the sweep
359
369
 
@@ -372,7 +382,7 @@ It purges trail rows past `config.events_retention` (12 months by default — CN
372
382
  ## 🔏 Security & privacy posture
373
383
 
374
384
  - **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.
385
+ - **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
386
  - **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
387
  - **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
388
  - **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.
@@ -418,7 +428,7 @@ Sessions.configure do |config|
418
428
  config.parent_controller = "::ApplicationController"
419
429
  config.current_user_method = :current_user # chain: this → current_user → Current.session&.user
420
430
  config.authenticate_method = :authenticate_user!
421
- config.layout = nil # nil inherits the parent controller's layout
431
+ config.layout = nil # nil, "app", or ->(controller) { ... }
422
432
  config.require_reauthentication = nil # ->(controller) { ... } sudo gate
423
433
  config.session_class = "Session"
424
434
  config.strategy_methods = {} # { "OtpAuthenticatable" => :otp }
@@ -427,10 +437,10 @@ end
427
437
 
428
438
  ## 🧱 Why the models?
429
439
 
430
- Two primitives, linked — **rows are active sessions; events are history**:
440
+ Two primitives, linked — **rows are lifecycle state; events are history**:
431
441
 
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.
442
+ - **`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.
443
+ - **`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
444
 
435
445
  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
446
 
@@ -112,7 +112,8 @@ module Sessions
112
112
  end
113
113
 
114
114
  def sessions_layout
115
- Sessions.config.layout
115
+ configured = Sessions.config.layout
116
+ configured.respond_to?(:call) ? configured.call(self) : configured
116
117
  end
117
118
 
118
119
  # The optional sudo gate (ASVS 3.3.4's "having re-entered login
@@ -141,9 +142,9 @@ module Sessions
141
142
  def sessions_owner_sessions
142
143
  user = sessions_current_user
143
144
  if user.respond_to?(:sessions)
144
- user.sessions
145
+ user.sessions.live
145
146
  else
146
- Sessions.session_model.where(user: user)
147
+ Sessions.session_model.live.where(user: user)
147
148
  end
148
149
  end
149
150
 
@@ -45,6 +45,29 @@ module Sessions
45
45
  EVENT_ICON_NAMES.fetch(event.event.to_s, "information-circle")
46
46
  end
47
47
 
48
+ # Semantic queries for custom/ejected views. Keep liveness and
49
+ # enumeration-safe history semantics in the gem, so host styling changes
50
+ # don't accidentally regress the data contract.
51
+ def sessions_devices_for(user, limit: nil)
52
+ scope = if user.respond_to?(:sessions)
53
+ user.sessions.live.by_recency
54
+ else
55
+ Sessions.session_model.live.where(user: user).by_recency
56
+ end
57
+ limit ? scope.limit(limit) : scope
58
+ end
59
+
60
+ def sessions_history_for(user, limit: nil)
61
+ scope = if user.respond_to?(:session_history)
62
+ user.session_history.recent
63
+ elsif user.respond_to?(:session_events)
64
+ user.session_events.recent
65
+ else
66
+ Sessions::Event.where(authenticatable: user).recent
67
+ end
68
+ limit ? scope.limit(limit) : scope
69
+ end
70
+
48
71
  # "Active now" within the touch window, else "Active 3 minutes ago".
49
72
  def sessions_last_active_in_words(session)
50
73
  # active_now? owns the window (config.touch_every) — the badge can't
@@ -1,13 +1,13 @@
1
1
  <%# The live device registry — renderable from the engine OR straight from %>
2
2
  <%# any host view: render "sessions/devices", user: current_user %>
3
3
  <%# Locals: %>
4
- <%# sessions: the rows to render (defaults to user.sessions) %>
4
+ <%# sessions: the rows to render (defaults via sessions_devices_for)%>
5
5
  <%# user: whose devices (only needed when sessions: isn't given)%>
6
6
  <%# current_session: the row serving this request (badged, not revocable) %>
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
+ (sessions_devices_for(local_assigns[:user]).to_a if local_assigns[:user] && respond_to?(:sessions_devices_for)) ||
11
11
  []
12
12
  routes = sessions_engine_routes if respond_to?(:sessions_engine_routes)
13
13
  %>
@@ -1,12 +1,12 @@
1
1
  <%# The login-activity trail — renderable from any host view too: %>
2
2
  <%# render "sessions/history", user: current_user, limit: 10 %>
3
3
  <%# Locals: %>
4
- <%# events: the trail rows (defaults to user.session_events.recent) %>
4
+ <%# events: the trail rows (defaults via sessions_history_for) %>
5
5
  <%# user: whose history (only needed when events: isn't given) %>
6
6
  <%# limit: cap when deriving from user (default 10) %>
7
7
  <%
8
8
  events = local_assigns[:events] ||
9
- local_assigns[:user]&.session_events&.recent&.limit(local_assigns.fetch(:limit, 10)) ||
9
+ (sessions_history_for(local_assigns[:user], limit: local_assigns.fetch(:limit, 10)) if local_assigns[:user] && respond_to?(:sessions_history_for)) ||
10
10
  []
11
11
  %>
12
12
  <% if events.respond_to?(:empty?) && events.empty? %>