sessions 0.1.3 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -2
- data/README.md +26 -16
- data/app/controllers/sessions/application_controller.rb +4 -3
- data/app/helpers/sessions/engine_helper.rb +23 -0
- data/app/views/sessions/_devices.html.erb +2 -2
- data/app/views/sessions/_history.html.erb +2 -2
- 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 +23 -7
- data/lib/generators/sessions/templates/add_lifecycle_to_sessions.rb.erb +37 -0
- data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +28 -0
- data/lib/generators/sessions/templates/create_sessions.rb.erb +20 -5
- data/lib/generators/sessions/templates/create_sessions_events.rb.erb +4 -4
- data/lib/generators/sessions/templates/initializer.rb +9 -8
- data/lib/generators/sessions/templates/madmin/session_resource.rb +24 -13
- data/lib/generators/sessions/templates/madmin/sessions_controller.rb +11 -7
- data/lib/generators/sessions/templates/madmin/user_panel/madmin_user_panel.rb +43 -0
- data/lib/generators/sessions/templates/madmin/user_panel/sessions.html.erb +133 -0
- data/lib/generators/sessions/upgrade_generator.rb +4 -1
- data/lib/sessions/adapters/omakase.rb +62 -18
- data/lib/sessions/adapters/warden.rb +172 -48
- data/lib/sessions/configuration.rb +14 -4
- 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 +56 -21
- data/lib/sessions/version.rb +1 -1
- data/lib/sessions.rb +68 -14
- metadata +9 -5
data/docs/PRD.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# `sessions` — Product Requirements Document
|
|
2
2
|
|
|
3
3
|
> **Status**: Draft v1 for review (Javi). **Date**: 2026-06-11.
|
|
4
|
-
> **Research base**: every claim and shape in this document is backed by the nine research memos in [`docs/research/`](research/) — read-only audits of
|
|
4
|
+
> **Research base**: every claim and shape in this document is backed by the nine research memos in [`docs/research/`](research/) — read-only audits of HostApp, LicenseSeat, the rameerez gem ecosystem, rails/rails (v8.1.3 + main), Devise 5.0.4 + Warden + devise-security, OmniAuth + google_sign_in + webauthn-ruby, authtrail + authie + authentication-zero + Rodauth, the `browser`/`device_detector` parsers, hotwire-native-ios/android, plus live web research (all sources fetched 2026-06-10/11, exact URLs inline and in the memos). Citations below use the form `(→ research/NN §X)` plus direct URLs where load-bearing.
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
|
|
12
12
|
`sessions` gives every Rails 8+ app, in one `bundle add` + one generator + one `has_sessions` macro:
|
|
13
13
|
|
|
14
|
-
1. **A live device registry** — every active session, enriched with parsed device intelligence ("Chrome 137 on macOS", "
|
|
15
|
-
2. **Per-session remote revocation** — "log out of that device", "sign out everywhere else" — that actually works on Rails 8 omakase auth (
|
|
14
|
+
1. **A live device registry** — every active session, enriched with parsed device intelligence ("Chrome 137 on macOS", "HostApp 2.4.1 on iPhone 15 Pro (iOS 19.5)"), IP geolocation (via `trackdown`, soft dependency), auth method ("via Google", "via passkey"), and throttled last-seen tracking.
|
|
15
|
+
2. **Per-session remote revocation** — "log out of that device", "sign out everywhere else" — that actually works on Rails 8 omakase auth (end the lifecycle row), on Devise (token-per-row generalization of devise-security's proven `session_limitable` mechanism), and on remember-me cookies.
|
|
16
16
|
3. **An append-only login-activity trail** — every successful *and failed* login attempt, logout, and revocation, with attempted identity, device, geo, and failure reason — linked to the live session it created (the linkage no prior art has, → research/06 §Improve).
|
|
17
17
|
4. **A drop-in "Your devices" page** — engine-mounted, Tailwind-friendly, i18n'd (en + es), matching the GitHub/Google/Stripe UX contract — plus admin-grade scopes for fraud triage and a madmin recipe.
|
|
18
18
|
5. **First-class citizenship for every 2026 login method**: Rails 8 native auth, Devise (4.x & 5.x), OmniAuth/OAuth (with failed-OAuth capture), Google One Tap (FedCM-era), Sign in with Apple, passkeys, magic links — via automatic classification plus a one-line `Sessions.tag` API for the flows that can't self-identify (→ research/05).
|
|
@@ -29,7 +29,7 @@ The gem **decorates the session of record; it never becomes it** (the #1 lesson
|
|
|
29
29
|
### 1.1 Rails 8 omakase auth: a substrate, deliberately unfinished
|
|
30
30
|
|
|
31
31
|
- Rails 8.0 (2024-11-07) shipped `bin/rails generate authentication`: a `Session < ApplicationRecord` model (2 lines), a `sessions` table (`user:references ip_address:string user_agent:string`), an `Authentication` concern, and a signed **permanent** cookie holding the Session row id — `cookies.signed.permanent[:session_id]`, httponly, SameSite=Lax, 20-year expiry ([authentication.rb.tt](https://github.com/rails/rails/blob/main/railties/lib/rails/generators/rails/authentication/templates/app/controllers/concerns/authentication.rb.tt), → research/03 §1).
|
|
32
|
-
- **Every request resolves the row** (`Session.find_by(id: cookies.signed[:session_id])`), so
|
|
32
|
+
- **Every request resolves the row** (`Session.find_by(id: cookies.signed[:session_id])`), so a lifecycle row can be made server-revocable without changing Rails' cookie shape. Rails built the substrate and shipped no product on top of it: the only generated route is singular `resource :session` — no index, no devices page; ip/user_agent are written once and never read again; **no code path ever UPDATEs a session row**, so `updated_at == created_at` forever (grep-verified, → research/03 §2).
|
|
33
33
|
- The punchline: Rails' own security guide recommends an `updated_at`-based `Session.sweep` for expiry ([security guide §sessions](https://guides.rubyonrails.org/security.html)) **that the generated code can never satisfy because nothing touches `updated_at`** (→ research/03 §5). The framework documents the hole this gem fills.
|
|
34
34
|
- The gaps are **policy, not backlog**. DHH on the PR: *"This is not intended to be an all-singing, all-dancing answer to every possible authentication concern… do not expect magic links or passkeys or 2FA. That's not going to happen with this generator"* ([rails/rails#52328](https://github.com/rails/rails/pull/52328)). Rails 8.1 (2025-10-22) added zero auth features (only: password reset now `sessions.destroy_all`s, and passwords#create got `rate_limit`); 8.2 edge adds only Argon2 + Sec-Fetch-Site CSRF. The `Authentication` concern is **byte-identical from 8.0.5 through 8.1.3 to main** — an exceptionally stable instrumentation target (→ research/03 §4, research/08 §Timeline).
|
|
35
35
|
|
|
@@ -50,7 +50,7 @@ The gem **decorates the session of record; it never becomes it** (the #1 lesson
|
|
|
50
50
|
### 1.4 Hotwire Native
|
|
51
51
|
|
|
52
52
|
- Hotwire Native UA construction is deterministic and documented in SDK source: iOS *appends* `"Hotwire Native iOS; Turbo Native iOS; bridge-components: […]"`; Android *prepends* its segment to the stock Chromium WebView UA. `turbo_native_app?` matches `/(Turbo|Hotwire) Native/` (→ research/07 §B).
|
|
53
|
-
- **Android WebView is exempt from Chrome's UA reduction** — native Android UAs still carry real device model + OS version for free. iOS carries real OS version but never the hardware model; app version appears nowhere by default.
|
|
53
|
+
- **Android WebView is exempt from Chrome's UA reduction** — native Android UAs still carry real device model + OS version for free. iOS carries real OS version but never the hardware model; app version appears nowhere by default. HostApp's native HTTP clients already use a richer convention (`"HostApp Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)"` + validated `X-Client-*` headers) that the gem should formalize (→ research/07 §B, research/01 §3).
|
|
54
54
|
|
|
55
55
|
### 1.5 The gap, in one table
|
|
56
56
|
|
|
@@ -123,7 +123,7 @@ GitHub (Settings → Sessions + security log), Google ("Your devices" + the cano
|
|
|
123
123
|
5. **DX is candy.** `has_sessions`, `session.device_name`, `session.revoke!`, `user.revoke_other_sessions!` — bang verbs, `?` predicates, kwargs, chainable scopes, reads like English (→ research/02 §4).
|
|
124
124
|
6. **Soft dependencies everywhere.** `trackdown` (geo), `device_detector` (parser upgrade), Devise/Warden, OmniAuth — all optional, detected with `defined?()`, rescued everywhere. Only hard runtime deps: Rails frameworks + `browser` (MIT, zero-dep, 15 KB) (→ research/07 §A, research/02 §2).
|
|
125
125
|
7. **Privacy as a feature.** Bounded retention + purge job, optional IP truncation, optional AR encryption, no client-side fingerprinting ever (WP224 consent trap), lat/lng precision reduction (→ research/09 §Privacy).
|
|
126
|
-
8. **Incubated in production.** Built against
|
|
126
|
+
8. **Incubated in production.** Built against HostApp (Devise 5 + Hotwire Native + Cloudflare + Spanish UI) and LicenseSeat (Devise 4.9 + MaxMind trackdown + api_keys), extracted only when the shapes survive contact with both (→ research/01).
|
|
127
127
|
|
|
128
128
|
---
|
|
129
129
|
|
|
@@ -133,9 +133,9 @@ GitHub (Settings → Sessions + security log), Google ("Your devices" + the cano
|
|
|
133
133
|
|---|---|---|
|
|
134
134
|
| **End user** | "Is my account safe? What's logged in? Kick that device." | `/settings/sessions` devices page: friendly device names, location, last seen, current-session badge, per-row Log out, "Sign out everywhere else", and a "Was this you?" email on new-device logins. |
|
|
135
135
|
| **Developer** | "Give me GitHub-style session security without building it for the third time." | `bundle add sessions` → `rails g sessions:install` → `has_sessions` → done. Works identically on Rails 8 auth and Devise; OAuth/native just work; one initializer of lambdas to integrate mailers/audit (goodmail, noticed, AuditLog…). |
|
|
136
|
-
| **Admin / T&S** | "Who tried to brute-force us last night? Is this account being taken over? Kill every session this user has." | `Sessions::Event` scopes (failed logins, by IP, by identity, velocity), per-user session admin + `revoke_all!`, new-device/new-country signals, madmin resource recipe, optional tee into the host's AuditLog (
|
|
136
|
+
| **Admin / T&S** | "Who tried to brute-force us last night? Is this account being taken over? Kill every session this user has." | `Sessions::Event` scopes (failed logins, by IP, by identity, velocity), per-user session admin + `revoke_all!`, new-device/new-country signals, madmin resource recipe, optional tee into the host's AuditLog (HostApp pattern). |
|
|
137
137
|
|
|
138
|
-
Three installed bases to serve from day one: (a) Rails 8 generator apps, (b) Devise apps (
|
|
138
|
+
Three installed bases to serve from day one: (a) Rails 8 generator apps, (b) Devise apps (HostApp, LicenseSeat, RailsFast), (c) any of the above with OAuth/One Tap/passkeys/magic links layered on (→ research/08 §Implications).
|
|
139
139
|
|
|
140
140
|
---
|
|
141
141
|
|
|
@@ -180,14 +180,14 @@ Three installed bases to serve from day one: (a) Rails 8 generator apps, (b) Dev
|
|
|
180
180
|
┌─────────────────────────────┐ ┌──────────────────────────────────┐
|
|
181
181
|
│ sessions (registry) │ │ sessions_events (trail) │
|
|
182
182
|
│ host-owned, Rails-8-shaped │◄────────│ gem-owned, append-only │
|
|
183
|
-
│
|
|
184
|
-
│ one signed-in device
|
|
185
|
-
│
|
|
183
|
+
│ LIFECYCLE state: one row = │ session │ HISTORY: logins (ok+failed), │
|
|
184
|
+
│ one signed-in device; │ _id │ logouts, revocations, expiry. │
|
|
185
|
+
│ ended_at NULL means live. │ │ Survives cleanup/erasure. │
|
|
186
186
|
└─────────────────────────────┘ └──────────────────────────────────┘
|
|
187
187
|
```
|
|
188
188
|
|
|
189
|
-
- **One mental model**: *rows =
|
|
190
|
-
- The trail's `session_id` column (plain id, deliberately **no FK constraint** —
|
|
189
|
+
- **One mental model**: *rows = session lifecycle state; events = audit history.* `revoke!` ends the registry row in place (`ended_at`/`ended_reason`) and writes a `revoked` event in the same transaction. Devise/Warden never infers security intent from a missing row; it kicks only when a token-backed row has an explicit kicking lifecycle reason.
|
|
190
|
+
- The trail's `session_id` column (plain id, deliberately **no FK constraint** — account erasure and legacy host deletes may still remove the registry row) is the linkage no prior art has: a suspicious login event is one click away from revoking the live session it created (→ research/06 §Improve).
|
|
191
191
|
|
|
192
192
|
### 6.2 Adopt-or-generate: the registry is always the Rails 8 shape
|
|
193
193
|
|
|
@@ -223,14 +223,14 @@ All gem logic lives in `Sessions::Model` (the concern) and `Sessions::Event` (ge
|
|
|
223
223
|
|
|
224
224
|
Both first-class adapters activate automatically and independently (an app can have both — e.g. Devise app that later adds omakase auth for a second scope). Activation is duck-typed and guarded:
|
|
225
225
|
|
|
226
|
-
- **Rails 8 adapter** (→ research/03 §Implications): `Rails.application.config.to_prepare` → if `defined?(::Session)` && table has `ip_address`/`user_agent` → `Session.include(Sessions::Model)`; if `ApplicationController.private_method_defined?(:start_new_session_for)` → `ApplicationController.prepend(Sessions::OmakaseControllerHooks)`. The prepend sits in front of the included `Authentication` concern in the ancestor chain, so `super`-wrapping `resume_session` (throttled touch) and `terminate_session` (logout
|
|
226
|
+
- **Rails 8 adapter** (→ research/03 §Implications): `Rails.application.config.to_prepare` → if `defined?(::Session)` && table has `ip_address`/`user_agent` → `Session.include(Sessions::Model)`; if `ApplicationController.private_method_defined?(:start_new_session_for)` → `ApplicationController.prepend(Sessions::OmakaseControllerHooks)`. The prepend sits in front of the included `Authentication` concern in the ancestor chain, so `super`-wrapping `resume_session` (throttled touch/expiry/end-state refusal) and `terminate_session` (logout lifecycle end) is clean, name-stable-since-8.0, and requires zero app edits. Login events ride **model callbacks** (`after_create_commit`: ip/UA already on the row); explicit logout/revocation uses `end!`; host-side `destroy_all` remains covered by the compatibility callback (→ research/03 §Top 6).
|
|
227
227
|
- **Devise adapter** (→ research/04 §Implications): registered from a Railtie initializer guarded by `defined?(::Warden::Manager)` (Bundler.require precedes initializers; hooks live on the Manager *class* and are read live per request — no load-order coupling, no `require "warden"`). The four hooks:
|
|
228
228
|
1. `after_set_user except: :fetch` — any fresh login (form, remember-me, OmniAuth, sign-up auto-login, post-password-reset). Guards: `warden.authenticated?(scope)` && `opts[:store] != false` (**critical**: token/HTTP-Basic auth fires this hook *every request* with `store: false` — without the guard we'd mint a session row per API call) && our skip flags. Creates the registry row, stores `[row_id, raw_token]` in `warden.session(scope)` (survives Warden's `:renew` SID rotation; auto-deleted by Warden on logout).
|
|
229
229
|
2. `after_set_user only: :fetch` — per-request resume: look up row by id, `secure_compare` token digest; missing/revoked → `warden.raw_session.clear; warden.logout(scope); throw :warden, scope:, message: :session_revoked` (the proven session_limitable sequence); else throttled touch.
|
|
230
230
|
3. `before_failure` — failed logins: persist scope/action/message/attempted_path from `env['warden.options']`; attempted identity from `request.params[scope]` **only when `request.post?` && credentials hash present** (filters out plain 401 page-hits and timeouts); never read the password key. Store Devise's failure symbol verbatim (`:invalid` under paranoid mode — don't infer account existence).
|
|
231
|
-
4. `before_logout` — mark row
|
|
231
|
+
4. `before_logout` — mark row ended + `logout` event (fires once per scope; also on forced logouts like timeout).
|
|
232
232
|
- **OmniAuth integration** (→ research/05 §1): no hook needed for successes (the callback lands in the same controller seam either adapter already covers; we classify by sniffing `env['omniauth.auth']`). Failures: compose-wrap `OmniAuth.config.on_failure` in an after-Devise initializer — record `omniauth.error.type` + provider + origin + IP/UA, then call the original endpoint.
|
|
233
|
-
- **Explicit API**: the universal seam for everything else —
|
|
233
|
+
- **Explicit API**: the universal seam for everything else — HostApp's manual native sign-in branch renders 422s without touching Warden's failure app, so it needs `Sessions.record_failed_attempt` (→ research/01 §Implications 3); One Tap/passkey/magic-link controllers call `Sessions.tag(request, method: :passkey, detail: {...})` before signing in.
|
|
234
234
|
|
|
235
235
|
### 6.4 Auth-method classification pipeline
|
|
236
236
|
|
|
@@ -250,23 +250,23 @@ Taxonomy (two indexed columns + one JSON): `auth_method` ∈ `password, oauth, g
|
|
|
250
250
|
Raw-first, three layers (→ research/07 §Implications):
|
|
251
251
|
|
|
252
252
|
1. **Persist raw**: `user_agent` as `text` (no 255 truncation — authie's footgun; Hotwire Native UAs with bridge-components exceed 255), plus interesting headers (`Sec-CH-UA*`, `X-Client-*`) in a `client_hints` JSON column. Parsing is a projection; `sessions:reparse` (v1.x) can re-run it as parsers improve.
|
|
253
|
-
2. **Native matcher first** (the moat — no third-party parser does this): `/(Turbo|Hotwire) Native (iOS|Android)/` (same contract as turbo-rails' `turbo_native_app?`) → platform; then the documented prefix convention `AppName/1.2.3 (iPhone15,2; iOS 19.5; build 241);` → app name/version/build/model/OS;
|
|
253
|
+
2. **Native matcher first** (the moat — no third-party parser does this): `/(Turbo|Hotwire) Native (iOS|Android)/` (same contract as turbo-rails' `turbo_native_app?`) → platform; then the documented prefix convention `AppName/1.2.3 (iPhone15,2; iOS 19.5; build 241);` → app name/version/build/model/OS; HostApp's legacy shapes (`"HostApp Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)"`) and validated `X-Client-Platform/Version/Build/OS` headers accepted as input too; on Android, fall back to the embedded WebView UA for model/OS (UA-reduction-exempt).
|
|
254
254
|
3. **Web parser**: `browser` gem (hard dep: MIT, zero-dep, ~15 KB, maintained, ships `ios_app?`/`android_app?` webview heuristics — also what Mastodon uses) for browser name/version, OS family, device type, bot flag. **`device_detector` auto-upgrade adapter** when the host bundles it (better Android device names, Client-Hints-native, 108 KB bot list — but LGPL, 1.5 MB data, stale since 2024-07, which is also why it's not the default; GitLab wraps it with a 1024-char truncation we mirror). `config.ua_parser` accepts `:browser`, `:device_detector`, or a lambda.
|
|
255
255
|
|
|
256
|
-
**Honest display names** (frozen-UA reality, → research/07 §C): never render frozen tokens as facts — "Chrome 137 on macOS" (no version), "Safari on iOS 19.5 · iPhone", "
|
|
256
|
+
**Honest display names** (frozen-UA reality, → research/07 §C): never render frozen tokens as facts — "Chrome 137 on macOS" (no version), "Safari on iOS 19.5 · iPhone", "HostApp 2.4.1 on Pixel 8 (Android 16)". iPads on Safari display as "Safari on macOS" (no server-side tell; documented). Optional `config.request_client_hints = true` sets `Accept-CH` to recover real platform versions + Android models on Chromium; login POSTs are rarely first-navigations, so hints are reliably present exactly when we need them.
|
|
257
257
|
|
|
258
258
|
### 6.6 Geolocation via `trackdown` (soft dependency)
|
|
259
259
|
|
|
260
260
|
The contract, lifted verbatim from footprinted's proven integration (→ research/02 §2):
|
|
261
261
|
|
|
262
262
|
- Guard every call with `defined?(Trackdown)`; rescue **everything** and log (trackdown raises on private/loopback IPs in dev — a geo failure must never block a login write).
|
|
263
|
-
- Always `Trackdown.locate(ip.to_s, request: request)` so Cloudflare headers win when present (
|
|
263
|
+
- Always `Trackdown.locate(ip.to_s, request: request)` so Cloudflare headers win when present (HostApp mode: zero-config, free, synchronous header read).
|
|
264
264
|
- MaxMind mode (LicenseSeat shape): geolocate in the gem's enrichment job, and **pre-extract CF-header geo at enqueue time** so workers never need the MaxMind DB.
|
|
265
265
|
- Skip lookup when `country_code` already present. Store footprinted's proven column set (country_code/name, city, region; lat/lng on events only, precision-reduced). Flag emoji derives from `country_code` at render time — no column.
|
|
266
266
|
|
|
267
267
|
### 6.7 Touch, expiry & lifecycle
|
|
268
268
|
|
|
269
|
-
- **Throttled touch**: `last_seen_at` updated at most every `config.touch_every` (default 5 minutes) via one conditional `update_all` statement (hot-row-safe, callback-free —
|
|
269
|
+
- **Throttled touch**: `last_seen_at` updated at most every `config.touch_every` (default 5 minutes) via one conditional `update_all` statement (hot-row-safe, callback-free — HostApp's `IS DISTINCT FROM` pattern generalized; Rodauth's validate+touch-in-one-UPDATE proves the shape; authie's touch-every-request and devise-security's per-request `update_column` are the documented anti-patterns) (→ research/01 §Implications 6, research/06 §Steal).
|
|
270
270
|
- This finally makes the Rails security guide's own `Session.sweep` recommendation implementable (→ research/03 §5).
|
|
271
271
|
- **Expiry**: `config.idle_timeout` / `config.max_session_lifetime` (both default `nil` — a tracking gem must not silently change login lifetimes; see §12) with `config.timeout_preset = :nist_aal2` sugar (24h absolute / 1h idle). Enforced inline at resume (both adapters) and by the generated `Sessions::SweepJob` (also prunes per-user overflow beyond `config.max_sessions_per_user`, default 100 — GitLab's number — and purges trail rows past retention).
|
|
272
272
|
|
|
@@ -321,7 +321,7 @@ create_table :sessions_events do |t|
|
|
|
321
321
|
t.references :authenticatable, polymorphic: true # nullable: unknown-identity failures
|
|
322
322
|
t.string :scope
|
|
323
323
|
t.bigint :session_id # ← the linkage. Plain column, NO FK constraint:
|
|
324
|
-
#
|
|
324
|
+
# lifecycle rows can be erased; history must survive.
|
|
325
325
|
t.string :identity # email-as-typed (normalized), even for unknown accounts
|
|
326
326
|
t.string :auth_method, :auth_provider
|
|
327
327
|
t.json :auth_detail
|
|
@@ -375,12 +375,12 @@ Post-install message: ecosystem house style (emoji headline, numbered steps, yel
|
|
|
375
375
|
### 8.2 The model API
|
|
376
376
|
|
|
377
377
|
```ruby
|
|
378
|
-
current_user.sessions.
|
|
379
|
-
current_user.sessions.inactive # stale > 30 days (UI grouping, not enforcement)
|
|
378
|
+
current_user.sessions.live # live devices, most recent first
|
|
379
|
+
current_user.sessions.inactive # stale live rows > 30 days (UI grouping, not enforcement)
|
|
380
380
|
|
|
381
381
|
session = current_user.sessions.find(params[:id])
|
|
382
382
|
session.device_name # => "Chrome 137 on macOS"
|
|
383
|
-
# => "
|
|
383
|
+
# => "HostApp 2.4.1 on iPhone 15 Pro (iOS 19.5)"
|
|
384
384
|
session.location # => "Madrid, Spain" (+ session.country_flag => "🇪🇸")
|
|
385
385
|
session.last_seen_at # => 3 minutes ago
|
|
386
386
|
session.current? # => true for the request's own session
|
|
@@ -388,7 +388,7 @@ session.hotwire_native? # session.native_ios? / session.native_android? / ses
|
|
|
388
388
|
session.via_oauth? # session.auth_method / .auth_provider / "Signed in with Google"
|
|
389
389
|
session.suspicious? # v1.x: new-IP/new-country heuristics
|
|
390
390
|
|
|
391
|
-
session.revoke!(reason: :user_revoked, by: current_user) #
|
|
391
|
+
session.revoke!(reason: :user_revoked, by: current_user) # ends row + writes event
|
|
392
392
|
current_user.revoke_other_sessions! # GitHub's "sign out everywhere else"
|
|
393
393
|
current_user.revoke_all_sessions! # admin hammer (account takeover response)
|
|
394
394
|
|
|
@@ -405,7 +405,7 @@ Sessions::Event.by_country("RU").logins # admin: geo filteri
|
|
|
405
405
|
Sessions.current(request) # the registry row for this request (both adapters)
|
|
406
406
|
Sessions.tag(request, method: :passkey, detail: { user_verified: true }) # before sign-in
|
|
407
407
|
Sessions.record_failed_attempt(request, scope: :user, identity: params[:email],
|
|
408
|
-
reason: :invalid_password) # manual seams (
|
|
408
|
+
reason: :invalid_password) # manual seams (HostApp's native branch)
|
|
409
409
|
Sessions.track_login(user, request, method: :sso) # fully manual integrations
|
|
410
410
|
```
|
|
411
411
|
|
|
@@ -424,7 +424,7 @@ Sessions.configure do |config|
|
|
|
424
424
|
# — Device intelligence —
|
|
425
425
|
config.ua_parser = :browser # :device_detector | ->(ua, headers) { ... }
|
|
426
426
|
config.request_client_hints = false # set Accept-CH for real platform versions / Android models
|
|
427
|
-
config.native_app_names = ["
|
|
427
|
+
config.native_app_names = ["HostApp"] # extra UA prefixes to recognize (auto-learned from convention)
|
|
428
428
|
# — IP & geo —
|
|
429
429
|
config.ip_resolver = ->(request) { request.remote_ip } # CF-Connecting-IP setups override
|
|
430
430
|
config.ip_mode = :full # :truncated → zero last IPv4 octet / last 80 v6 bits (GA precedent)
|
|
@@ -456,14 +456,14 @@ end
|
|
|
456
456
|
```
|
|
457
457
|
|
|
458
458
|
- chats-style isolated engine: `path: ""` root resources, semantic `sessions-*` CSS classes with minimal default styles (Tailwind-friendly, themeable), no JS beyond Turbo (`button_to` + `turbo_confirm`), `rails g sessions:views` ejection for full control (→ research/02 §7).
|
|
459
|
-
- Alternatively, render the partials inside an existing settings shell (
|
|
460
|
-
- Contents per the standardized UX contract (§2.3): device icon by `device_type`, `device_name`, approximate location (labeled approximate), `last_seen_at` in words, "This device" badge, per-row **Log out** button, **Sign out of all other sessions** button, link to login history. i18n: `en` + `es` shipped (
|
|
459
|
+
- Alternatively, render the partials inside an existing settings shell (HostApp's `<section>`/`setting_row` pages; LicenseSeat's `settings` namespace): `render "sessions/devices", user: current_user`.
|
|
460
|
+
- Contents per the standardized UX contract (§2.3): device icon by `device_type`, `device_name`, approximate location (labeled approximate), `last_seen_at` in words, "This device" badge, per-row **Log out** button, **Sign out of all other sessions** button, link to login history. i18n: `en` + `es` shipped (HostApp's UI is Spanish, → research/01 §5).
|
|
461
461
|
|
|
462
462
|
### 8.6 Generators
|
|
463
463
|
|
|
464
464
|
- `sessions:install` — adaptive migration(s) + initializer + SweepJob into `app/jobs/` + `recurring.yml` snippet (trackdown/nondisposable pattern).
|
|
465
465
|
- `sessions:views` — eject engine views/partials.
|
|
466
|
-
- v1.x: `sessions:madmin` (resource files mirroring
|
|
466
|
+
- v1.x: `sessions:madmin` (resource files mirroring HostApp's `audit_log_resource.rb`), `sessions:one_tap`, `sessions:passkeys` integration injectors.
|
|
467
467
|
|
|
468
468
|
---
|
|
469
469
|
|
|
@@ -472,18 +472,18 @@ end
|
|
|
472
472
|
### 9.1 Rails 8 omakase (zero-touch)
|
|
473
473
|
|
|
474
474
|
- **Login events**: `Session.after_create_commit` — ip/UA already on the row from `start_new_session_for`; classification pipeline runs against `Sessions::Current.request` (set by a tiny gem middleware that stores the request reference per-request — needed because model callbacks lack request context; reset automatically by the executor).
|
|
475
|
-
- **Logout/revocation events**: `
|
|
475
|
+
- **Logout/revocation events**: `end!`/`revoke!` writes lifecycle state plus audit event transactionally; `after_destroy_commit` remains only as a compatibility hook for host-side raw deletes and Rails-generated `destroy_all` paths (→ research/03 §Top 6).
|
|
476
476
|
- **Touch**: prepend-wrap `resume_session` → after `super`, throttled touch of `Current.session`.
|
|
477
477
|
- **Failed logins**: two automatic layers + one manual: (a) prepend `SessionsController#create` when the generated duck-shape is detected — after `super`, if no new session was started and the request was a credentials POST, record `failed_login` with the permitted identity param; (b) subscribe to the `rate_limit.action_controller` notification (8.1+) for brute-force-threshold events — free signal, no code; (c) `Sessions.record_failed_attempt` for custom controllers. Opt-out: `config.track_failed_logins = false`.
|
|
478
478
|
- **Edge cases encoded** (→ research/03 §Implications 8): the generated `sign_in_as` test helper creates rows with nil ip/UA — all parsing is nil-tolerant; `--api` apps lack `helper_method` — engine helpers are Base/API-aware; signed-not-encrypted cookie exposes sequential row ids (informational; revocation security rests on the signature); 20-year permanent cookie means our sweep + optional idle timeout is the only real expiry; `ip_address` truthfulness depends on `trusted_proxies` (README "Behind Cloudflare" section: cloudflare-rails / replacement-semantics warning / CF-Connecting-IP only when origin-locked).
|
|
479
479
|
|
|
480
|
-
### 9.2 Devise / Warden (4.x and 5.x —
|
|
480
|
+
### 9.2 Devise / Warden (4.x and 5.x — HostApp is 5.0.4, LicenseSeat 4.9.4)
|
|
481
481
|
|
|
482
482
|
- The four hooks of §6.3, with the full guard set lifted from Devise's own hooks: skip flags (`env['sessions.skip']`, per-call `sign_in(user, sessions_skip: true)`, sticky session flag — mirroring session_limitable's three layers), `opts[:store] != false`, `warden.authenticated?(scope)`.
|
|
483
483
|
- **Fixation-safe by construction**: Warden's `set_user` renews the Rack SID but keeps session *data* — our token, stored in `warden.session(scope)`, survives login rotation and is deleted by Warden on logout. We never key on the Rack SID (→ research/04 §Top 3).
|
|
484
|
-
- **Remember-me**: a cookie re-auth is a real `:authentication` event (strategy `Rememberable`) arriving with a fresh Rack session → new registry row; stale rows get swept (v1) or re-linked via the browser-continuity cookie (v1.x). **Revocation closes the remember-me hole**: with `config.revoke_remember_me = true` (default), `revoke!` also rotates the user's remember credentials (Devise `forget_me!`/salt semantics — user-wide, documented: other devices keep their live sessions but cannot auto-revive after those end; GitLab does exactly this).
|
|
484
|
+
- **Remember-me**: a cookie re-auth is a real `:authentication` event (strategy `Rememberable`) arriving with a fresh Rack session → new registry row; stale rows get swept (v1) or re-linked via the browser-continuity cookie (v1.x). **Revocation closes the remember-me hole**: with `config.revoke_remember_me = true` (default), `revoke!` also rotates the user's remember credentials (Devise `forget_me!`/salt semantics — user-wide, documented: other devices keep their live sessions but cannot auto-revive after those end; GitLab does exactly this). HostApp's silent 1-year native remember-me makes this non-negotiable (→ research/01 §8 gap 4).
|
|
485
485
|
- **Multi-scope**: rows carry `scope`; `Devise.sign_out_all_scopes` fires `before_logout` once per scope — handled.
|
|
486
|
-
- **Coexistence**: `:trackable` keeps working (we never set `devise.skip_trackable`); `has_sessions` simply supersedes it — README suggests dropping trackable columns when ready. `bypass_sign_in` runs no callbacks → same session continues, token stays valid (documented). Timeoutable may `throw` on the same `:fetch` event — we tolerate both hook orders.
|
|
486
|
+
- **Coexistence**: `:trackable` keeps working (we never set `devise.skip_trackable`); `has_sessions` simply supersedes it — README suggests dropping trackable columns when ready. `bypass_sign_in` runs no callbacks → same session continues, token stays valid (documented). Timeoutable may `throw` on the same `:fetch` event — we tolerate both hook orders. HostApp's read-only `warden.user(run_callbacks: false)` peeks and its stale-remember-cookie native bootstrap never fire our hooks (correct — no login happens) (→ research/01 §3, research/04 §Implications).
|
|
487
487
|
|
|
488
488
|
### 9.3 OAuth / OmniAuth
|
|
489
489
|
|
|
@@ -498,13 +498,13 @@ Successes auto-classified (§6.4) on whichever adapter is active. Failures: the
|
|
|
498
498
|
|
|
499
499
|
### 9.5 Hotwire Native
|
|
500
500
|
|
|
501
|
-
- Detection/parsing per §6.5. **Device identity = the cookie jar, never the UA**: Hotwire Native shares one session cookie between WebView navigations and native HTTP calls while presenting two different UAs —
|
|
501
|
+
- Detection/parsing per §6.5. **Device identity = the cookie jar, never the UA**: Hotwire Native shares one session cookie between WebView navigations and native HTTP calls while presenting two different UAs — HostApp's NativeHttpClient explicitly syncs cookies — so one device = one registry row, with `client_hints` capturing the richer native-client headers (→ research/01 §3 "One cookie = one session across surfaces").
|
|
502
502
|
- README ships the 3-line iOS/Android `applicationUserAgentPrefix` snippets (app version everywhere, hardware model on iOS — Android model is free). Without them, the gem still yields platform + OS (+ Android model) from SDK defaults (→ research/07 §B).
|
|
503
503
|
- The signed `session_id` cookie is readable from middleware (`request.cookie_jar.signed[:session_id]` — the generated ActionCable Connection uses the same trick) for native-API contexts outside controllers (→ research/03 §Implications 5).
|
|
504
504
|
|
|
505
505
|
### 9.6 trackdown modes
|
|
506
506
|
|
|
507
|
-
- **
|
|
507
|
+
- **HostApp mode** (zero config, Cloudflare): synchronous CF-header read at request time — free.
|
|
508
508
|
- **LicenseSeat mode** (MaxMind initializer + refresh job): geo enrichment in `Sessions::GeolocateJob` with CF pre-extraction at enqueue.
|
|
509
509
|
- No trackdown → geo columns stay nil; UI omits location cleanly; README points to trackdown setup (footprinted's call-out box pattern).
|
|
510
510
|
|
|
@@ -524,7 +524,7 @@ Your devices
|
|
|
524
524
|
🖥 Chrome on macOS — This device [badge]
|
|
525
525
|
Madrid, Spain · Active now · Signed in May 2 via Google
|
|
526
526
|
|
|
527
|
-
📱
|
|
527
|
+
📱 HostApp 2.4.1 on iPhone 15 Pro (iOS 19.5) [Log out]
|
|
528
528
|
Madrid, Spain · Active 3 minutes ago · Signed in Apr 28
|
|
529
529
|
|
|
530
530
|
🖥 Firefox on Windows [Log out]
|
|
@@ -552,19 +552,19 @@ Requirements:
|
|
|
552
552
|
|
|
553
553
|
- **Scopes are the product** (BYOUI, moderate's posture): `Sessions::Event.failed_logins.last_24_hours.group(:ip_address).count`, `.for_identity`, `.for_ip`, `.by_country`, `.new_devices`, `user.sessions.active`, `Sessions::Event.velocity(identity:, within: 10.minutes)` (v1.x).
|
|
554
554
|
- **Admin verbs**: `user.revoke_all_sessions!(by: admin, reason: :admin_revoked)` — the account-takeover response; every admin action lands in the trail with `by`.
|
|
555
|
-
- **madmin recipe** (`sessions:madmin` generator, v1.x): SessionResource + EventResource mirroring
|
|
556
|
-
- **AuditLog tee**: `config.events = ->(event) { AuditLog.log(event_type: "session.#{event.name}", data: event.to_h, user: event.user, request: event.request) }` — one line wires
|
|
555
|
+
- **madmin recipe** (`sessions:madmin` generator, v1.x): SessionResource + EventResource mirroring HostApp's `audit_log_resource.rb` (menu parent "Security", recent-first, RelativeTimeField) (→ research/01 §5).
|
|
556
|
+
- **AuditLog tee**: `config.events = ->(event) { AuditLog.log(event_type: "session.#{event.name}", data: event.to_h, user: event.user, request: event.request) }` — one line wires HostApp's hash-chained ledger; same envelope works for Telegrama alerts (→ research/01 §2, research/02 §5).
|
|
557
557
|
- **New-device detection** (v1, powers `on_new_device`): a login is a *new device* when no prior session/event for that user matches on (device_type, os_name, app/browser identity) — deliberately coarse UA+IP-derived matching, never fingerprinting. New-country flag rides the same check when geo is present.
|
|
558
558
|
|
|
559
559
|
## 12. Security & privacy requirements (hard, each cited)
|
|
560
560
|
|
|
561
561
|
1. **Never persist a usable session credential.** Devise-mode tokens: random 32-byte, stored as SHA-256 digest, raw value only in the user's Rack session (OWASP Cheat Sheet "log a salted-hash"; Discourse/Rodauth precedent; high-entropy random ⇒ plain SHA-256 suffices — no pepper KDF theater). Omakase mode stores nothing secret (cookie signature is the credential). Never log raw tokens or cookie values anywhere (→ research/09 §Compliance, research/06 §Steal).
|
|
562
562
|
2. **Tracking is error-isolated** — every adapter body wrapped (authtrail `safely` pattern), failures logged at `warn`, login proceeds. A geo/parser/DB hiccup must never 500 a sign-in (→ research/02 §5).
|
|
563
|
-
3. **Revocation is server-side and immediate** (ASVS 7.4.1):
|
|
563
|
+
3. **Revocation is server-side and immediate** (ASVS 7.4.1): lifecycle end state is checked on the very next request in both adapters; Devise revoke also invalidates remember-me by default (§9.2).
|
|
564
564
|
4. **Terminate-others on password change** default-on (ASVS 3.3.3/7.4.3; Phoenix/Laravel/omakase-8.1 precedent). In Devise, the salt-embedded session value already kills cookie sessions on password change — our rows follow via the `:fetch` validation; we also emit the events.
|
|
565
565
|
5. **Failed-attempt logging is enumeration-safe**: store Devise's message symbol verbatim (paranoid mode stays `:invalid`); never echo whether the identity exists in any UI; never store the password or its length; identity normalized (`strip.downcase`) for correlation (→ research/04 §3).
|
|
566
566
|
6. **Data minimization** (GDPR Art. 5(1)(c)): no request bodies, no referrer trails (drop authtrail's `referrer` column), nullable IP, UA + IP + derived columns only. IPs and UAs are personal data (*Breyer* C-582/14, Recital 30) processed under Art. 6(1)(f)/Recital 49 (network security) — stated in README with a balancing-test note (→ research/09 §Privacy).
|
|
567
|
-
7. **Bounded retention**: trail default 12 months (CNIL 6–12), purge job generated and scheduled; registry rows
|
|
567
|
+
7. **Bounded retention**: trail default 12 months (CNIL 6–12), purge job generated and scheduled; live registry rows end with their sessions, then retention/account-erasure cleanup can remove them. Right-to-erasure: `dependent: :destroy`/`delete_all` wiring + `Sessions.forget(user)` helper that also nulls identity on retained failure rows.
|
|
568
568
|
8. **Optional hardening**: `config.ip_mode = :truncated` (GA precedent: zero last IPv4 octet / last 80 v6 bits, applied *before* persistence); AR-encryption recipe (`encrypts :ip_address, deterministic: true` — deterministic needed for equality queries, tradeoff documented per the Rails guide; non-deterministic for `user_agent`); lat/lng precision reduction default-on (→ research/09 §Privacy).
|
|
569
569
|
9. **No client-side fingerprinting, ever** — scope guardrail (WP224: consent-gated under ePrivacy 5(3)). Server-observed UA + IP only — the GitLab/Mastodon/Discourse line (→ research/09 §Fraud).
|
|
570
570
|
10. **Timeout enforcement is opt-in** — a tracking gem must not silently shorten anyone's sessions; presets (`:nist_aal2` etc.) make opting in one line. Defaults documented loudly. The 20-year omakase cookie means *our* sweep is the only expiry most apps will have — the README says so.
|
|
@@ -577,7 +577,7 @@ Requirements:
|
|
|
577
577
|
- **Structure**: `Rails::Engine` with `isolate_namespace Sessions`; spine files `require_relative`'d; models/jobs under `lib/sessions/{models,jobs}` wired into the host Zeitwerk loader (`push_dir`/`collapse`/`ignore` before `:set_autoload_paths` — moderate/chats pattern). Never defines a top-level `Session` constant in the gem itself (→ research/02 §1, research/03 §Implications 7).
|
|
578
578
|
- **Dependencies**: `activerecord`/`activesupport`/`actionpack`/`railties >= 7.1, < 9.0` (Rails-8-first, 7.1 floor is cheap per moderate/chats); `browser >= 6` (hard); everything else soft (`trackdown`, `device_detector`, Devise/Warden, OmniAuth). Ruby `>= 3.2`. MIT. `rubygems_mfa_required`. Authors/email per house gemspec (→ research/02 §1).
|
|
579
579
|
- **Errors**: `Sessions::Error < StandardError`, `Sessions::ConfigurationError`, `Sessions::UnknownAuthSystemError` (generator-time).
|
|
580
|
-
- **DB support**: PostgreSQL, MySQL, SQLite via portable column types + adaptive migration (uuid/bigint, jsonb/json, optional `:inet` upgrade on PG). Both
|
|
580
|
+
- **DB support**: PostgreSQL, MySQL, SQLite via portable column types + adaptive migration (uuid/bigint, jsonb/json, optional `:inet` upgrade on PG). Both HostApp and LicenseSeat are uuid-PK Postgres apps; dummy apps test bigint+SQLite too.
|
|
581
581
|
|
|
582
582
|
## 14. Testing & quality strategy
|
|
583
583
|
|
|
@@ -591,7 +591,7 @@ Requirements:
|
|
|
591
591
|
## 15. Rollout plan
|
|
592
592
|
|
|
593
593
|
1. **Phase 0 — Gem core** (this repo): registry + trail + both adapters + device intel + UI, built TDD against the two dummy apps. README written early in the house formula (emoji title → candy example → quickstart → deep dives → "what it doesn't do" → "why the models").
|
|
594
|
-
2. **Phase 1 — Incubate in
|
|
594
|
+
2. **Phase 1 — Incubate in HostApp** (Devise 5 + native + Cloudflare + Spanish): point Gemfile at the sibling checkout (`moderate` precedent); wire `config.events → AuditLog`, goodmail new-device recipe, madmin resources; "Sesiones y dispositivos" section in `/settings`; native UA-prefix snippets into hostapp-ios/android. Validates: Warden adapter, manual-branch failure seam, remember-me revocation, CF geo, native parsing, i18n.
|
|
595
595
|
3. **Phase 2 — Validate in LicenseSeat** (Devise 4.9, MaxMind, api_keys, English): validates devise 4.x, MaxMind async geo, token-auth exclusion, `settings` namespace UI fit.
|
|
596
596
|
4. **Phase 3 — Omakase proof**: a RailsFast-adjacent demo app on `rails g authentication` (zero-touch story, screenshots for README/launch post).
|
|
597
597
|
5. **Phase 4 — Extract & launch**: cut 0.1.0 → rubygems; launch content timed to the ecosystem calendar: Planet Argon 2026 survey results land **July 2026** (fresh auth-share data to cite) and **Rails World Austin is 2026-09-23/24** (→ research/08 §Implications 6).
|
|
@@ -619,14 +619,14 @@ Requirements:
|
|
|
619
619
|
6. **`Sessions::Event` vs `Sessions::Login` naming** for the trail model (events include logouts/revocations — `Event` recommended). Confirm.
|
|
620
620
|
7. **Reserve the gem name now?** Push a 0.0.1 stub to RubyGems this week to lock `sessions` (and optionally `sessionable`/`login_activity` as redirect-stubs? — probably unnecessary). Recommended: yes, immediately.
|
|
621
621
|
8. **Sudo mode**: ship only the `require_reauthentication` hook (recommended for v1) or build a first-party password-confirm flow (auth-zero-style `sudo_at` on the session row is cheap and we have the column budget — could be the v1.x headline)?
|
|
622
|
-
9. **Scope of `signup` enrichment**:
|
|
622
|
+
9. **Scope of `signup` enrichment**: HostApp's 22 `signup_*` columns overlap heavily with what every session row now carries. Should the gem also expose a `Sessions.attribution_for(user)` (first-session) helper so HostApp can eventually drop SignupAttribution, or is that HostApp's own cleanup later?
|
|
623
623
|
10. **License/positioning detail**: any appetite for a paid `sessions_pro` tier later (impossible travel, org dashboards)? Affects how much fraud tooling lands in the open core roadmap.
|
|
624
624
|
|
|
625
625
|
## 18. Research appendix
|
|
626
626
|
|
|
627
627
|
| Memo | Covers |
|
|
628
628
|
|---|---|
|
|
629
|
-
| [research/01-
|
|
629
|
+
| [research/01-host-app.md](research/01-host-app.md) | HostApp + LicenseSeat audit: Devise setup, native UA/header contracts, AuditLog pattern, trackdown modes, UI conventions, gaps |
|
|
630
630
|
| [research/02-ecosystem.md](research/02-ecosystem.md) | rameerez house style: macros, config, generators, hooks, UI shipping, trackdown/footprinted deep dives |
|
|
631
631
|
| [research/03-rails-core.md](research/03-rails-core.md) | Rails 8.1.3 + main auth generator internals, verbatim templates, supporting APIs, integration points |
|
|
632
632
|
| [research/04-devise-warden.md](research/04-devise-warden.md) | Warden hook ABI, Devise sign-in flow, session_limitable revocation template, edge cases, Devise 2026 state |
|
|
@@ -661,12 +661,12 @@ So "existing projects already solve all the needs" is false — but "authtrail a
|
|
|
661
661
|
|
|
662
662
|
**The bear case, stated plainly:**
|
|
663
663
|
|
|
664
|
-
1. **This is a vitamin, not a painkiller, for most apps.** Sessions pages are security hygiene — rarely visited, never revenue. Unlike `usage_credits` or `pricing_plans` (which touch money), nobody's launch is blocked on this. Demand concentrates in serious production apps: B2B SaaS facing SOC2/ASVS checklists, consumer apps fighting fraud/ATO (
|
|
664
|
+
1. **This is a vitamin, not a painkiller, for most apps.** Sessions pages are security hygiene — rarely visited, never revenue. Unlike `usage_credits` or `pricing_plans` (which touch money), nobody's launch is blocked on this. Demand concentrates in serious production apps: B2B SaaS facing SOC2/ASVS checklists, consumer apps fighting fraud/ATO (HostApp's exact case), and anyone selling upmarket. Hobby apps will skip it.
|
|
665
665
|
2. **The ceiling is authtrail-magnitude, not Devise-magnitude.** Millions of downloads over years, not hundreds of millions. That's a successful rameerez gem, not a breakout — *unless* distribution changes the math (shipping it default-on in RailsFast, where a devices page becomes a template selling point, is genuinely your unfair advantage).
|
|
666
|
-
3. **Part of the value is
|
|
666
|
+
3. **Part of the value is thinner on Rails 8 than Devise.** Rails has a first-party session row and signed cookie; on omakase our value is breadth and polish (failure capture has no hook, touch has write-amplification traps, lifecycle state, retention/GDPR, native parsing), not inventing the primitive. On Devise the value is capability. The technical moat is Devise; the marketing wedge is Rails 8.
|
|
667
667
|
4. **Teams who want zero effort already have an answer: hosted auth** (Clerk/WorkOS sell session management as a feature). Our market is specifically the own-your-auth majority that the Rails 8 wave is actively growing — which is the right side of the trend, but it's a real boundary.
|
|
668
668
|
|
|
669
|
-
**What makes me land on "build it" despite all that:** the validation isn't vibes — Laravel and Phoenix ship this *by default*, OWASP ASVS makes it a literal L2 requirement, GitLab/Mastodon/Discourse each pay ongoing maintenance on hand-rolled versions, and authtrail proves people install adjacent tooling at scale while leaving the harder 70% of the feature unserved. And your economics are unusually favorable: **
|
|
669
|
+
**What makes me land on "build it" despite all that:** the validation isn't vibes — Laravel and Phoenix ship this *by default*, OWASP ASVS makes it a literal L2 requirement, GitLab/Mastodon/Discourse each pay ongoing maintenance on hand-rolled versions, and authtrail proves people install adjacent tooling at scale while leaving the harder 70% of the feature unserved. And your economics are unusually favorable: **HostApp needs this regardless** (it currently has unrevocable 1-year native remember-me cookies and zero login history — a genuine security gap), so the incubation cost is sunk; gem-ifying is marginal cost on top.
|
|
670
670
|
|
|
671
671
|
If I had to bet: solid, durable, "obvious in retrospect" ecosystem gem with authtrail-or-better adoption — not a rocket. The two things that would most change the odds upward: RailsFast default inclusion, and nailing the 60-second zero-config demo on a fresh Rails 8 app. The one thing that would kill it: scope creep into auth itself.
|
|
672
672
|
|
|
@@ -700,19 +700,19 @@ If I compress it to one sentence: **be the obvious answer in every fresh Rails 8
|
|
|
700
700
|
|
|
701
701
|
## Do we provide views? Do we provide an engine? How does this all work, exactly?
|
|
702
702
|
|
|
703
|
-
Both — it's a four-layer stack where each layer is optional and built on the one below. This is the synthesis of how your own gems already do it (chats = engine + ejection, moderate = primitives + BYOUI, api_keys = mounted dashboard, profitable = authenticated mount), picked per layer for this gem's reality:
|
|
703
|
+
Both — it's a four-layer stack where each layer is optional and built on the one below. This is the synthesis of how your own gems already do it (chats = engine + ejection, moderate = primitives + BYOUI, api_keys = mounted dashboard, profitable = authenticated mount), picked per layer for this gem's reality: HostApp wants Spanish `setting_row` sections inside its existing settings page, LicenseSeat wants a page in its `settings` namespace — so one fixed page can't be the only offering (→ research/01 §5).
|
|
704
704
|
|
|
705
705
|
**Layer 0 — Model API, no UI at all.** Everything works headless: `user.sessions.active`, `session.device_name`, `session.revoke!`, `Sessions::Event` scopes. A host can build 100% custom UI in ~20 lines of their own controller. This layer is the contract; everything above is convenience.
|
|
706
706
|
|
|
707
707
|
**Layer 1 — Partials, renderable inside the host's own pages.** Rails automatically appends every engine's `app/views` to the host's view lookup path, so the gem ships partials that any host view can render directly with locals:
|
|
708
708
|
|
|
709
709
|
```erb
|
|
710
|
-
<%# inside
|
|
710
|
+
<%# inside HostApp's app/views/settings/show.html.erb, in its own <section> %>
|
|
711
711
|
<%= render "sessions/devices", user: current_user %>
|
|
712
712
|
<%= render "sessions/history", user: current_user, limit: 10 %>
|
|
713
713
|
```
|
|
714
714
|
|
|
715
|
-
No mount, no routes from us — the revoke buttons are `button_to`s pointing at engine routes *or* host-provided routes (configurable URL helper, so
|
|
715
|
+
No mount, no routes from us — the revoke buttons are `button_to`s pointing at engine routes *or* host-provided routes (configurable URL helper, so HostApp can route revocation through its own controller if it wants). This is the layer HostApp actually uses.
|
|
716
716
|
|
|
717
717
|
**Layer 2 — The mounted engine (the README headline).** A complete drop-in page for apps that just want it done:
|
|
718
718
|
|
|
@@ -736,7 +736,7 @@ Mechanics, exactly:
|
|
|
736
736
|
|
|
737
737
|
**Layer 3 — Ejection, exactly Devise/chats-style.** `rails g sessions:views` copies the engine's views into `app/views/sessions/` — and because application view paths take precedence over engine view paths, the copies shadow the gem's automatically. Edit freely; gem updates never touch them (the documented tradeoff, same as `devise:views`). Controllers stay ours and deliberately trivial — if you need custom controller behavior, you've graduated to Layer 0/1 rather than us supporting a controller-override matrix (Devise's `scoped_views`/custom-controllers surface is a maintenance tarpit we're explicitly not replicating).
|
|
738
738
|
|
|
739
|
-
**Admin is *not* a layer** — that stays primitives + recipes (moderate's posture): `Sessions::Event` scopes plus the `sessions:madmin` generator producing resource files modeled on
|
|
739
|
+
**Admin is *not* a layer** — that stays primitives + recipes (moderate's posture): `Sessions::Event` scopes plus the `sessions:madmin` generator producing resource files modeled on HostApp's `audit_log_resource.rb`. No shipped admin UI; admin frameworks vary too much and madmin/Avo/Administrate all want to own that surface.
|
|
740
740
|
|
|
741
741
|
So the gem tree looks like: `app/views/sessions/` (partials + engine pages), `app/controllers/sessions/` (two small controllers), `config/routes.rb` (engine routes), `lib/sessions/models/` (Zeitwerk-wired models), `lib/generators/sessions/{install,views,madmin}`. The README leads with Layer 2 (the 60-second demo needs the mount), then immediately shows Layer 1 ("or render it inside your own settings page") — because that's the path both of your production apps will actually take.
|
|
742
742
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
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
|
|