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.
@@ -1,6 +1,6 @@
1
- # CarHey audit: auth, sessions & device detection
1
+ # HostApp audit: auth, sessions & device detection
2
2
 
3
- Read-only audit of `/Users/javi/GitHub/carhey` (Rails 8.1.1, RailsFast, Devise), its native shells `/Users/javi/GitHub/carhey-ios` + `/Users/javi/GitHub/carhey-android`, and (addendum) `/Users/javi/GitHub/licenseseat`. Audited 2026-06-11. All paths relative to each repo root unless absolute. Facts cite `path:line`; anything inferred is marked as such.
3
+ Read-only audit of `/path/to/repos/hostapp` (Rails 8.1.1, RailsFast, Devise), its native shells `/path/to/repos/hostapp-ios` + `/path/to/repos/hostapp-android`, and (addendum) `/path/to/repos/licenseseat`. Audited 2026-06-11. All paths relative to each repo root unless absolute. Facts cite `path:line`; anything inferred is marked as such.
4
4
 
5
5
  ## Top findings
6
6
 
@@ -8,9 +8,9 @@ Read-only audit of `/Users/javi/GitHub/carhey` (Rails 8.1.1, RailsFast, Devise),
8
8
  - Admin is a `users.admin` boolean (`db/schema.rb:1260`) gated by `authenticate :user, lambda { |u| u.admin? }` (`config/routes.rb:26`). Single Devise scope; no OmniAuth, no passkeys, no 2FA, no magic links.
9
9
  - Session store is Rails' default CookieStore — no `session_store` initializer exists anywhere in `config/` (verified by grep). Sessions are therefore **unenumerable and unrevocable server-side** today.
10
10
  - Native apps get silent 1-year remember-me: `remember_hotwire_native_session` (`app/controllers/users/sessions_controller.rb:187-198`) + `config.remember_for = 1.year` (`config/initializers/devise.rb:175`).
11
- - CarHey already has a world-class **signup-time** device fingerprint: 22 `signup_*` columns on `users` (`db/schema.rb:1306-1322`), populated by `SignupAttribution` (DeviceDetector + UA Client Hints, `app/services/signup_attribution.rb`) and `SignupDisplayMetrics`. It is one-shot — never refreshed at login.
11
+ - HostApp already has a world-class **signup-time** device fingerprint: 22 `signup_*` columns on `users` (`db/schema.rb:1306-1322`), populated by `SignupAttribution` (DeviceDetector + UA Client Hints, `app/services/signup_attribution.rb`) and `SignupDisplayMetrics`. It is one-shot — never refreshed at login.
12
12
  - Per-user "what client is this user running NOW" exists as `last_seen_*` columns (`db/schema.rb:1280-1284`), written only by the native JSON API via a race-safe throttled SQL `UPDATE ... WHERE IS DISTINCT FROM` (`app/controllers/api/v1/base_controller.rb:32-57`). Web requests never touch it; one row per user, not per device.
13
- - Native shells announce themselves via load-bearing UA tokens: Android WebView prefix `"CarHey Android;"` (`carhey-android .../CarHeyApplication.kt:169`), iOS `"CarHey iOS; RailsFast Native iOS;"` (`carhey-ios RailsFast/Core/AppConfiguration.swift:10-12`). Server matches `/\b(?:CarHey\s+Android|Hotwire Native Android|Turbo Native Android)\b/i` (`app/services/signup_attribution.rb:315-324`).
13
+ - Native shells announce themselves via load-bearing UA tokens: Android WebView prefix `"HostApp Android;"` (`hostapp-android .../HostAppApplication.kt:169`), iOS `"HostApp iOS; RailsFast Native iOS;"` (`hostapp-ios RailsFast/Core/AppConfiguration.swift:10-12`). Server matches `/\b(?:HostApp\s+Android|Hotwire Native Android|Turbo Native Android)\b/i` (`app/services/signup_attribution.rb:315-324`).
14
14
  - Both shells also stamp **exact** `X-Client-Platform/Version/Build/OS` headers on native JSON calls (Android OkHttp interceptor `NativeHttpClient.kt:47-80`; iOS `NativeHttpClient.swift:12-56`), parsed server-side by `ClientVersionInfo` (`app/controllers/concerns/client_version_info.rb`).
15
15
  - **No push notifications anywhere**: no FCM/APNs code in either shell (grep for `UNUserNotificationCenter|registerForRemoteNotifications|Firebase` returned nothing), no device-token tables, no push gems.
16
16
  - IP geolocation = `trackdown` 0.2.0, **no initializer** (default `:auto` provider → Cloudflare `CF-IPCountry`/`CF-IPCity` headers), called exactly once in the whole app: synchronously at signup (`app/controllers/users/registrations_controller.rb:45`).
@@ -127,7 +127,7 @@ end
127
127
 
128
128
  **Server-side detection** is turbo-rails' `hotwire_native_app?` (UA `~ /(Turbo|Hotwire) Native/`; cited in-code at `app/controllers/application_controller.rb:195-196`). Per-platform: `HotwireNativeHelper#hotwire_native_platform` (`app/helpers/hotwire_native_helper.rb:63-69`) checks UA for literal `"Hotwire Native Android"` / `"Hotwire Native iOS"`. Bridge-component capability sniffing parses the UA's `bridge-components: [...]` list (:78-94) — e.g. the toast component decides flash handling at `app/controllers/native/entries_controller.rb:40-45`.
129
129
 
130
- **iOS WebView UA** — prefix built at `carhey-ios/RailsFast/Core/AppConfiguration.swift:10-12`:
130
+ **iOS WebView UA** — prefix built at `hostapp-ios/RailsFast/Core/AppConfiguration.swift:10-12`:
131
131
 
132
132
  ```swift
133
133
  static var userAgentPrefix: String {
@@ -135,29 +135,29 @@ static var userAgentPrefix: String {
135
135
  }
136
136
  ```
137
137
 
138
- `applicationName` = `CFBundleDisplayName` = `CarHey` (`carhey-ios/project.yml:63`), set via `Hotwire.config.applicationUserAgentPrefix` (`carhey-ios/RailsFast/App/AppDelegate.swift:102`). Per the comment at `AppDelegate.swift:90-93`, the framework appends `"Hotwire Native iOS"`, `"Turbo Native iOS"`, and the bridge-component list — so the effective WebView UA starts `CarHey iOS; RailsFast Native iOS; Hotwire Native iOS; Turbo Native iOS; bridge-components: [...]` followed by the WebKit UA (final composed string is framework behavior — inference from those comments, not observed at runtime).
138
+ `applicationName` = `CFBundleDisplayName` = `HostApp` (`hostapp-ios/project.yml:63`), set via `Hotwire.config.applicationUserAgentPrefix` (`hostapp-ios/RailsFast/App/AppDelegate.swift:102`). Per the comment at `AppDelegate.swift:90-93`, the framework appends `"Hotwire Native iOS"`, `"Turbo Native iOS"`, and the bridge-component list — so the effective WebView UA starts `HostApp iOS; RailsFast Native iOS; Hotwire Native iOS; Turbo Native iOS; bridge-components: [...]` followed by the WebKit UA (final composed string is framework behavior — inference from those comments, not observed at runtime).
139
139
 
140
- **iOS native-JSON UA + headers** — `carhey-ios/RailsFast/Core/NativeHttpClient.swift:12-15,61-72`: headers `X-Client-Platform: ios`, `X-Client-Version` (CFBundleShortVersionString), `X-Client-Build` (CFBundleVersion), `X-Client-OS` (`"iOS \(version)"`), and UA:
140
+ **iOS native-JSON UA + headers** — `hostapp-ios/RailsFast/Core/NativeHttpClient.swift:12-15,61-72`: headers `X-Client-Platform: ios`, `X-Client-Version` (CFBundleShortVersionString), `X-Client-Build` (CFBundleVersion), `X-Client-OS` (`"iOS \(version)"`), and UA:
141
141
 
142
142
  ```swift
143
143
  return "\(applicationName) iOS \(version) (build \(build); iOS \(osVersion); \(resolvedModel))"
144
144
  ```
145
145
 
146
- **Android WebView UA** — `carhey-android/.../CarHeyApplication.kt:169`: `Hotwire.config.applicationUserAgentPrefix = "CarHey Android;"`.
146
+ **Android WebView UA** — `hostapp-android/.../HostAppApplication.kt:169`: `Hotwire.config.applicationUserAgentPrefix = "HostApp Android;"`.
147
147
 
148
- **Android OkHttp UA + headers** — `carhey-android/.../ClientHeaders.kt:25-28,66-77`:
148
+ **Android OkHttp UA + headers** — `hostapp-android/.../ClientHeaders.kt:25-28,66-77`:
149
149
 
150
150
  ```kotlin
151
- return "CarHey Android $versionName (build $versionCode; Android $osRelease; sdk $sdkInt; $device)"
151
+ return "HostApp Android $versionName (build $versionCode; Android $osRelease; sdk $sdkInt; $device)"
152
152
  ```
153
153
 
154
- stamped by an application-level interceptor with `.header()` replace-not-append semantics (`carhey-android/.../NativeHttpClient.kt:47-80`). `ClientHeaders.kt:17-21` documents the **critical contract**: the UA must contain the literal space-separated token `CarHey Android` because the server matches `/\bCarHey\s+Android\b/i` — `SignupAttribution#detect_native_platform` (`app/services/signup_attribution.rb:315-324`) accepts `CarHey Android|Hotwire Native Android|Turbo Native Android` and `CarHey iOS|iPhone|iPad|Hotwire Native iOS|Turbo Native iOS`.
154
+ stamped by an application-level interceptor with `.header()` replace-not-append semantics (`hostapp-android/.../NativeHttpClient.kt:47-80`). `ClientHeaders.kt:17-21` documents the **critical contract**: the UA must contain the literal space-separated token `HostApp Android` because the server matches `/\bHostApp\s+Android\b/i` — `SignupAttribution#detect_native_platform` (`app/services/signup_attribution.rb:315-324`) accepts `HostApp Android|Hotwire Native Android|Turbo Native Android` and `HostApp iOS|iPhone|iPad|Hotwire Native iOS|Turbo Native iOS`.
155
155
 
156
156
  **Header parsing server-side** — `ClientVersionInfo` concern (`app/controllers/concerns/client_version_info.rb`): platform allow-list `%w[android ios web]`, semver regex, build clamped to Play's 2.1B cap, 64-char OS cap; explicit "spoofable, diagnostics-only, never authorization" doctrine (:7-12). Fallback path delegates to `SignupAttribution` (:66-75). Consumed by `Api::V1::BaseController#touch_last_seen_client` (:32-57) — the throttled, race-safe `update_all` with `IS DISTINCT FROM` predicates that maintains `users.last_seen_*`.
157
157
 
158
158
  **Session-relevant native routes** (`config/routes.rb:84-95`): `/native/entry` (canonical signed-out bootstrap), `/native/handoff` (auth-success), `/native/auth/welcome` (legacy alias), `/native/configurations/{ios,android}/v1` (server-driven path configuration, version-gated auth sheet/handoff rules in `app/controllers/native/configurations_controller.rb:67-75,196-205,344-352,494`). Native bootstrap defensively consumes **stale remember cookies of now-inactive users** before Warden runs: `consume_inactive_native_remembered_user!` reads `cookies.signed["remember_user_token"]` via `User.serialize_from_cookie` without firing strategies (`app/controllers/application_controller.rb:371-417`) — a subtle Devise+native lifecycle edge any session gem must not break.
159
159
 
160
- **One cookie = one session across surfaces.** Native JSON requests authenticate with the *same Rails session cookie* as the WebView: Hotwire Native syncs WKWebView cookies into `HTTPCookieStorage.shared` after every page load and the iOS URLSession is explicitly configured to use it (`carhey-ios/RailsFast/Core/NativeHttpClient.swift:75-100`, with source links in-code); Android's OkHttp client follows the same posture. Consequence for the gem: a "device" is one shared cookie jar spanning WebView navigations *and* native HTTP calls — per-request UA strings differ (WebView UA vs `ClientHeaders` UA) while the session identity stays constant, so device identity must key off the session/remember cookie, never off the UA.
160
+ **One cookie = one session across surfaces.** Native JSON requests authenticate with the *same Rails session cookie* as the WebView: Hotwire Native syncs WKWebView cookies into `HTTPCookieStorage.shared` after every page load and the iOS URLSession is explicitly configured to use it (`hostapp-ios/RailsFast/Core/NativeHttpClient.swift:75-100`, with source links in-code); Android's OkHttp client follows the same posture. Consequence for the gem: a "device" is one shared cookie jar spanning WebView navigations *and* native HTTP calls — per-request UA strings differ (WebView UA vs `ClientHeaders` UA) while the session identity stays constant, so device identity must key off the session/remember cookie, never off the UA.
161
161
 
162
162
  **Push device registration: none.** No notification code in either shell, no token model, no `/native` push route. (Verified: zero `UNUserNotificationCenter`/`registerForRemoteNotifications`/Firebase hits in either repo.)
163
163
 
@@ -180,7 +180,7 @@ stamped by an application-level interceptor with `.header()` replace-not-append
180
180
 
181
181
  - **Stack** (`0-overview.mdc`): RailsFast omakase — Postgres, importmaps/no-build, Tailwind 4 + heroicons, Solid Queue/Cache/Cable (no Redis), Kamal deploys behind Cloudflare, AWS SES mail, madmin admin, goodmail emails, `moderate` for T&S. Inline comments must carry rationale + source URLs ("Document as you go").
182
182
  - **Quality** (`1-quality.mdc`): DRY/KISS/YAGNI, "the best part is no part", no enterprise architecture, idiomatic Rails, syntactic sugar valued.
183
- - **Project specifics** (`3-project-specifics.mdc`): mobile-first Hotwire Native hybrid; minimize native code, maximize Rails-rendered surface; sister repos `../carhey-android`, `../carhey-ios`; design parity native↔web.
183
+ - **Project specifics** (`3-project-specifics.mdc`): mobile-first Hotwire Native hybrid; minimize native code, maximize Rails-rendered surface; sister repos `../hostapp-android`, `../hostapp-ios`; design parity native↔web.
184
184
  - **Namespacing**: domain modules as dirs — `Ride::` (`app/models/ride/*.rb`), `User::TripFeed` (`app/models/user/trip_feed.rb`), controllers under `users/`, `native/`, `ride/`, `compliance/`; jobs namespaced (`app/jobs/operations/`, `Operations::ReportAlertJob` per `config/initializers/moderate.rb:52`) on Solid Queue.
185
185
  - **Mailers**: `*Goodmailer` classes; `DeviseGoodmailer < Devise::Mailer` rebuilds every Devise email with goodmail's DSL (`text`/`button`/`code_box`/`sign`) (`app/mailers/devise_goodmailer.rb`); brand config in `config/initializers/goodmail.rb`.
186
186
  - **Testing**: Minitest, `parallelize(workers: :number_of_processors)`, `fixtures :all`, helpers `create_user`/`create_organization_for`, PostGIS SRID bootstrap (`test/test_helper.rb:8-40`).
@@ -224,26 +224,26 @@ stamped by an application-level interceptor with `.header()` replace-not-append
224
224
 
225
225
  ## LicenseSeat (second target app: plain-web RailsFast SaaS)
226
226
 
227
- `/Users/javi/GitHub/licenseseat` — Rails 8 licensing SaaS, same RailsFast template, **no Hotwire Native shell**, UI in English.
227
+ `/path/to/repos/licenseseat` — Rails 8 licensing SaaS, same RailsFast template, **no Hotwire Native shell**, UI in English.
228
228
 
229
229
  - **Auth stack is the same RailsFast wiring**: identical Devise module list incl. `:trackable` (`app/models/user.rb:6-8`), identical 4 custom controllers (`config/routes.rb:2-7`), `DeviseGoodmailer`, Turnstile, madmin drawn at `/admin/dashboard` (`config/routes.rb:36-37`), `users.admin` boolean. Differences: devise **4.9.4** (`Gemfile.lock:167`) and a leaner initializer — `allow_unconfirmed_access_for = 6.hours`, **no** `remember_for`/`extend_remember_period`/`rememberable_options` overrides, **no** custom failure app (verified non-comment lines of `config/initializers/devise.rb`). So the gem must span devise 4.x and 5.x.
230
- - **Leaner users table**: trackable columns + `signup_ip/_city/_country/_country_code` only — none of CarHey's 18 UA/device/display `signup_*` columns, no `last_seen_*` (users table in `db/schema.rb`, cols at offsets :2-26 of the block). No `SignupAttribution`; `device_detector` not in the Gemfile.
231
- - **Trackdown 0.3.1 with a full initializer** (`config/initializers/trackdown.rb`): `provider = :auto`, MaxMind account/license from credentials, `db/geodata/GeoLite2-City.mmdb`, `reject_private_ips` in prod, auto-download on boot, plus `TrackdownDatabaseRefreshJob` (`app/jobs/trackdown_database_refresh_job.rb:1-5`). Same single sync call site at signup (`app/controllers/users/registrations_controller.rb:80-87`). This is the MaxMind-backed config shape the gem's soft dependency should also support (vs CarHey's zero-config CF-header mode).
230
+ - **Leaner users table**: trackable columns + `signup_ip/_city/_country/_country_code` only — none of HostApp's 18 UA/device/display `signup_*` columns, no `last_seen_*` (users table in `db/schema.rb`, cols at offsets :2-26 of the block). No `SignupAttribution`; `device_detector` not in the Gemfile.
231
+ - **Trackdown 0.3.1 with a full initializer** (`config/initializers/trackdown.rb`): `provider = :auto`, MaxMind account/license from credentials, `db/geodata/GeoLite2-City.mmdb`, `reject_private_ips` in prod, auto-download on boot, plus `TrackdownDatabaseRefreshJob` (`app/jobs/trackdown_database_refresh_job.rb:1-5`). Same single sync call site at signup (`app/controllers/users/registrations_controller.rb:80-87`). This is the MaxMind-backed config shape the gem's soft dependency should also support (vs HostApp's zero-config CF-header mode).
232
232
  - **`footprinted` 0.3.1 is ACTIVE** (`Gemfile:181`): `footprints` table (`db/schema.rb:119-160`) is polymorphic geo+device telemetry — `event_type`, `inet ip`, lat/lng, country/city/continent, plus promoted device columns `device_id`, `app_version`, `platform`, `os_name/_version`, `device_type`, `architecture`, `cpu_cores`, `memory_gb`, `jsonb metadata`. Configured async (`config/initializers/footprinted.rb`: `config.async = true`), metadata→column promotion monkey-patch (`config/initializers/footprinted_extensions.rb`), used for **product/licensing telemetry** (DAU/MAU by `device_id`, `app/models/concerns/product_analytics.rb:13-27`; intake via `app/controllers/concerns/license_seat_telemetry.rb`) — *not* for login tracking. It's the closest existing schema to a "device" row in either app.
233
233
  - **Token auth exists**: `api_keys` 0.3.0 (git main; `Gemfile:190`) — org-owned `pk_`/`sk_` keys with per-key permissions (`config/initializers/api_keys.rb:1-25`), `api_keys` table with `last_used_at`/`revoked_at`/`token_digest` (`db/schema.rb:45-75`), self-serve UI under `namespace :settings { resources :api_keys ... }` (`config/routes.rb:160-162`) and madmin resources at `app/madmin/resources/api_keys/`. The licensing API authenticates with these keys, not cookies — session tracking must not swallow those requests. `license_seat_activations` even stores `ip_address` per machine activation (`db/schema.rb:192-198`).
234
- - **Settings layout differs**: single `settings#show` (`config/routes.rb:157`) + a `settings` namespace for sub-resources; views are form partials (`app/views/settings/_account_form.html.erb`, `_organization_form.html.erb`) rather than CarHey's setting_row sections. A sessions page here would naturally be `settings/sessions` inside that namespace.
234
+ - **Settings layout differs**: single `settings#show` (`config/routes.rb:157`) + a `settings` namespace for sub-resources; views are form partials (`app/views/settings/_account_form.html.erb`, `_organization_form.html.erb`) rather than HostApp's setting_row sections. A sessions page here would naturally be `settings/sessions` inside that namespace.
235
235
  - No `moderate`/`AuditLog` (moderate commented at `Gemfile:184`); audit needs are gem-owned (`license_seat_audit_events`, `db/schema.rb:214`).
236
236
 
237
237
  ## Implications for the sessions gem (opinion)
238
238
 
239
- 1. **Own the truth in a DB-backed session/device registry** (à la Rails 8 `Session` model): a signed per-device cookie (or session-id claim) checked against a `sessions` row each request. That's the only way to get enumeration + revocation on top of CookieStore — and it must also bind/rotate with Devise's remember-me, since CarHey's native sessions are effectively 1-year remember cookies (`sessions_controller.rb:187-198`). Per-device remember tokens or a session-epoch column on the row is the revocation primitive.
240
- 2. **Hook Warden, not Devise controllers**: `after_set_user`/`after_authentication`/`before_logout` covers stock + CarHey's manual native branch (which still calls `sign_in`); ship a separate adapter for Rails 8 omakase auth (no Warden there). Never run model callbacks in the hot path — see the read-only `warden.user(run_callbacks: false)` pattern and the stale-remember-cookie bootstrap (`application_controller.rb:360-417`) the gem must coexist with.
241
- 3. **Record failures too**: subscribe to `warden.authenticate` failure / Devise lockable paths; CarHey's manual branch renders 422 without touching Warden's failure app (`sessions_controller.rb:96-118`), so the gem needs an explicit `track_failed_attempt` seam callable from custom controllers.
239
+ 1. **Own the truth in a DB-backed session/device registry** (à la Rails 8 `Session` model): a signed per-device cookie (or session-id claim) checked against a `sessions` row each request. That's the only way to get enumeration + revocation on top of CookieStore — and it must also bind/rotate with Devise's remember-me, since HostApp's native sessions are effectively 1-year remember cookies (`sessions_controller.rb:187-198`). Per-device remember tokens or a session-epoch column on the row is the revocation primitive.
240
+ 2. **Hook Warden, not Devise controllers**: `after_set_user`/`after_authentication`/`before_logout` covers stock + HostApp's manual native branch (which still calls `sign_in`); ship a separate adapter for Rails 8 omakase auth (no Warden there). Never run model callbacks in the hot path — see the read-only `warden.user(run_callbacks: false)` pattern and the stale-remember-cookie bootstrap (`application_controller.rb:360-417`) the gem must coexist with.
241
+ 3. **Record failures too**: subscribe to `warden.authenticate` failure / Devise lockable paths; HostApp's manual branch renders 422 without touching Warden's failure app (`sessions_controller.rb:96-118`), so the gem needs an explicit `track_failed_attempt` seam callable from custom controllers.
242
242
  4. **Adopt the AuditLog.log signature** (`event_type:, data:, user:, request:`) for its event API and `inet ip_address` + `text user_agent` column types; offer an optional sink so apps can tee login events into their own AuditLog.
243
- 5. **Lift SignupAttribution into the gem as the per-session parser** (device_detector + Client Hints + Hotwire Native tokens incl. configurable app-prefix regexes like `CarHey Android`, plus `bridge-components:` parsing) and honor `X-Client-Platform/Version/Build/OS` with `ClientVersionInfo`-style validation. That turns CarHey's signup-only intelligence into every-session intelligence and lets the gem name devices "CarHey en Pixel 7 (Android 14)".
243
+ 5. **Lift SignupAttribution into the gem as the per-session parser** (device_detector + Client Hints + Hotwire Native tokens incl. configurable app-prefix regexes like `HostApp Android`, plus `bridge-components:` parsing) and honor `X-Client-Platform/Version/Build/OS` with `ClientVersionInfo`-style validation. That turns HostApp's signup-only intelligence into every-session intelligence and lets the gem name devices "HostApp en Pixel 7 (Android 14)".
244
244
  6. **Copy the throttled `update_all ... IS DISTINCT FROM` touch** (`api/v1/base_controller.rb:32-57`) for per-session `last_seen_at` — hot-row safe, callback-free.
245
- 7. **Geo = trackdown soft dependency, both modes**: sync CF-header path when free (CarHey), async job fallback for MaxMind lookups (LicenseSeat shape); never block sign-in on geo (mirror the rescue at `registrations_controller.rb:45-53`).
246
- 8. **UI**: ship a controller + plain-Tailwind views/partials apps can render inside their own settings shell (CarHey's `setting_row` sections vs LicenseSeat's `settings` namespace prove one fixed page won't fit); i18n-first (CarHey is Spanish). Provide a madmin resource template mirroring `audit_log_resource.rb`. New-device email should be a plain ActionMailer that apps can override with a `*Goodmailer`.
245
+ 7. **Geo = trackdown soft dependency, both modes**: sync CF-header path when free (HostApp), async job fallback for MaxMind lookups (LicenseSeat shape); never block sign-in on geo (mirror the rescue at `registrations_controller.rb:45-53`).
246
+ 8. **UI**: ship a controller + plain-Tailwind views/partials apps can render inside their own settings shell (HostApp's `setting_row` sections vs LicenseSeat's `settings` namespace prove one fixed page won't fit); i18n-first (HostApp is Spanish). Provide a madmin resource template mirroring `audit_log_resource.rb`. New-device email should be a plain ActionMailer that apps can override with a `*Goodmailer`.
247
247
  9. **Stay out of token-auth lanes**: skip tracking for `api_keys`-authenticated and other non-cookie requests by default (LicenseSeat's licensing API).
248
248
  10. **Schema suggestion**: `sessions` (per device/browser: user, token/epoch, ip `inet`, ua `text`, parsed client/platform/browser/os/app_version/app_build/device_model, geo country/city, created/last_seen/revoked_at, revoked_reason) + `login_attempts` (success+failure, email-as-typed, user nullable, ip, ua, geo, failure_reason) — append-only like AuditLog but without the hash chain in v1 (advisory-lock serialization is explicitly unsuitable for high-frequency writes per `audit_log.rb` comments echoed in `api/v1/base_controller.rb:29-30`). Use UUID PKs — every table in both apps is `id: :uuid, default: gen_random_uuid()` — but don't hard-require Postgres types if SQLite support matters. Leave a `push_token` column or a `session_devices`-style association point for the push registration neither app has yet.
249
249
 
@@ -3,7 +3,7 @@
3
3
  Research memo for the `sessions` gem (drop-in session & login-activity tracking, device management, Rails 8+).
4
4
  Sources: read-only study of 10 local repos — `trackdown`, `footprinted` (deep dives), `usage_credits`,
5
5
  `pricing_plans`, `api_keys`, `nondisposable`, `profitable`, `wallets` (mature, trust most), `moderate`, `chats`
6
- (newer, best for UI-shipping + host hooks). All 10 repos present. Citations are `path:line` under `/Users/javi/GitHub/`.
6
+ (newer, best for UI-shipping + host hooks). All 10 repos present. Citations are `path:line` under `/path/to/repos/`.
7
7
 
8
8
  ## Top findings
9
9
 
@@ -1,6 +1,6 @@
1
1
  # Device intelligence: UA parsing, Hotwire Native, client hints, IP capture
2
2
 
3
- Researched 2026-06-11. Code citations are from read-only clones under `/tmp/sessions-research/` (browser, device_detector, hotwire-native-ios, hotwire-native-android, turbo-rails, rails-stable @ v8.1.3) and the local apps under `/Users/javi/GitHub/`. Web citations carry URL + fetch date.
3
+ Researched 2026-06-11. Code citations are from read-only clones under `/tmp/sessions-research/` (browser, device_detector, hotwire-native-ios, hotwire-native-android, turbo-rails, rails-stable @ v8.1.3) and the local apps under `/path/to/repos/`. Web citations carry URL + fetch date.
4
4
 
5
5
  ## Top findings
6
6
 
@@ -11,7 +11,7 @@ Researched 2026-06-11. Code citations are from read-only clones under `/tmp/sess
11
11
  - **Android WebView is explicitly excluded from Chrome's UA reduction** ("We don't have current plans for User-Agent Reduction on iOS and Android WebView" — chromium.org/updates/ua-reduction, fetched 2026-06-10). So Hotwire Native **Android** UAs still carry real device model + real Android version. Hotwire Native **iOS** UAs carry real iOS version but never the hardware model.
12
12
  - **Web Chrome UAs are husks since 2023**: frozen `Windows NT 10.0`, `Intel Mac OS X 10_15_7`, `Linux; Android 10; K`, minor versions `0.0.0`. Real data moved to UA Client Hints — which **only Chromium ships; Safari and Firefox still refuse as of June 2026**.
13
13
  - **iPadOS masquerades as macOS by default** since iPadOS 13 — server-side, an iPad on Safari is byte-identical to a Mac. No fix without JS.
14
- - **Our four local apps customize the UA prefix today but none embeds app version or (iOS) device model** — the gem should ship a recommended prefix convention; CarHey's native HTTP client already proves the pattern (`"CarHey Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)"`).
14
+ - **Our four local apps customize the UA prefix today but none embeds app version or (iOS) device model** — the gem should ship a recommended prefix convention; HostApp's native HTTP client already proves the pattern (`"HostApp Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)"`).
15
15
  - **`request.remote_ip` behind Cloudflare returns a Cloudflare edge IP** unless CF ranges are added to `trusted_proxies` (which *replaces* the private-range defaults) — document `cloudflare-rails` or an `ip_resolver` hook. Portable IP column: `string limit: 45`; `inet` is Postgres-only.
16
16
 
17
17
  ## A. Ruby UA parsers: `browser` vs `device_detector`
@@ -56,7 +56,7 @@ Researched 2026-06-11. Code citations are from read-only clones under `/tmp/sess
56
56
  1. **Always persist the raw UA in a `text` column** (no 255 limit) plus the relevant raw Client-Hint headers when present. Parsing is a *projection* that can be re-run as parsers/conventions improve. Validated by uniform prior art.
57
57
  2. **Hard dependency on `browser`** as the default web parser: MIT, zero-dep, tiny, Rails-aware, good enough for "Chrome 137 on macOS" + bot flagging. A drop-in gem needs device intel working with zero setup; an adapter-only design would gut the first-run experience.
58
58
  3. **Optional `device_detector` adapter** (auto-upgrade if the host app bundles it): better device names on legacy/Android UAs, native Client-Hints handling, much bigger bot DB. Don't hard-depend: LGPL, 1.5 MB data, 2-years-stale releases.
59
- 4. **Built-in native-app UA parser that runs first** (before any web parser): recognizes `Hotwire Native iOS|Android`, `Turbo Native`, the recommended prefix convention below, and CarHey's existing shapes. This is the gem's actual moat; no third-party parser does it.
59
+ 4. **Built-in native-app UA parser that runs first** (before any web parser): recognizes `Hotwire Native iOS|Android`, `Turbo Native`, the recommended prefix convention below, and HostApp's existing shapes. This is the gem's actual moat; no third-party parser does it.
60
60
  5. Expose `config.ua_parser = :browser | :device_detector | ->(ua, headers) { DeviceInfo.new(...) }` for escape hatches, and stamp rows with parser identity/version if cheap (optional).
61
61
 
62
62
  ## B. Hotwire Native user agents
@@ -125,17 +125,17 @@ Substring contract: `"Turbo Native"` or `"Hotwire Native"` anywhere in the UA. T
125
125
 
126
126
  | App | Prefix set | Where | Effective UA shape |
127
127
  |---|---|---|---|
128
- | carhey-ios | `"CarHey iOS; RailsFast Native iOS;"` (`{App}` from `CFBundleDisplayName`) | `RailsFast/Core/AppConfiguration.swift:10-12`, applied `RailsFast/App/AppDelegate.swift:103` | `Mozilla/5.0 (iPhone; CPU iPhone OS x_y like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CarHey iOS; RailsFast Native iOS; Hotwire Native iOS; Turbo Native iOS; bridge-components: […]` |
128
+ | hostapp-ios | `"HostApp iOS; RailsFast Native iOS;"` (`{App}` from `CFBundleDisplayName`) | `RailsFast/Core/AppConfiguration.swift:10-12`, applied `RailsFast/App/AppDelegate.swift:103` | `Mozilla/5.0 (iPhone; CPU iPhone OS x_y like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) HostApp iOS; RailsFast Native iOS; Hotwire Native iOS; Turbo Native iOS; bridge-components: […]` |
129
129
  | railsfast-ios | `"RailsFast iOS; RailsFast Native iOS;"` | `RailsFast/Core/AppConfiguration.swift:10-12`, `RailsFast/App/AppDelegate.swift:93-99` | same shape, `RailsFast` tokens |
130
- | carhey-android | `"CarHey Android;"` | `app/src/main/java/com/carhey/android/CarHeyApplication.kt:169` | `CarHey Android; Hotwire Native Android; Turbo Native Android; bridge-components: […]; Mozilla/5.0 (Linux; Android NN; <Model> Build/…; wv) … Chrome/NNN.0.0.0 Mobile Safari/537.36` |
130
+ | hostapp-android | `"HostApp Android;"` | `app/src/main/java/com/hostapp/android/HostAppApplication.kt:169` | `HostApp Android; Hotwire Native Android; Turbo Native Android; bridge-components: […]; Mozilla/5.0 (Linux; Android NN; <Model> Build/…; wv) … Chrome/NNN.0.0.0 Mobile Safari/537.36` |
131
131
  | railsfast-android | `"${BuildConfig.APPLICATION_NAME} Android; RailsFast Native Android;"` | `app/src/main/java/com/railsfast/android/RailsFastApplication.kt:45-46` | same shape with two leading brand segments |
132
132
 
133
133
  So today: **no webview UA carries the app version anywhere, and iOS UAs carry no device model.** Android model/OS arrive free via the WebView default UA.
134
134
 
135
- However, CarHey's *native* (URLSession/OkHttp) calls already use a richer convention the gem should accept as prior art:
135
+ However, HostApp's *native* (URLSession/OkHttp) calls already use a richer convention the gem should accept as prior art:
136
136
 
137
- - iOS: `"\(applicationName) iOS \(version) (build \(build); iOS \(osVersion); \(resolvedModel))"` → e.g. `CarHey iOS 1.0.5 (build 6; iOS 19.5; iPhone15,2)` (`carhey-ios/RailsFast/Core/NativeHttpClient.swift:61-71`), plus headers `X-Client-Platform/-Version/-Build/-OS` (`NativeHttpClient.swift:13-17`).
138
- - Android: `"CarHey Android $versionName (build $versionCode; Android $osRelease; sdk $sdkInt; $device)"` → e.g. `CarHey Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)` (`carhey-android/app/src/main/java/com/carhey/android/ClientHeaders.kt:66-76`; header names `:25-29`).
137
+ - iOS: `"\(applicationName) iOS \(version) (build \(build); iOS \(osVersion); \(resolvedModel))"` → e.g. `HostApp iOS 1.0.5 (build 6; iOS 19.5; iPhone15,2)` (`hostapp-ios/RailsFast/Core/NativeHttpClient.swift:61-71`), plus headers `X-Client-Platform/-Version/-Build/-OS` (`NativeHttpClient.swift:13-17`).
138
+ - Android: `"HostApp Android $versionName (build $versionCode; Android $osRelease; sdk $sdkInt; $device)"` → e.g. `HostApp Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)` (`hostapp-android/app/src/main/java/com/hostapp/android/ClientHeaders.kt:66-76`; header names `:25-29`).
139
139
 
140
140
  ### Recommended UA convention for the gem's README
141
141
 
@@ -143,11 +143,11 @@ Use an RFC 9110-style product token as the `applicationUserAgentPrefix`, ending
143
143
 
144
144
  ```text
145
145
  <AppName>/<version> (<model>; <os> <os_version>; build <build>);
146
- e.g. CarHey/2.4.1 (iPhone15,2; iOS 19.5; build 241);
147
- e.g. CarHey/2.4.1 (Pixel 8; Android 16; build 241);
146
+ e.g. HostApp/2.4.1 (iPhone15,2; iOS 19.5; build 241);
147
+ e.g. HostApp/2.4.1 (Pixel 8; Android 16; build 241);
148
148
  ```
149
149
 
150
- Parse rule (gem-side, tolerant): `%r{(?<app>[\w .-]+)/(?<version>\d[\w.]*) \((?<fields>[^)]*)\)}` with semicolon-split, order-insensitive fields; also accept CarHey's space-separated legacy `"CarHey iOS 1.0.5 (build 6; …)"`. Everything else (Hotwire markers, WebView UA) stays intact, so `hotwire_native_app?` and bridge components keep working.
150
+ Parse rule (gem-side, tolerant): `%r{(?<app>[\w .-]+)/(?<version>\d[\w.]*) \((?<fields>[^)]*)\)}` with semicolon-split, order-insensitive fields; also accept HostApp's space-separated legacy `"HostApp iOS 1.0.5 (build 6; …)"`. Everything else (Hotwire markers, WebView UA) stays intact, so `hotwire_native_app?` and bridge components keep working.
151
151
 
152
152
  README client snippets:
153
153
 
@@ -156,7 +156,7 @@ README client snippets:
156
156
  ```swift
157
157
  var u = utsname(); uname(&u)
158
158
  let model = withUnsafeBytes(of: &u.machine) { String(decoding: $0.prefix(while: { $0 != 0 }), as: UTF8.self) }
159
- Hotwire.config.applicationUserAgentPrefix = "CarHey/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0") (\(model); iOS \(UIDevice.current.systemVersion));"
159
+ Hotwire.config.applicationUserAgentPrefix = "HostApp/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0") (\(model); iOS \(UIDevice.current.systemVersion));"
160
160
  ```
161
161
 
162
162
  (`model` is `"iPhone15,2"` on device, `"arm64"` on Simulator — fine for production traffic.)
@@ -165,7 +165,7 @@ Hotwire.config.applicationUserAgentPrefix = "CarHey/\(Bundle.main.infoDictionary
165
165
 
166
166
  ```kotlin
167
167
  Hotwire.config.applicationUserAgentPrefix =
168
- "CarHey/${BuildConfig.VERSION_NAME} " +
168
+ "HostApp/${BuildConfig.VERSION_NAME} " +
169
169
  "(${Build.MODEL}; Android ${Build.VERSION.RELEASE}; build ${BuildConfig.VERSION_CODE});"
170
170
  ```
171
171
 
@@ -181,7 +181,7 @@ module Sessions
181
181
  say " schedule: every day at 4am"
182
182
 
183
183
  say "\nEvery login now lands on the devices page and in the trail:"
184
- say " current_user.sessions.active # live devices, revocable"
184
+ say " current_user.sessions.live # live devices, revocable"
185
185
  say " current_user.session_history # the trail — logins, failures, revocations"
186
186
  say "\nEvery session, every device, every login — tracked. 🔐✨\n", :green
187
187
  end
@@ -28,7 +28,7 @@ module Sessions
28
28
 
29
29
  This generator produces Madmin resources for the session registry and
30
30
  the login trail. For other admin frameworks, build on the same
31
- primitives it uses: Session.active / session.revoke! /
31
+ primitives it uses: Session.live / session.revoke! /
32
32
  Sessions::Event scopes (failed_logins, last_24_hours, …).
33
33
  MSG
34
34
  end
@@ -71,12 +71,12 @@ module Sessions
71
71
 
72
72
  say "\n 3. (Optional) For a per-user panel (devices + trail on the user's"
73
73
  say " show page), add a member action to your users controller that"
74
- say " loads `user.sessions.by_recency` and `user.session_history.recent`"
74
+ say " loads `user.sessions.live.by_recency` and `user.session_history.recent`"
75
75
  say " — the README's Admin section has the full recipe."
76
76
 
77
- say "\nRevoking from the index destroys the row: that device is signed out"
78
- say "on its very next request, and the revocation lands in the trail with"
79
- say "admin attribution. 🔐\n", :green
77
+ say "\nRevoking from the index ends the row in place: that device is signed out"
78
+ say "on its very next matching request, and the revocation lands in the trail"
79
+ say "with admin attribution. 🔐\n", :green
80
80
  end
81
81
 
82
82
  private
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # v0.2 upgrade: session rows are lifecycle records now. A live device is
4
+ # `ended_at: nil`; explicit ending state lives on the row instead of being
5
+ # inferred from a missing row plus a sessions_events tombstone.
6
+ class AddSessionsLifecycleTo<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
7
+ def change
8
+ change_table :<%= table_name %>, bulk: true do |t|
9
+ t.datetime :ended_at unless column_exists?(:<%= table_name %>, :ended_at)
10
+ t.string :ended_reason unless column_exists?(:<%= table_name %>, :ended_reason)
11
+ t.string :ended_by_type unless column_exists?(:<%= table_name %>, :ended_by_type)
12
+ t.send(reference_column_type, :ended_by_id) unless column_exists?(:<%= table_name %>, :ended_by_id)
13
+ t.send(json_column_type, :ended_metadata) unless column_exists?(:<%= table_name %>, :ended_metadata)
14
+ end
15
+
16
+ add_index :<%= table_name %>, :ended_at unless index_exists?(:<%= table_name %>, :ended_at)
17
+ add_index :<%= table_name %>, :ended_reason unless index_exists?(:<%= table_name %>, :ended_reason)
18
+ add_index :<%= table_name %>, %i[ended_by_type ended_by_id] unless index_exists?(:<%= table_name %>, %i[ended_by_type ended_by_id])
19
+ end
20
+
21
+ private
22
+
23
+ def reference_column_type
24
+ config = Rails.configuration.generators
25
+ case config.options[config.orm][:primary_key_type]
26
+ when :uuid then :uuid
27
+ when :string then :string
28
+ when :integer, :serial then :integer
29
+ else :bigint
30
+ end
31
+ end
32
+
33
+ def json_column_type
34
+ return :jsonb if connection.adapter_name.match?(/postg/i)
35
+
36
+ :json
37
+ end
38
+ end
@@ -64,6 +64,14 @@ class AddSessionsColumnsTo<%= table_name.camelize %> < ActiveRecord::Migration<%
64
64
  # Session.sweep recommendation always needed.
65
65
  t.datetime :last_seen_at
66
66
  t.string :last_seen_ip, limit: 45 # refreshed with the touch (roaming devices)
67
+
68
+ # Lifecycle state. `ended_at: nil` is the live-device set; ended rows
69
+ # remain as auditable history. Events are the trail, not the source of
70
+ # truth for whether a session should still be accepted.
71
+ t.datetime :ended_at
72
+ t.string :ended_reason
73
+ t.references :ended_by, polymorphic: true, type: foreign_key_type
74
+ t.send(json_column_type, :ended_metadata)
67
75
  end
68
76
 
69
77
  add_index :<%= table_name %>, :device_id
@@ -73,10 +81,28 @@ class AddSessionsColumnsTo<%= table_name.camelize %> < ActiveRecord::Migration<%
73
81
  add_index :<%= table_name %>, :auth_provider
74
82
  add_index :<%= table_name %>, :country_code
75
83
  add_index :<%= table_name %>, :last_seen_at
84
+ add_index :<%= table_name %>, :ended_at
85
+ add_index :<%= table_name %>, :ended_reason
86
+ add_index :<%= table_name %>, %i[ended_by_type ended_by_id]
76
87
  end
77
88
 
78
89
  private
79
90
 
91
+ def foreign_key_type
92
+ config = Rails.configuration.generators
93
+ reference_column_type(config.options[config.orm][:primary_key_type])
94
+ end
95
+
96
+ # Reference columns hold VALUES of the PK type, so serial pseudo-types map to
97
+ # their plain integer equivalents.
98
+ def reference_column_type(setting)
99
+ case setting
100
+ when nil, :bigserial then :bigint
101
+ when :serial then :integer
102
+ else setting
103
+ end
104
+ end
105
+
80
106
  # :jsonb on PostgreSQL, :json elsewhere — same adaptive pattern as the
81
107
  # rest of the gem ecosystem (chats, api_keys, …). match? (not equality):
82
108
  # PostGIS apps report adapter_name "PostGIS" and are PostgreSQL too.
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # The Rails-8-shaped sessions table for a Devise app: one row = one
4
- # signed-in device, destroyed on logout/revocation (rows = active sessions;
5
- # history lives in sessions_events). Deliberately the SAME base shape
3
+ # The Rails-8-shaped sessions table for a Devise app: one row = one signed-in
4
+ # device lifecycle (`ended_at: nil` means live; history lives in
5
+ # sessions_events). Deliberately the SAME base shape
6
6
  # `rails generate authentication` creates — so if you ever migrate from
7
7
  # Devise to Rails auth, your sessions table is already waiting.
8
8
  class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
@@ -23,8 +23,8 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_ve
23
23
 
24
24
  # Devise/Warden mode: SHA-256 of a random token whose raw value lives
25
25
  # ONLY in the user's own session (OWASP: never persist raw session
26
- # identifiers). The Warden adapter validates it on every request
27
- # destroy the row and that device is signed out on its next request.
26
+ # identifiers). The Warden adapter validates it on every request; only
27
+ # a matching token plus explicit lifecycle end may sign that device out.
28
28
  t.string :token_digest
29
29
 
30
30
  # The Warden scope ("user") — multi-scope Devise apps.
@@ -72,6 +72,15 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_ve
72
72
  t.datetime :last_seen_at
73
73
  t.string :last_seen_ip, limit: 45
74
74
 
75
+ # Lifecycle state. v0.2 keeps rows as the source of truth instead of
76
+ # deleting them and asking events to act as tombstones. `ended_at: nil`
77
+ # is the live-device set; explicit ended_reason values are the only
78
+ # thing a Devise/Warden request is allowed to enforce.
79
+ t.datetime :ended_at
80
+ t.string :ended_reason
81
+ t.references :ended_by, polymorphic: true, type: foreign_key_type
82
+ t.send(json_column_type, :ended_metadata)
83
+
75
84
  t.timestamps
76
85
  end
77
86
 
@@ -82,6 +91,9 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_ve
82
91
  add_index :<%= table_name %>, :auth_provider
83
92
  add_index :<%= table_name %>, :country_code
84
93
  add_index :<%= table_name %>, :last_seen_at
94
+ add_index :<%= table_name %>, :ended_at
95
+ add_index :<%= table_name %>, :ended_reason
96
+ add_index :<%= table_name %>, %i[ended_by_type ended_by_id]
85
97
  end
86
98
 
87
99
  private
@@ -23,10 +23,10 @@ class CreateSessionsEvents < ActiveRecord::Migration<%= migration_version %>
23
23
  t.references :authenticatable, polymorphic: true, type: foreign_key_type, index: false
24
24
  t.string :scope
25
25
 
26
- # The trail ↔ registry linkage. A plain column, NO foreign key: the
27
- # registry row it points at gets destroyed on revoke; history must
28
- # survive. A suspicious login here is one lookup away from revoking
29
- # the live session it created.
26
+ # The trail ↔ registry linkage. A plain column, NO foreign key:
27
+ # lifecycle rows are normally ended in place, but account-erasure and
28
+ # legacy host deletes may still remove them. A suspicious login here
29
+ # is one lookup away from revoking the live session it created.
30
30
  t.send(session_id_column_type, :session_id)
31
31
 
32
32
  t.string :identity # email-as-typed (normalized), even for unknown accounts
@@ -122,8 +122,8 @@ Sessions.configure do |config|
122
122
  # "Was this you?" email here. Not fired on a user's very first login.
123
123
  #
124
124
  # PASS THE EVENT to your mailer, not the session: the event is a
125
- # persisted, GlobalID-able record that survives revocation (the session
126
- # row may be destroyed before an async job runs) and already carries
125
+ # persisted, GlobalID-able record that survives revocation and account
126
+ # erasure cleanup, and already carries
127
127
  # everything the email needs — event.user, event.device_name,
128
128
  # event.location, event.country_flag, event.occurred_at.
129
129
  #
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Live device registry (sessions gem): one row = one signed-in device,
4
- # destroyed on logout/revocation so this index IS the set of sessions that
5
- # can act on your app right now. The append-only history lives in
6
- # Sessions::Event (see Sessions::EventResource).
3
+ # Device lifecycle registry (sessions gem): one row = one signed-in device.
4
+ # `ended_at: nil` is the live set that can act on your app right now; ended
5
+ # rows stay around as durable state/audit context until retention or account
6
+ # erasure removes them. The append-only history lives in Sessions::Event.
7
7
  class SessionResource < Madmin::Resource
8
8
  model <%= session_class %>
9
9
 
@@ -30,6 +30,8 @@ class SessionResource < Madmin::Resource
30
30
  attribute :app_build, index: false, form: false
31
31
  attribute :user_agent, index: false, form: false
32
32
  attribute :last_seen_at, index: true, form: false, label: "Last seen"
33
+ attribute :ended_at, index: true, form: false, label: "Ended"
34
+ attribute :ended_reason, index: true, form: false, label: "Ended because"
33
35
  attribute :created_at, index: true, form: false, label: "Signed in"
34
36
  attribute :updated_at, show: false, form: false
35
37
 
@@ -39,8 +41,14 @@ class SessionResource < Madmin::Resource
39
41
  attribute :adoption_key, show: false, form: false
40
42
  attribute :auth_detail, show: false, form: false
41
43
  attribute :client_hints, show: false, form: false
44
+ attribute :ended_by_type, show: false, form: false
45
+ attribute :ended_by_id, show: false, form: false
46
+ attribute :ended_metadata, show: false, form: false
42
47
 
43
- # Gem scopes: active = last activity within 30 days.
48
+ # Gem scopes: live/ended are lifecycle state; active/inactive are UI
49
+ # grouping within the live set (last activity within 30 days).
50
+ scope :live
51
+ scope :ended
44
52
  scope :active
45
53
  scope :inactive
46
54
 
@@ -53,9 +61,9 @@ class SessionResource < Madmin::Resource
53
61
  def self.default_sort_column = "created_at"
54
62
  def self.default_sort_direction = "desc"
55
63
 
56
- # Remote logout: destroys the row (the device is signed out on its next
57
- # request), writes the `revoked` trail event attributed to the admin, and
58
- # rotates the user's remember-me credentials in Devise mode.
64
+ # Remote logout: ends the row in place, writes the `revoked` trail event
65
+ # attributed to the admin, and rotates the user's remember-me credentials
66
+ # in Devise mode. The device is signed out on its next matching request.
59
67
  member_action do
60
68
  button_to "Revoke session",
61
69
  main_app.revoke_madmin_session_path(@record),
@@ -2,10 +2,10 @@
2
2
 
3
3
  module Madmin
4
4
  class SessionsController < Madmin::ResourceController
5
- # Admin remote logout. `revoke!` destroys the row (the device is kicked
6
- # on its next request — the cookie session and, in Devise mode, any
7
- # remember-me revival), and writes the immutable `revoked` trail event
8
- # with `by:` attribution.
5
+ # Admin remote logout. `revoke!` ends the row in place (the device is
6
+ # kicked on its next matching request — the cookie session and, in Devise
7
+ # mode, any remember-me revival), and writes the immutable `revoked`
8
+ # trail event with `by:` attribution.
9
9
  def revoke
10
10
  session_row = <%= session_class %>.find(params[:id])
11
11
  device = session_row.device_name
@@ -27,6 +27,8 @@ module Sessions
27
27
  File.join(db_migrate_path, "add_sessions_adoption_key_to_#{table_name}.rb")
28
28
  migration_template "add_app_build_to_sessions_events.rb.erb",
29
29
  File.join(db_migrate_path, "add_sessions_app_build_to_sessions_events.rb")
30
+ migration_template "add_lifecycle_to_sessions.rb.erb",
31
+ File.join(db_migrate_path, "add_sessions_lifecycle_to_#{table_name}.rb")
30
32
  end
31
33
 
32
34
  def display_post_upgrade_message
@@ -34,7 +36,8 @@ module Sessions
34
36
  say "\nTo complete the upgrade:"
35
37
  say " 1. Review the generated migration."
36
38
  say " 2. Run 'rails db:migrate'."
37
- say " ⚠️ Deploy the migrations before relying on adoption hardening or app-build event columns.", :yellow
39
+ say " ⚠️ Deploy the migrations before relying on lifecycle-row revocation, " \
40
+ "adoption hardening, or app-build event columns.", :yellow
38
41
  end
39
42
 
40
43
  private