studio-engine 0.135.2 → 0.136.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.
@@ -0,0 +1,194 @@
1
+ // studio/page_header_self_styled: the page-header controller's work for an app with no
2
+ // Stimulus and no import map (config.page_header_self_styled, docs/PAGE_HEADER.md, "In a
3
+ // plain-CSS app"). studio/page_header/_assets loads it by its own module tag, once per
4
+ // page, beside the row's scoped CSS.
5
+ //
6
+ // For every studio/_page_header on the page it:
7
+ // - marks the row data-stuck once it has left its place in the flow, so the CSS gives
8
+ // it the bar;
9
+ // - marks which ends of the tabs hide a tab (data-fade-start, data-fade-end);
10
+ // - scrolls the open tab into view inside the tabs on load;
11
+ // - keeps each Links menu's aria-expanded in step with its popover, walks into the list
12
+ // on ArrowDown, and places the list where the browser has no anchor positioning.
13
+ //
14
+ // With no pinned stack on the page (window.studioPinnedStack), it also publishes
15
+ // --nav-bottom from the document's first <header>, when that header is sticky or fixed,
16
+ // so the row sticks under the app's own masthead. An app whose CSS sets --nav-bottom or
17
+ // --pin-page-top keeps its own value.
18
+ //
19
+ // IT IMPORTS NOTHING, because a plain app may have no import map. The five measurements
20
+ // below are studio/page_header's, copied; test/javascript/page_header_self_styled.test.mjs
21
+ // holds the copies to the originals. It boots only in a browser, so the test can load it
22
+ // as a data: module. A module runs once per document, so a Turbo visit rescans on
23
+ // turbo:load.
24
+
25
+ export function isStuck(rowTop, restTop) {
26
+ return rowTop - restTop > 0.5
27
+ }
28
+
29
+ export function sentinelMarksThePlace(parentDisplay) {
30
+ return !/flex|grid/.test(parentDisplay)
31
+ }
32
+
33
+ export function hiddenEnds({ scrollLeft, clientWidth, scrollWidth }) {
34
+ return {
35
+ start: scrollLeft > 1,
36
+ end: scrollLeft + clientWidth < scrollWidth - 1
37
+ }
38
+ }
39
+
40
+ export function revealBy(box, tab, margin = 32) {
41
+ if (tab.left < box.left) return tab.left - box.left - margin
42
+ if (tab.right > box.right) return tab.right - box.right + margin
43
+ return 0
44
+ }
45
+
46
+ export function menuPlacement(button, list, viewport, gap = 8) {
47
+ const below = viewport.height - button.bottom
48
+ const above = button.top
49
+ const top = below < list.height + gap && above > below
50
+ ? Math.max(gap, button.top - list.height - 4)
51
+ : button.bottom + 4
52
+ const left = Math.min(
53
+ Math.max(gap, button.right - list.width),
54
+ Math.max(gap, viewport.width - list.width - gap)
55
+ )
56
+ return { top, left }
57
+ }
58
+
59
+ // The bottom edge, in px, of the app's own masthead: the first header, when it is sticky
60
+ // or fixed. 0 for any other header, or none.
61
+ export function mastheadBottom(position, bottom) {
62
+ return position === "sticky" || position === "fixed" ? Math.max(0, bottom) : 0
63
+ }
64
+
65
+ function anchorsSupported() {
66
+ return typeof CSS !== "undefined" && !!CSS.supports &&
67
+ CSS.supports("anchor-name: --a") && CSS.supports("position-anchor: --a")
68
+ }
69
+
70
+ function boot() {
71
+ if (window.studioPageHeaderSelfStyled) {
72
+ window.studioPageHeaderSelfStyled.scan()
73
+ return
74
+ }
75
+
76
+ const root = document.documentElement
77
+ let rows = []
78
+ let frame = null
79
+ let ownsNavBottom = null
80
+
81
+ const publishNavBottom = () => {
82
+ if (window.studioPinnedStack) return
83
+ if (ownsNavBottom === null) {
84
+ const computed = getComputedStyle(root)
85
+ ownsNavBottom = !root.style.getPropertyValue("--nav-bottom") &&
86
+ !computed.getPropertyValue("--nav-bottom").trim() &&
87
+ !computed.getPropertyValue("--pin-page-top").trim()
88
+ }
89
+ if (!ownsNavBottom) return
90
+ const header = document.querySelector("header")
91
+ const bottom = header ? mastheadBottom(getComputedStyle(header).position, header.getBoundingClientRect().bottom) : 0
92
+ const value = `${Math.round(bottom * 100) / 100}px`
93
+ if (root.style.getPropertyValue("--nav-bottom") !== value) root.style.setProperty("--nav-bottom", value)
94
+ }
95
+
96
+ // The top of the row's place in the flow. In a flex or grid parent only the row can
97
+ // say, so it is unstuck for one read; no frame paints in between.
98
+ const restTop = ({ sentinel, row }) => {
99
+ if (sentinelMarksThePlace(getComputedStyle(sentinel.parentElement).display)) {
100
+ return sentinel.getBoundingClientRect().top
101
+ }
102
+ row.style.position = "static"
103
+ const top = row.getBoundingClientRect().top
104
+ row.style.position = ""
105
+ return top
106
+ }
107
+
108
+ const placeMenu = (button, list) => {
109
+ const place = menuPlacement(button.getBoundingClientRect(), list.getBoundingClientRect(),
110
+ { width: window.innerWidth, height: window.innerHeight })
111
+ list.style.top = `${place.top}px`
112
+ list.style.left = `${place.left}px`
113
+ list.style.right = "auto"
114
+ }
115
+
116
+ const sync = () => {
117
+ publishNavBottom()
118
+ rows = rows.filter((item) => item.row.isConnected)
119
+ for (const item of rows) {
120
+ item.row.toggleAttribute("data-stuck", isStuck(item.row.getBoundingClientRect().top, restTop(item)))
121
+ if (!anchorsSupported()) {
122
+ for (const { button, list } of item.menus) {
123
+ if (list.matches(":popover-open")) placeMenu(button, list)
124
+ }
125
+ }
126
+ if (!item.tabs) continue
127
+ const ends = hiddenEnds(item.tabs)
128
+ item.tabs.toggleAttribute("data-fade-start", ends.start)
129
+ item.tabs.toggleAttribute("data-fade-end", ends.end)
130
+ }
131
+ }
132
+
133
+ const schedule = () => {
134
+ if (frame) return
135
+ frame = requestAnimationFrame(() => {
136
+ frame = null
137
+ sync()
138
+ })
139
+ }
140
+
141
+ // A disclosure, not role="menu": the popover opens, closes on Escape and gives focus
142
+ // back with no script. This keeps aria-expanded honest and adds ArrowDown.
143
+ const wireMenu = (menu) => {
144
+ const button = menu.querySelector("[popovertarget]")
145
+ const list = menu.querySelector("[popover]")
146
+ if (!button || !list) return null
147
+ button.setAttribute("aria-expanded", "false")
148
+ list.addEventListener("toggle", (event) => {
149
+ const open = event.newState === "open"
150
+ button.setAttribute("aria-expanded", String(open))
151
+ if (open) {
152
+ if (!anchorsSupported()) placeMenu(button, list)
153
+ return
154
+ }
155
+ if (document.activeElement === document.body || list.contains(document.activeElement)) button.focus()
156
+ })
157
+ button.addEventListener("keydown", (event) => {
158
+ if (event.key !== "ArrowDown") return
159
+ event.preventDefault()
160
+ if (!list.matches(":popover-open")) list.showPopover?.()
161
+ list.querySelector("a")?.focus()
162
+ })
163
+ return { button, list }
164
+ }
165
+
166
+ const wire = (sentinel) => {
167
+ if (sentinel.hasAttribute("data-page-header-live")) return
168
+ const row = sentinel.nextElementSibling
169
+ if (!row || !row.classList.contains("page-header")) return
170
+ sentinel.setAttribute("data-page-header-live", "")
171
+ const tabs = row.querySelector(".page-header-tabs")
172
+ const menus = [...row.querySelectorAll(".page-header-menu")].map(wireMenu).filter(Boolean)
173
+ if (tabs) {
174
+ tabs.addEventListener("scroll", schedule, { passive: true })
175
+ const open = tabs.querySelector("[aria-current]")
176
+ if (open) tabs.scrollLeft += revealBy(tabs.getBoundingClientRect(), open.getBoundingClientRect())
177
+ }
178
+ rows.push({ sentinel, row, tabs, menus })
179
+ }
180
+
181
+ const scan = () => {
182
+ document.querySelectorAll(".page-header-sentinel").forEach(wire)
183
+ sync()
184
+ }
185
+
186
+ window.studioPageHeaderSelfStyled = { scan }
187
+ window.addEventListener("scroll", schedule, { passive: true })
188
+ window.addEventListener("resize", schedule, { passive: true })
189
+ document.addEventListener("turbo:load", scan)
190
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", scan)
191
+ else scan()
192
+ }
193
+
194
+ if (typeof window !== "undefined" && typeof document !== "undefined") boot()
@@ -106,6 +106,15 @@ export function leaveStyle(win, style) {
106
106
  }
107
107
  }
108
108
 
109
+ // A skip the engine chooses. Chrome rejects the skipped transition's `ready` with "Transition
110
+ // was skipped" and leaves the rejection unhandled (measured: a pageerror whenever nothing
111
+ // awaits `ready`). This skip is the engine's own decision, so that one rejection is expected
112
+ // and handled here. A transition the engine did not skip is never touched.
113
+ export function skipOwnTransition(transition) {
114
+ if (transition.ready && typeof transition.ready.catch === "function") transition.ready.catch(() => {})
115
+ transition.skipTransition()
116
+ }
117
+
109
118
  // A slow image must not hold a click: past the cap the swap goes ahead.
110
119
  export function withCap(promise, ms, timer = setTimeout) {
111
120
  return Promise.race([promise, new Promise((done) => timer(done, ms))])
@@ -210,9 +219,11 @@ export function installPageTransitions(doc) {
210
219
 
211
220
  doc.addEventListener("turbo:visit", (event) => {
212
221
  root.dataset.pageTransitionVisit = ""
213
- // Turbo opens its view transition before it reads turbo-visit-control. A response it will
214
- // not render gets no turbo:before-render, so this stands, and that transition draws nothing.
215
- // The full load after it plays the one motion. Left alone while a motion is still running.
222
+ // A response Turbo will not render gets no turbo:before-render, so this stands. Turbo
223
+ // opens no transition for it (studio/reload_visits takes the meta out of what it parses),
224
+ // and should one open on a path that guard does not see (a frame's reload visit), this
225
+ // makes it draw nothing. The full load after it plays the one motion. Left alone while a
226
+ // motion is still running.
216
227
  if (!root.dataset.pageTransition) root.dataset.pageTransition = STILL
217
228
  target = new URL(event.detail.url).pathname
218
229
  // A GET that redirects: Turbo renders the response, then replaces to the final address
@@ -231,17 +242,37 @@ export function installPageTransitions(doc) {
231
242
 
232
243
  // A page never visited: read its names from the response, ahead of the old page's picture.
233
244
  // Only a page with names of its own can share one, so every other page skips the parse.
245
+ //
246
+ // TURBO READS THE PAGE THROUGH THIS READ. Turbo starts its view transition one frame after
247
+ // its own read of the response resolves, and the browser takes the old page's picture then:
248
+ // the morph's name must be down by that moment. Turbo's responseHTML getter is a fresh
249
+ // response.clone().text() on each access, and nothing orders this module's read before
250
+ // Turbo's, so this read is put in its place (studio/reload_visits does the same): the read
251
+ // Turbo awaits is the one that has already named the morph. Measured without it: under an
252
+ // 8x CPU throttle, 2 of 6 visits started the transition with no name, and nothing morphed.
234
253
  doc.addEventListener("turbo:before-fetch-response", (event) => {
235
254
  if (event.target && event.target.tagName === "TURBO-FRAME") return
236
255
  if (!morphNames(doc).size) return
237
256
  const { fetchResponse } = event.detail
238
- Promise.resolve(fetchResponse.responseHTML).then((html) => {
239
- if (!html) return
240
- const names = morphNames(new win.DOMParser().parseFromString(html, "text/html"))
241
- const path = new URL(fetchResponse.response.url).pathname
242
- known.set(path, names)
243
- if (target && (path === target || fetchResponse.redirected)) predict(names)
244
- }).catch(() => {})
257
+ const named = Promise.resolve(fetchResponse.responseHTML).then((html) => {
258
+ try {
259
+ if (!html) return html
260
+ const names = morphNames(new win.DOMParser().parseFromString(html, "text/html"))
261
+ const path = new URL(fetchResponse.response.url).pathname
262
+ known.set(path, names)
263
+ if (target && (path === target || fetchResponse.redirected)) predict(names)
264
+ } catch {
265
+ // A page this module cannot read morphs nothing; Turbo still renders it.
266
+ }
267
+ return html
268
+ })
269
+ // A failed read rejects for Turbo exactly as its own would; this module needs no answer.
270
+ named.catch(() => {})
271
+ try {
272
+ Object.defineProperty(fetchResponse, "responseHTML", { configurable: true, value: named })
273
+ } catch {
274
+ // A response that refuses the swap keeps Turbo's own read, and the race this closes.
275
+ }
245
276
  })
246
277
 
247
278
  // In capture, so the hold is in place before any other listener takes Turbo's resume.
@@ -305,7 +336,7 @@ export function installPageTransitions(doc) {
305
336
  const left = leaveStyle(win, style)
306
337
  if (!transition) return
307
338
  if (!left) {
308
- transition.skipTransition()
339
+ skipOwnTransition(transition)
309
340
  return
310
341
  }
311
342
  markHeld(doc)
@@ -0,0 +1,93 @@
1
+ // studio/reload_visits: Turbo opens no view transition for a visit it will only reload.
2
+ //
3
+ // THE DEFECT. Smooth load (layouts/studio/_smooth_load) turns on Turbo's same-document view
4
+ // transitions. Turbo starts the transition BEFORE it reads the response's head: its render
5
+ // callback is what finds turbo-visit-control reload (turf-monster's /wallet and /account) or a
6
+ // changed data-turbo-track="reload" asset, and then navigates by a full load. That navigation
7
+ // can skip the transition while its `ready` promise is still pending. Turbo awaits only
8
+ // `finished` (turbo-rails 2.0.23, turbo.js ViewTransitioner#renderChange), so `ready` rejects
9
+ // with nobody listening: an unhandled "Transition was skipped" error on every such visit that
10
+ // loses the race (turf-monster CI run 42447610386).
11
+ //
12
+ // THE FIX, AT THE CAUSE. Turbo starts a transition only when both the current page and the new
13
+ // snapshot carry the view-transition meta (PageView#shouldTransitionTo). So when Turbo's own
14
+ // renderer says it will not render a response, this takes that meta out of the HTML Turbo
15
+ // parses. Turbo then swaps nothing in a transition and goes straight to its full load, which
16
+ // plays the page transition once (docs/PAGE_TRANSITIONS.md, "Full page loads"). Only a page
17
+ // Turbo was going to throw away is changed, and the page the full load brings is the server's
18
+ // own. No promise is marked handled, so a real failure (a duplicate view-transition-name, a
19
+ // render that throws) still surfaces.
20
+ //
21
+ // THE SEAM. turbo:before-fetch-response, the moment studio/loader also uses: Turbo awaits
22
+ // fetchResponse.responseHTML right after it, so a promise put there is what Turbo parses. The
23
+ // two compose in either order, since each reads the other's promise. A frame's response is left
24
+ // alone: a frame never transitions, and its reload is a new fetch.
25
+ //
26
+ // It imports nothing; test/javascript/reload_visits.test.mjs loads it as a data: module.
27
+
28
+ // The meta Turbo reads for a same-document transition: `view-transition` (same-origin), or
29
+ // the `turbo-view-transition` setting. Not `view-transition-*` names of anything else.
30
+ const TRANSITION_META = /<meta\b[^>]*\bname\s*=\s*["']?(?:turbo-)?view-transition(?![\w-])[^>]*>/gi
31
+ const VISIT_CONTROL_RELOAD = /<meta[^>]*(turbo-visit-control[^>]*reload|reload[^>]*turbo-visit-control)/i
32
+
33
+ // The head alone: Turbo's decision reads only the head, and the body is most of the bytes.
34
+ export function headOf(html) {
35
+ const end = html.search(/<\/head\s*>/i)
36
+ return end < 0 ? html : html.slice(0, end)
37
+ }
38
+
39
+ export function prefersTransition(html) {
40
+ TRANSITION_META.lastIndex = 0
41
+ return TRANSITION_META.test(headOf(html))
42
+ }
43
+
44
+ // Whether Turbo would render this HTML as the next page. Asked of Turbo's own PageRenderer, so
45
+ // the answer cannot drift from Turbo's (visit control, then the tracked assets). Without
46
+ // Turbo's classes, the visit-control meta alone, the case a host sets on purpose.
47
+ export function rendersAsPage(html, doc, turbo) {
48
+ const head = headOf(html)
49
+ if (turbo && turbo.PageRenderer && turbo.PageSnapshot) {
50
+ const current = turbo.PageSnapshot.fromElement(doc.documentElement)
51
+ const next = turbo.PageSnapshot.fromHTMLString(head)
52
+ return new turbo.PageRenderer(current, next, false).shouldRender
53
+ }
54
+ return !VISIT_CONTROL_RELOAD.test(head)
55
+ }
56
+
57
+ // The HTML Turbo should parse: unchanged, unless it carries the transition meta and Turbo will
58
+ // not render it, and then the same HTML with that meta out of its head.
59
+ export function withoutReloadTransition(html, doc, turbo) {
60
+ if (typeof html !== "string" || !prefersTransition(html)) return html
61
+ // A Turbo whose classes changed shape leaves the HTML as it came: the old behaviour, not a
62
+ // failed visit.
63
+ try {
64
+ if (rendersAsPage(html, doc, turbo)) return html
65
+ } catch {
66
+ return html
67
+ }
68
+ const head = headOf(html)
69
+ return head.replace(TRANSITION_META, "") + html.slice(head.length)
70
+ }
71
+
72
+ export function guardResponse(event, doc, turbo) {
73
+ if (event.target && event.target.tagName === "TURBO-FRAME") return false
74
+ const response = event.detail && event.detail.fetchResponse
75
+ if (!response || !response.isHTML) return false
76
+ const html = Promise.resolve(response.responseHTML).then((text) => withoutReloadTransition(text, doc, turbo))
77
+ Object.defineProperty(response, "responseHTML", { configurable: true, value: html })
78
+ return true
79
+ }
80
+
81
+ let installed = false
82
+
83
+ export function installReloadVisits(doc) {
84
+ if (installed || !doc || !doc.addEventListener) return false
85
+ installed = true
86
+ doc.addEventListener("turbo:before-fetch-response", (event) => {
87
+ const win = doc.defaultView
88
+ guardResponse(event, doc, win && win.Turbo)
89
+ })
90
+ return true
91
+ }
92
+
93
+ if (typeof document !== "undefined") installReloadVisits(document)
@@ -1,21 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Studio
4
6
  # One signed-in browser session: who, whether they were an admin, when it
5
7
  # started and when it was last seen. It is a cached record for the app's own
6
8
  # dashboard, kept on purpose without an IP, a user agent or a path.
7
9
  #
8
10
  # A new row starts after IDLE without a request. An open row is written at most
9
- # once per THROTTLE, and the cookie remembers when, so a request inside that
10
- # window runs no query at all.
11
+ # once per THROTTLE, and the cookie remembers when.
12
+ #
13
+ # A DEVICE SESSION YOU CAN END (docs/DEVICE_SESSIONS.md). The cookie store keeps
14
+ # the whole session in the browser, so a logout used to delete only the
15
+ # browser's copy: a copy of the pre-logout cookie still signed in. Each signed-in
16
+ # cookie now carries a random `sid` in its mark, and every row of that device
17
+ # session carries the same sid. A logout stamps `revoked_at` on those rows, and
18
+ # every signed-in request reads its sid's rows (one indexed query): a revoked
19
+ # row ends the sid for good, and so does no row at all: the request is signed
20
+ # out. Other devices have other sids, so they stay signed in.
11
21
  #
12
- # Nil-safe, because the table comes from a migration the host runs: an app that
13
- # has not run it records nothing and serves as before.
22
+ # Nil-safe, because the table and its columns come from migrations the host
23
+ # runs: an app without them records nothing, checks nothing and serves as
24
+ # before.
14
25
  class UserSession < ApplicationRecord
15
26
  self.table_name = "studio_user_sessions"
16
27
 
17
28
  IDLE = 30.minutes
18
29
  THROTTLE = 5.minutes
30
+ # A device session idle this long has its rows pruned, so its cookie no longer
31
+ # signs in: the user signs in again.
32
+ RETENTION = 90.days
33
+ PRUNE_EVERY = 1.hour
34
+ PRUNE_BATCH = 1_000
35
+ # A failed verdict writes at most one ErrorLog row per this window per
36
+ # process: a database fault fails every request, and the log must not add a
37
+ # write to each of them.
38
+ CHECK_FAILURE_LOG_EVERY = 5.minutes
19
39
 
20
40
  belongs_to :user, class_name: "::User", optional: true
21
41
 
@@ -27,50 +47,225 @@ module Studio
27
47
  def self.cookie_key = :"#{Studio.session_key}_session_mark"
28
48
 
29
49
  # Off under test unless a test asks: the row a first request writes would count in
30
-
31
50
  # every host's query-budget test, which measures the page and not this.
32
-
33
51
  cattr_accessor :record_in_test, default: false
34
52
 
53
+ # A signed-in cookie with no sid predates this change. Until this moment it is
54
+ # accepted and given a sid on its request, so the deploy signs nobody out;
55
+ # after it, such a cookie is signed out once. The bound matters: a copy of a
56
+ # pre-deploy cookie carries no sid, so without it that copy would stay
57
+ # replayable after the device logs out.
58
+ cattr_accessor :legacy_grace_until, default: Time.utc(2026, 12, 8)
35
59
 
36
- def self.recording?
60
+ # When this process last pruned (prune_due!). Per process, so it costs nothing.
61
+ cattr_accessor :pruned_at, default: nil
37
62
 
38
- !Rails.env.test? || record_in_test
63
+ # When this process last wrote an ErrorLog row for a failed verdict
64
+ # (log_check_failure). Per process, like pruned_at.
65
+ cattr_accessor :check_failure_logged_at, default: nil
39
66
 
67
+ def self.recording?
68
+ !Rails.env.test? || record_in_test
40
69
  end
41
70
 
42
-
43
71
  def self.available?
44
72
  connection.data_source_exists?(table_name)
45
73
  rescue ActiveRecord::ActiveRecordError, NoMethodError
46
74
  false
47
75
  end
48
76
 
77
+ # The table carries the sid and revoked_at columns. The schema cache and the
78
+ # model's column cache answer it, so a request pays no query for it.
79
+ def self.revocable?
80
+ return false unless connection.schema_cache.data_source_exists?(table_name)
81
+
82
+ names = column_names
83
+ names.include?("sid") && names.include?("revoked_at")
84
+ rescue ActiveRecord::ActiveRecordError, NoMethodError
85
+ false
86
+ end
87
+
88
+ # This app's mark in `store` (the session, or a plain Hash decoded from the
89
+ # cookie, as a cable connection reads it), as a string-keyed Hash.
90
+ def self.mark_in(store)
91
+ mark = store[cookie_key]
92
+ mark = store[cookie_key.to_s] if mark.nil? && store.is_a?(Hash)
93
+ mark.respond_to?(:to_h) ? mark.to_h.transform_keys(&:to_s) : {}
94
+ end
95
+
96
+ def self.new_sid = SecureRandom.urlsafe_base64(24)
97
+
98
+ # The verdict on a signed-in request, read before anything else runs. Answers
99
+ # [verdict, row]:
100
+ #
101
+ # :live the sid names a row that is not revoked (row is that row)
102
+ # :ended any row of the sid is revoked, or the sid has no row (pruned,
103
+ # or never written); also a sid-less cookie past legacy_grace_until
104
+ # :legacy no sid, inside the grace: accepted, and track gives it one
105
+ # :off nothing to check (not recording, or no columns)
106
+ #
107
+ # One indexed query when the cookie carries a sid, none otherwise. A failure
108
+ # answers :off, so a database fault signs nobody out: the request's own user
109
+ # lookup needs the same database, and a copied cookie cannot cause the fault.
110
+ def self.verdict(store, now: Time.current)
111
+ return [:off, nil] unless recording? && revocable?
112
+
113
+ sid = mark_in(store)["sid"].presence
114
+ return [now < legacy_grace_until ? :legacy : :ended, nil] unless sid
115
+
116
+ # A revoked row sorts first: a row a request in flight wrote after the
117
+ # logout cannot bring the sid back.
118
+ row = where(sid: sid.to_s).order(Arel.sql("revoked_at IS NULL"), id: :desc).first
119
+ return [:ended, nil] if row.nil? || row.revoked_at
120
+
121
+ [:live, row]
122
+ rescue StandardError => e
123
+ Rails.logger&.warn("[user_session] verdict #{e.class}: #{e.message}")
124
+ log_check_failure(e, now)
125
+ [:off, nil]
126
+ end
127
+
128
+ # True when `store` names an ended device session (verdict :ended). The one
129
+ # check a host's ApplicationCable::Connection makes, since /cable reads the
130
+ # cookie outside the controller filter (docs/DEVICE_SESSIONS.md):
131
+ #
132
+ # return nil if Studio::UserSession.respond_to?(:ended?) && Studio::UserSession.ended?(session)
133
+ #
134
+ # `store` is request.session or the Hash decoded from the cookie. :legacy and
135
+ # :off answer false, as they do for a page request. It never writes `store`.
136
+ def self.ended?(store, now: Time.current)
137
+ verdict(store, now: now).first == :ended
138
+ end
139
+
140
+ # Writes the verdict's failure to ErrorLog, at most once per
141
+ # CHECK_FAILURE_LOG_EVERY per process. The stamp is set before the write, so a
142
+ # write that fails too (the database is down) is not retried on every request.
143
+ # Never raises.
144
+ def self.log_check_failure(exception, now = Time.current)
145
+ return if check_failure_logged_at && check_failure_logged_at > now - CHECK_FAILURE_LOG_EVERY
146
+
147
+ self.check_failure_logged_at = now
148
+ ::ErrorLog.capture!(exception) if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
149
+ rescue StandardError => e
150
+ Rails.logger&.warn("[user_session] check failure not logged: #{e.class}: #{e.message}")
151
+ nil
152
+ end
153
+
154
+ # Starts this device's session at a sign-in (ErrorHandling#set_app_session):
155
+ # writes its first row with a new sid and marks `store`, so the very next
156
+ # request is checked. A sign-in by the same user over a live sid keeps it: a
157
+ # re-auth is the same device session. The sid is read again first (one indexed
158
+ # query): a revoked sid, or one with no row, is never reused, so a sign-in on
159
+ # a copied cookie cannot bring an ended session back. A different user gets a
160
+ # new sid; the old one is not revoked, since a sign-in is not a logout. Answers
161
+ # the row, or nil. It raises on a failed write, so the caller can log it.
162
+ def self.start!(store, user, now: Time.current)
163
+ return unless user && recording? && revocable?
164
+
165
+ mark = mark_in(store)
166
+ return if mark["user"] == user.id && live_sid?(mark["sid"], user)
167
+
168
+ admin = user.respond_to?(:admin?) && user.admin?
169
+ row = create!(sid: new_sid, user_id: user.id, admin: admin ? true : false, started_at: now, last_seen_at: now)
170
+ store[cookie_key] = { "id" => row.id, "user" => user.id, "at" => now.to_i, "sid" => row.sid }
171
+ prune_due!(now)
172
+ row
173
+ end
174
+
175
+ # Ends this device's session: stamps revoked_at on every row of its sid and
176
+ # drops the mark from `store`. Other sids (other devices, and other apps on a
177
+ # shared cookie) are untouched. Answers the rows it revoked. It raises on a
178
+ # failed write, so the caller can log it (ErrorHandling#end_device_session).
179
+ def self.revoke!(store, now: Time.current)
180
+ sid = mark_in(store)["sid"].presence
181
+ store.delete(cookie_key)
182
+ return 0 unless sid && recording? && revocable?
183
+
184
+ where(sid: sid.to_s, revoked_at: nil).update_all(revoked_at: now, updated_at: now)
185
+ end
186
+
49
187
  # Records this request for `user`, reading and writing `store` (the session).
50
- # Answers the row it wrote, or nil. Never raises: a request must not fail
51
- # because its bookkeeping did.
52
- def self.track(store, user, now: Time.current)
188
+ # `row` is the row verdict read for this request, so tracking adds no read of
189
+ # its own. Answers the row it wrote, or nil. Never raises: a request must not
190
+ # fail because its bookkeeping did.
191
+ def self.track(store, user, now: Time.current, row: nil)
53
192
  return if user.nil? || !recording?
54
193
 
55
- mark = store[cookie_key]
56
- mark = mark.to_h.transform_keys(&:to_s) if mark.respond_to?(:to_h)
57
- same_user = mark.is_a?(Hash) && mark["user"] == user.id
58
- seen = same_user ? Time.zone.at(mark["at"].to_i) : nil
59
- return if seen && seen > now - THROTTLE
194
+ mark = mark_in(store)
195
+ same_user = mark["user"] == user.id
196
+ seen = same_user && mark["at"] ? Time.zone.at(mark["at"].to_i) : nil
197
+ revocable = revocable?
198
+ sid = same_user ? mark["sid"].presence : nil
199
+ # A sid-less cookie does not wait out the throttle: it gets its sid now.
200
+ return if seen && seen > now - THROTTLE && (sid || !revocable)
60
201
  return unless available?
61
202
 
62
- row = same_user && seen > now - IDLE ? find_by(id: mark["id"], user_id: user.id) : nil
203
+ row = revocable && sid ? row_for_sid(sid, user, now, row) : legacy_row(mark, same_user, seen, user, now, revocable)
63
204
  if row
64
- row.update_columns(last_seen_at: now)
205
+ changes = { last_seen_at: now }
206
+ changes[:sid] = sid = new_sid if revocable && sid.nil?
207
+ row.update_columns(changes)
65
208
  else
209
+ sid ||= new_sid if revocable
66
210
  admin = user.respond_to?(:admin?) && user.admin?
67
- row = create!(user_id: user.id, admin: admin ? true : false, started_at: now, last_seen_at: now)
211
+ attrs = { user_id: user.id, admin: admin ? true : false, started_at: now, last_seen_at: now }
212
+ attrs[:sid] = sid if revocable
213
+ row = create!(attrs)
214
+ prune_due!(now)
68
215
  end
69
- store[cookie_key] = { "id" => row.id, "user" => user.id, "at" => now.to_i }
216
+ store[cookie_key] = { "id" => row.id, "user" => user.id, "at" => now.to_i }.tap { |m| m["sid"] = sid if revocable }
70
217
  row
71
218
  rescue StandardError => e
72
219
  Rails.logger&.warn("[user_session] #{e.class}: #{e.message}")
73
220
  nil
74
221
  end
222
+
223
+ # True when `sid` has rows, all of them unrevoked, and the latest is `user`'s.
224
+ # One query on the sid index; a revoked row sorts first, as in verdict.
225
+ def self.live_sid?(sid, user)
226
+ return false if sid.blank?
227
+
228
+ row = where(sid: sid.to_s).order(Arel.sql("revoked_at IS NULL"), id: :desc).first
229
+ !row.nil? && row.revoked_at.nil? && row.user_id == user.id
230
+ end
231
+ private_class_method :live_sid?
232
+
233
+ # The open row to move for this sid: the one verdict read, else the latest by
234
+ # sid. Past IDLE nil, so a new row starts with the same sid.
235
+ def self.row_for_sid(sid, user, now, row)
236
+ row = nil unless row && row.sid == sid
237
+ row ||= where(sid: sid).order(id: :desc).first
238
+ row if row && row.user_id == user.id && row.revoked_at.nil? && row.last_seen_at > now - IDLE
239
+ end
240
+ private_class_method :row_for_sid
241
+
242
+ # The row a sid-less mark names (a cookie from before sids, or before this
243
+ # app's columns), inside IDLE. Under sids only a row with no sid of its own
244
+ # qualifies: it takes the cookie's new sid.
245
+ def self.legacy_row(mark, same_user, seen, user, now, revocable)
246
+ return unless same_user && seen && seen > now - IDLE
247
+
248
+ row = find_by(id: mark["id"], user_id: user.id)
249
+ row unless revocable && row&.sid.present?
250
+ end
251
+ private_class_method :legacy_row
252
+
253
+ # Deletes up to `batch` rows last seen before `before`. One statement on the
254
+ # last_seen_at index.
255
+ def self.prune!(now: Time.current, before: now - RETENTION, batch: PRUNE_BATCH)
256
+ where(id: where(arel_table[:last_seen_at].lt(before)).order(:id).limit(batch).select(:id)).delete_all
257
+ end
258
+
259
+ # Prunes at most once per PRUNE_EVERY per process, from the moment a row is
260
+ # created (a sign-in or a return after IDLE), so no host has to schedule a job.
261
+ def self.prune_due!(now = Time.current)
262
+ return if pruned_at && pruned_at > now - PRUNE_EVERY
263
+
264
+ self.pruned_at = now
265
+ prune!(now: now)
266
+ rescue StandardError => e
267
+ Rails.logger&.warn("[user_session] prune #{e.class}: #{e.message}")
268
+ nil
269
+ end
75
270
  end
76
271
  end