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 +4 -4
- data/CHANGELOG.md +13 -2
- data/README.md +9 -9
- data/app/controllers/sessions/application_controller.rb +2 -2
- data/app/views/sessions/_devices.html.erb +1 -1
- data/docs/PRD.md +52 -52
- data/docs/research/{01-carhey.md → 01-host-app.md} +23 -23
- data/docs/research/02-ecosystem.md +1 -1
- data/docs/research/07-device-detection.md +13 -13
- data/lib/generators/sessions/install_generator.rb +1 -1
- data/lib/generators/sessions/madmin_generator.rb +5 -5
- data/lib/generators/sessions/templates/add_lifecycle_to_sessions.rb.erb +38 -0
- data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +26 -0
- data/lib/generators/sessions/templates/create_sessions.rb.erb +17 -5
- data/lib/generators/sessions/templates/create_sessions_events.rb.erb +4 -4
- data/lib/generators/sessions/templates/initializer.rb +2 -2
- data/lib/generators/sessions/templates/madmin/session_resource.rb +16 -8
- data/lib/generators/sessions/templates/madmin/sessions_controller.rb +4 -4
- data/lib/generators/sessions/upgrade_generator.rb +4 -1
- data/lib/sessions/adapters/omakase.rb +62 -18
- data/lib/sessions/adapters/warden.rb +163 -48
- data/lib/sessions/configuration.rb +4 -3
- data/lib/sessions/current.rb +4 -4
- data/lib/sessions/end_reason.rb +67 -0
- data/lib/sessions/models/concerns/has_sessions.rb +3 -3
- data/lib/sessions/models/concerns/model.rb +124 -59
- data/lib/sessions/models/event.rb +24 -17
- data/lib/sessions/version.rb +1 -1
- data/lib/sessions.rb +13 -12
- metadata +4 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# HostApp audit: auth, sessions & device detection
|
|
2
2
|
|
|
3
|
-
Read-only audit of `/
|
|
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
|
-
-
|
|
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 `"
|
|
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 `
|
|
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` = `
|
|
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** — `
|
|
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** — `
|
|
146
|
+
**Android WebView UA** — `hostapp-android/.../HostAppApplication.kt:169`: `Hotwire.config.applicationUserAgentPrefix = "HostApp Android;"`.
|
|
147
147
|
|
|
148
|
-
**Android OkHttp UA + headers** — `
|
|
148
|
+
**Android OkHttp UA + headers** — `hostapp-android/.../ClientHeaders.kt:25-28,66-77`:
|
|
149
149
|
|
|
150
150
|
```kotlin
|
|
151
|
-
return "
|
|
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 (`
|
|
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 (`
|
|
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 `../
|
|
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
|
-
`/
|
|
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
|
|
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
|
|
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
|
|
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
|
|
240
|
-
2. **Hook Warden, not Devise controllers**: `after_set_user`/`after_authentication`/`before_logout` covers stock +
|
|
241
|
-
3. **Record failures too**: subscribe to `warden.authenticate` failure / Devise lockable paths;
|
|
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 `
|
|
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 (
|
|
246
|
-
8. **UI**: ship a controller + plain-Tailwind views/partials apps can render inside their own settings shell (
|
|
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 `/
|
|
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 `/
|
|
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;
|
|
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
|
|
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
|
-
|
|
|
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
|
-
|
|
|
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,
|
|
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. `
|
|
138
|
-
- Android: `"
|
|
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.
|
|
147
|
-
e.g.
|
|
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
|
|
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 = "
|
|
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
|
-
"
|
|
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.
|
|
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.
|
|
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
|
|
78
|
-
say "on its very next request, and the revocation lands in the trail
|
|
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
|
-
#
|
|
5
|
-
#
|
|
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
|
-
#
|
|
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:
|
|
27
|
-
#
|
|
28
|
-
#
|
|
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
|
|
126
|
-
#
|
|
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
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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:
|
|
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:
|
|
57
|
-
#
|
|
58
|
-
#
|
|
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!`
|
|
6
|
-
# on its next request — the cookie session and, in Devise
|
|
7
|
-
# remember-me revival), and writes the immutable `revoked`
|
|
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
|
|
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
|