studio-engine 0.74.12 → 0.75.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,99 @@
1
+ module Studio
2
+ # The server half of the session-drift primitive (docs/SESSION_DRIFT.md).
3
+ #
4
+ # Every page carries a STAMP: the session's state, its fingerprint, when it was
5
+ # issued and when it lapses, where to rehydrate it, and the identities the host
6
+ # has bound it to. The browser store (app/assets/javascripts/studio/session.js)
7
+ # reads the stamp, watches for drift, and rehydrates through
8
+ # Studio::SessionStatesController when the host draws that route.
9
+ #
10
+ # Included BY Studio::ErrorHandling, so every consumer that includes that
11
+ # concern has the stamp on every page with no wiring of its own. It adds
12
+ # helper methods only; it registers no filter and changes no response.
13
+ #
14
+ # THE HOST'S TWO HOOKS
15
+ #
16
+ # studio_session_identities — { source name => bound identity }. Baseline {}.
17
+ # A host that binds its session to an identity the engine knows nothing
18
+ # about (an external account, a device) overrides this, and a browser
19
+ # identity source registered under the same name observes it. The engine
20
+ # compares the two strings and never interprets them.
21
+ #
22
+ # client_session_payload (Studio::ErrorHandling) — the host's own page payload.
23
+ # The rehydrate endpoint returns it as `context` beside the stamp, so a host
24
+ # store hydrated from it can be refreshed from the same response.
25
+ module SessionDrift
26
+ extend ActiveSupport::Concern
27
+
28
+ included do
29
+ helper_method :studio_session_state, :studio_session_stamp, :studio_session_page_stamp,
30
+ :studio_session_identities, :studio_session_rehydrate_url
31
+ end
32
+
33
+ private
34
+
35
+ # The request's Studio::SessionState, built from the viewer alone: the state
36
+ # and the fingerprint depend on nothing else.
37
+ def studio_session_state
38
+ @studio_session_state ||= Studio::SessionState.new(current_user)
39
+ end
40
+
41
+ # The stamp for this request. Raises like any other controller code; the
42
+ # rehydrate endpoint relies on that so a failure reaches the error handler.
43
+ def studio_session_stamp
44
+ studio_session_state.to_stamp(
45
+ rehydrate_url: studio_session_rehydrate_url,
46
+ expires_at: studio_session_expires_at,
47
+ identities: studio_session_identities
48
+ )
49
+ end
50
+
51
+ # The stamp as the PAGE renders it. A page must never fail because its
52
+ # session decoration did, so in production a failure is logged to ErrorLog
53
+ # and the page renders without a stamp — the browser store then stays
54
+ # dormant, which is exactly how every page behaved before this existed.
55
+ # Development and test re-raise, mirroring handle_unexpected_error, so a
56
+ # broken stamp fails a consumer's suite instead of hiding in it.
57
+ def studio_session_page_stamp
58
+ studio_session_stamp
59
+ rescue StandardError => e
60
+ raise if defined?(::Rails) && ::Rails.respond_to?(:env) && (::Rails.env.development? || ::Rails.env.test?)
61
+
62
+ begin
63
+ ErrorLog.capture!(e)
64
+ rescue StandardError
65
+ nil
66
+ end
67
+ nil
68
+ end
69
+
70
+ # Host hook — see the module comment. Baseline: no bound identities.
71
+ def studio_session_identities
72
+ {}
73
+ end
74
+
75
+ # Where the browser store rehydrates, or nil when the host has not drawn the
76
+ # route (Studio.draw_session_routes). Without it the store still detects
77
+ # drift; it just cannot repair the page in place.
78
+ def studio_session_rehydrate_url
79
+ return nil unless Studio.draw_session_routes
80
+ return nil unless respond_to?(:studio_session_state_path, true)
81
+
82
+ studio_session_state_path
83
+ end
84
+
85
+ # When this session lapses, if the session store says. Rails' cookie store
86
+ # re-issues the cookie on every response, so a session with `expire_after`
87
+ # lapses that long after THIS response — which is what the stamp records.
88
+ # nil (the Rails default: a browser-session cookie) means no expiry source.
89
+ def studio_session_expires_at
90
+ return nil unless request.respond_to?(:session_options)
91
+
92
+ expire_after = request.session_options[:expire_after]
93
+ return nil unless expire_after.is_a?(Numeric) || expire_after.is_a?(ActiveSupport::Duration)
94
+ return nil unless expire_after.to_i.positive?
95
+
96
+ Time.now + expire_after.to_i
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,43 @@
1
+ module Studio
2
+ # GET /session/state — the rehydrate endpoint of the session-drift primitive
3
+ # (docs/SESSION_DRIFT.md). Drawn only when the host sets
4
+ # Studio.draw_session_routes; the browser store learns the URL from the page
5
+ # stamp and never guesses it.
6
+ #
7
+ # It answers "what is this browser's session NOW?" with three things:
8
+ #
9
+ # session — the same stamp a page render carries (Studio::SessionDrift), so
10
+ # the store compares like with like;
11
+ # context — the host's client_session_payload, so a host store hydrated from
12
+ # that payload refreshes from the same response;
13
+ # csrf — a fresh authenticity token. Signing in or out resets the Rails
14
+ # session, which invalidates every token a stale page holds; the
15
+ # store swaps it into the csrf-token meta. That repairs requests
16
+ # that read the meta (Turbo, fetch with X-CSRF-Token). It does NOT
17
+ # rewrite the hidden authenticity_token input of a form already on
18
+ # the page, so a data-turbo="false" form rendered before the reset
19
+ # still posts the old token.
20
+ #
21
+ # ANONYMOUS IS AN ANSWER, NOT A FAILURE. The action skips the host's
22
+ # require_authentication: a signed-out browser gets a 200 describing an
23
+ # anonymous session. A host filter that REVOKES a session (verify_session_token
24
+ # answers JSON with a 401) is still honoured, and the store reads that 401 as a
25
+ # revocation.
26
+ #
27
+ # Read-only: no writes, and Cache-Control: no-store so no proxy or browser cache
28
+ # ever answers for a different session.
29
+ class SessionStatesController < ::ApplicationController
30
+ skip_before_action :require_authentication, raise: false
31
+
32
+ def show
33
+ rescue_and_log(target: current_user) do
34
+ response.headers["Cache-Control"] = "no-store"
35
+ render json: {
36
+ session: studio_session_stamp,
37
+ context: respond_to?(:client_session_payload, true) ? client_session_payload : nil,
38
+ csrf: form_authenticity_token
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -23,6 +23,38 @@ class SessionContext
23
23
 
24
24
  attr_reader :user
25
25
 
26
+ # ---- Session state (docs/SESSION_DRIFT.md) --------------------------------
27
+ # The generic session-state primitive, delegated to Studio::SessionState, which
28
+ # reads the viewer and nothing else. This block only exposes it here; the
29
+ # legacy payload below (#to_h) is unchanged and carries none of it.
30
+ STATES = Studio::SessionState::STATES
31
+ SERVER_STATES = Studio::SessionState::SERVER_STATES
32
+
33
+ def session_state
34
+ @session_state ||= Studio::SessionState.new(user)
35
+ end
36
+
37
+ def state
38
+ session_state.state
39
+ end
40
+
41
+ def anonymous?
42
+ session_state.anonymous?
43
+ end
44
+
45
+ def authenticated?
46
+ session_state.authenticated?
47
+ end
48
+
49
+ def fingerprint(identities = {})
50
+ session_state.fingerprint(identities)
51
+ end
52
+
53
+ def to_stamp(**options)
54
+ session_state.to_stamp(**options)
55
+ end
56
+ # ---- end session state ----------------------------------------------------
57
+
26
58
  def initialize(user:, onchain_session:)
27
59
  @user = user
28
60
  @onchain_session = onchain_session
@@ -54,8 +54,10 @@
54
54
  .toast-blur-glow {
55
55
  background: rgba(0, 0, 0, 0.06);
56
56
  }
57
+ /* Slash form only: the theme's -rgb vars are space lists, so the legacy
58
+ comma form is invalid and painted no halo (legacy_rgba_var_guard_test.rb). */
57
59
  .dark .toast-blur-glow {
58
- background: rgba(var(--color-primary-500-rgb), 0.12);
60
+ background: rgb(var(--color-primary-500-rgb) / 0.12);
59
61
  }
60
62
  .toast-wrapper {
61
63
  position: relative;
@@ -10,6 +10,9 @@
10
10
  <meta name="apple-mobile-web-app-capable" content="yes">
11
11
  <%= csrf_meta_tags %>
12
12
  <%= csp_meta_tag %>
13
+ <%# The session-drift stamp + browser store. Renders nothing unless the controller
14
+ includes Studio::ErrorHandling. See studio/_session_stamp. %>
15
+ <%= render "studio/session_stamp" %>
13
16
  <%= render "layouts/studio/smooth_load" %>
14
17
  <link rel="icon" type="image/png" href="/favicon.png">
15
18
 
@@ -0,0 +1,32 @@
1
+ <%# The session-drift stamp (docs/SESSION_DRIFT.md), rendered by
2
+ layouts/studio/_head on every page.
3
+
4
+ It emits two things: a meta tag named studio-session carrying the stamp as
5
+ JSON, and the browser store that reads it (studio/session.js, which publishes
6
+ window.StudioSession). The store is NOT deferred: it has to register its
7
+ alpine:init listener before the deferred Alpine script runs.
8
+
9
+ A META TAG, NOT A JSON SCRIPT TAG, AND THAT IS LOAD-BEARING. Turbo merges the
10
+ head on every navigation. Script elements are copied in and never removed, so
11
+ a JSON script tag would pile up one copy per visit and the first copy, the
12
+ stale one, is the one a lookup by selector returns. Meta tags are replaced, so
13
+ the head always holds exactly the stamp of the page on screen.
14
+
15
+ GUARDED ON THE HELPER. studio_session_page_stamp comes from
16
+ Studio::SessionDrift, which Studio::ErrorHandling includes. A head rendered
17
+ anywhere else (the e2e lab, a bare view in a unit test, a host controller that
18
+ does not include the concern) emits nothing here, byte-identical to the head
19
+ before this partial existed.
20
+
21
+ THE SCRIPT DOES NOT DEPEND ON THE STAMP. A nil stamp (a production failure,
22
+ logged to ErrorLog) omits the meta tag and still loads the store, which then
23
+ stays dormant. The script is Turbo-tracked like every engine asset, and Turbo
24
+ fully reloads when two pages' tracked scripts differ, so a page whose stamp
25
+ failed must not also drop the script and turn the next click into a reload. %>
26
+ <% if respond_to?(:studio_session_page_stamp) %>
27
+ <% studio_session_stamp_value = studio_session_page_stamp %>
28
+ <% if studio_session_stamp_value %>
29
+ <%= tag.meta(name: "studio-session", content: studio_session_stamp_value.to_json) %>
30
+ <% end %>
31
+ <%= javascript_include_tag "studio/session", "data-turbo-track": "reload" %>
32
+ <% end %>
@@ -184,7 +184,7 @@
184
184
  <a :href="'https://explorer.solana.com/tx/' + (<%= tx_signature_key %>) + (typeof clusterParam !== 'undefined' ? clusterParam : '')"
185
185
  target="_blank" rel="noopener"
186
186
  class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border border-subtle hover:border-primary/50 transition group"
187
- style="background: rgba(var(--color-primary-500-rgb), 0.06);">
187
+ style="background: rgb(var(--color-primary-500-rgb) / 0.06);">
188
188
  <svg width="14" height="11" viewBox="0 0 397 311" fill="none">
189
189
  <defs>
190
190
  <linearGradient id="studio-solana-grad" x1="361" y1="-9" x2="153" y2="389" gradientUnits="userSpaceOnUse">
data/lib/studio/engine.rb CHANGED
@@ -47,6 +47,7 @@ module Studio
47
47
  studio/canvas_confetti.js
48
48
  studio/studio_confetti.js
49
49
  studio/sortable.js
50
+ studio/session.js
50
51
  studio/montserrat-latin.woff2
51
52
  studio/montserrat-latin-ext.woff2
52
53
  ]
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module Studio
7
+ # The session fingerprint: a short, opaque answer to "which signed-in session
8
+ # rendered this page?" It is stamped on every page (Studio::SessionState#to_stamp)
9
+ # and returned by the rehydrate endpoint, so two tabs, or one tab before and
10
+ # after it went to the background, can tell whether they still describe the
11
+ # same session WITHOUT exposing anything that identifies it.
12
+ #
13
+ # WHAT FEEDS IT, and why each part is there:
14
+ #
15
+ # * the user's id — a different account is a different session;
16
+ # * the user's session_token, when the host has that column
17
+ # (docs/USER_CONTRACT.md). Studio::ErrorHandling binds it into the cookie
18
+ # and a host rotates it to log a user out everywhere, so a rotation changes
19
+ # the fingerprint and a page rendered before it can see it was revoked;
20
+ # * the identities the host has bound the session to (Studio::SessionState#to_stamp's
21
+ # `identities:`). A host can re-bind a session to a different identity
22
+ # without the account or its token changing. Folding the bindings in means
23
+ # that re-bind changes the fingerprint too, so every other tab learns about
24
+ # it the same way it learns about a sign-out.
25
+ #
26
+ # All of it is keyed through an HMAC, so the fingerprint never carries the token
27
+ # (a bearer secret bound into the cookie) or the id in a readable form, and it
28
+ # cannot be recomputed by anyone without the app's secret.
29
+ #
30
+ # "anonymous" is deliberately a plain constant, not a digest. There is no
31
+ # identity to protect, and every signed-out tab SHOULD agree with every other
32
+ # signed-out tab: anonymous is a first-class state, not a missing value.
33
+ #
34
+ # Pure Ruby (no ActiveRecord), so it unit-tests without a database.
35
+ module SessionFingerprint
36
+ ANONYMOUS = "anonymous"
37
+
38
+ # 32 hex characters = 128 bits of the HMAC: two sessions never collide by
39
+ # accident, and it is still short enough to read in a log line.
40
+ LENGTH = 32
41
+
42
+ # The key_generator purpose. Changing it changes every fingerprint in the
43
+ # fleet at once, which reads to every open tab as "your session changed".
44
+ KEY_PURPOSE = "studio/session-fingerprint"
45
+
46
+ class MissingSecret < StandardError; end
47
+
48
+ module_function
49
+
50
+ # The fingerprint for a user (or nil) and the identities bound to the
51
+ # session. `secret:` exists for unit tests; everything else resolves it.
52
+ def for(user, identities: {}, secret: nil)
53
+ bindings = bindings(identities)
54
+ return ANONYMOUS if user.nil? && bindings.empty?
55
+
56
+ digest(material(user, bindings), secret: secret || resolve_secret)
57
+ end
58
+
59
+ def digest(material, secret:)
60
+ raise MissingSecret, "a session fingerprint needs a secret" if secret.nil? || secret.to_s.empty?
61
+
62
+ OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, material.to_s)[0, LENGTH]
63
+ end
64
+
65
+ # JSON, not a joined string: a host's identity names and values are
66
+ # arbitrary text, and no separator is safe against arbitrary text. A host
67
+ # with no session_token column still gets a per-account fingerprint; it simply
68
+ # cannot express "revoked" through it.
69
+ #
70
+ # Every part is a String (or nil) before it is encoded. Handing JSON an
71
+ # arbitrary object would route it through ActiveSupport's as_json, which walks
72
+ # instance variables — a test double or a decorated user could recurse there.
73
+ def material(user, bindings = [])
74
+ id = user.respond_to?(:id) ? user.id : user
75
+ token = user.respond_to?(:session_token) ? user.session_token : nil
76
+ JSON.generate(["studio-session", id&.to_s, token&.to_s, bindings])
77
+ end
78
+
79
+ # Sorted pairs, so the same bindings produce the same fingerprint whatever
80
+ # order the host built its hash in. A blank value is not a binding.
81
+ def bindings(identities)
82
+ (identities || {})
83
+ .map { |name, value| [name.to_s, value.to_s] }
84
+ .reject { |_, value| value.empty? }
85
+ .sort
86
+ end
87
+
88
+ # The explicit Studio.session_fingerprint_secret, else a key derived from the
89
+ # host app's secret_key_base. Every process of one app derives the SAME key,
90
+ # which is the property that matters: a fingerprint that differed per dyno
91
+ # would report drift on every request that landed on another one.
92
+ #
93
+ # `::Rails.respond_to?(:application)`, not `defined?(Rails)`: a gem can define
94
+ # a bare Rails namespace without an application behind it.
95
+ def resolve_secret
96
+ explicit = Studio.respond_to?(:session_fingerprint_secret) ? Studio.session_fingerprint_secret : nil
97
+ return explicit unless explicit.nil? || explicit.to_s.empty?
98
+
99
+ if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application &&
100
+ ::Rails.application.respond_to?(:key_generator)
101
+ return ::Rails.application.key_generator.generate_key(KEY_PURPOSE, 32)
102
+ end
103
+
104
+ raise MissingSecret,
105
+ "Studio::SessionFingerprint has no secret: set Studio.session_fingerprint_secret, " \
106
+ "or boot inside a Rails application (it derives one from secret_key_base)."
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The session's state, as a page is stamped with it (docs/SESSION_DRIFT.md).
5
+ #
6
+ # It answers one question: which session rendered this page, and is anyone
7
+ # signed in? `state` is :anonymous or :authenticated, `fingerprint`
8
+ # (Studio::SessionFingerprint) names the session without exposing it, and
9
+ # `to_stamp` is the JSON every page carries so the browser store
10
+ # (app/assets/javascripts/studio/session.js) can notice when the session
11
+ # changes underneath a page.
12
+ #
13
+ # It reads the viewer and nothing else. What an identity IS stays outside: a
14
+ # host that binds the session to something beyond the account passes it in as
15
+ # `identities:`, keyed by the name of the browser identity source that observes
16
+ # it, and this class only ever compares strings.
17
+ #
18
+ # Built per request by Studio::SessionDrift#studio_session_state, and exposed on
19
+ # SessionContext through delegators. Pure Ruby, so it unit-tests without Rails.
20
+ class SessionState
21
+ # The session lifecycle, in the order a page can move through it:
22
+ # anonymous — nobody is signed in. A first-class state, not an error:
23
+ # a pre-auth page is a real page with a real session.
24
+ # authenticated — somebody is signed in and the page still describes them.
25
+ # stale — the page learned its stamp is out of date (another tab
26
+ # changed the session, it expired, the server probe
27
+ # disagreed) and a rehydrate is due.
28
+ # changed — an identity source observes a different identity than the
29
+ # one the session is bound to.
30
+ # rehydrated — the page pulled the server's current session in place and
31
+ # is signed in (possibly as someone new).
32
+ # signed_out — the page was signed in and the server now reports nobody.
33
+ STATES = %i[anonymous authenticated stale changed rehydrated signed_out].freeze
34
+
35
+ # The only states a server render can report. The other four exist only in a
36
+ # browser that is comparing an old page against a newer truth.
37
+ SERVER_STATES = %i[anonymous authenticated].freeze
38
+
39
+ # Bumped only when a stamp field changes meaning. Additive fields do not bump it.
40
+ STAMP_VERSION = 1
41
+
42
+ attr_reader :user
43
+
44
+ def initialize(user)
45
+ @user = user
46
+ end
47
+
48
+ def state
49
+ user ? :authenticated : :anonymous
50
+ end
51
+
52
+ def anonymous?
53
+ state == :anonymous
54
+ end
55
+
56
+ def authenticated?
57
+ state == :authenticated
58
+ end
59
+
60
+ # The session's fingerprint. The stamp passes its own normalized identities,
61
+ # so a page render and the rehydrate endpoint always agree for one session.
62
+ def fingerprint(identities = {})
63
+ Studio::SessionFingerprint.for(user, identities: identities)
64
+ end
65
+
66
+ # The JSON-ready stamp a page carries and the rehydrate endpoint returns.
67
+ #
68
+ # rehydrate_url — where the browser store refetches this stamp; nil when the
69
+ # host does not draw the route, which leaves the store able to
70
+ # DETECT drift but not repair it in place.
71
+ # expires_at — when this session lapses, if the host's session store says
72
+ # so (a Time, or nil). The store's expiry source fires then.
73
+ # identities — { source name => bound identity string } for host-declared
74
+ # identity sources. Keys and values are stringified; a blank
75
+ # value is dropped rather than sent as "" so an unbound source
76
+ # stays unbound.
77
+ # issued_at — when the server built this stamp. Tabs compare it to decide
78
+ # whose truth is newer.
79
+ #
80
+ # Keys are camelCase because the browser reads them; times are epoch
81
+ # milliseconds so no client has to parse a date string.
82
+ def to_stamp(rehydrate_url: nil, expires_at: nil, identities: {}, issued_at: Time.now)
83
+ bound = normalize_identities(identities)
84
+ {
85
+ v: STAMP_VERSION,
86
+ state: state.to_s,
87
+ fingerprint: fingerprint(bound),
88
+ issuedAt: epoch_ms(issued_at),
89
+ expiresAt: expires_at && epoch_ms(expires_at),
90
+ rehydrateUrl: rehydrate_url.to_s.empty? ? nil : rehydrate_url.to_s,
91
+ identities: bound
92
+ }
93
+ end
94
+
95
+ private
96
+
97
+ def epoch_ms(time)
98
+ (time.to_r * 1000).to_i
99
+ end
100
+
101
+ def normalize_identities(identities)
102
+ (identities || {}).each_with_object({}) do |(name, value), out|
103
+ next if value.nil? || value.to_s.empty?
104
+
105
+ out[name.to_s] = value.to_s
106
+ end
107
+ end
108
+ end
109
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.74.12"
2
+ VERSION = "0.75.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -22,6 +22,8 @@ require "studio/s3"
22
22
  require "studio/image_cache"
23
23
  require "studio/link_token"
24
24
  require "studio/link_resolution"
25
+ require "studio/session_fingerprint"
26
+ require "studio/session_state"
25
27
  require "studio/email"
26
28
  require "studio/email_smoke"
27
29
  require "studio/mail_transport"
@@ -270,6 +272,33 @@ module Studio
270
272
  # its own routes (it can still reuse Studio::Link + Studio::LinkConsumption).
271
273
  mattr_accessor :draw_link_routes, default: true
272
274
 
275
+ # Draw the session-drift rehydrate endpoint, GET /session/state
276
+ # (Studio::SessionStatesController, helper studio_session_state_path). See
277
+ # docs/SESSION_DRIFT.md.
278
+ #
279
+ # OFF by default, and not because of a name collision (no consumer owns the
280
+ # path or the helper, checked 2026-09-16). The endpoint is a JSON GET that
281
+ # inherits the HOST's ApplicationController filters, and the browser store calls
282
+ # it whenever a tab returns from the background or learns another tab changed
283
+ # the session. A host must look at its own filters before that traffic starts:
284
+ # one that redirects every request to an onboarding page would answer the store
285
+ # with HTML. turf-monster already runs its own copy of this loop
286
+ # (/account/session_state), so drawing it there by default would double the
287
+ # requests. Each app's adoption turns it on:
288
+ #
289
+ # config.draw_session_routes = true
290
+ #
291
+ # With it off, every page still carries the stamp and the store still detects
292
+ # drift (another tab, expiry). It cannot repair a page in place, so it stays
293
+ # `stale` and the host decides what to do.
294
+ mattr_accessor :draw_session_routes, default: false
295
+
296
+ # The key Studio::SessionFingerprint signs with. nil (the default) derives one
297
+ # from the app's secret_key_base via Rails.application.key_generator, which
298
+ # every process of the app agrees on. Set it only to share fingerprints across
299
+ # two apps on one origin or to pin a value in a test.
300
+ mattr_accessor :session_fingerprint_secret, default: nil
301
+
273
302
  # Draw the shared transactional-email page at /admin/emails
274
303
  # (Studio::EmailsController). OFF by default because the path AND its helper
275
304
  # names (admin_emails_path / admin_email_path) are already taken in
@@ -887,6 +916,14 @@ module Studio
887
916
  constraints: { token: %r{[^/]+} }
888
917
  end
889
918
 
919
+ # The session-drift rehydrate endpoint (Studio::SessionStatesController).
920
+ # OPT-IN — see Studio.draw_session_routes. JSON only: the browser store is
921
+ # its one caller, and an HTML answer would only ever be a mistake.
922
+ if Studio.draw_session_routes
923
+ get "session/state", to: "studio/session_states#show", as: :studio_session_state,
924
+ defaults: { format: :json }
925
+ end
926
+
890
927
  # Knowledge layer — /admin/knowledge (Studio::KnowledgeDoc): folder/flat
891
928
  # document browser, upload-to-inbox intake, per-agent access map, and
892
929
  # 15-minute presigned downloads. Opt-in (default off) like every route
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.74.12
4
+ version: 0.75.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -303,6 +303,7 @@ files:
303
303
  - app/assets/images/state-flags/wy.svg
304
304
  - app/assets/javascripts/studio/alpine.js
305
305
  - app/assets/javascripts/studio/canvas_confetti.js
306
+ - app/assets/javascripts/studio/session.js
306
307
  - app/assets/javascripts/studio/sortable.js
307
308
  - app/assets/javascripts/studio/sticky_table_header.js
308
309
  - app/assets/javascripts/studio/studio_confetti.js
@@ -317,6 +318,7 @@ files:
317
318
  - app/controllers/concerns/studio/impersonation.rb
318
319
  - app/controllers/concerns/studio/link_consumption.rb
319
320
  - app/controllers/concerns/studio/magic_link_issuing.rb
321
+ - app/controllers/concerns/studio/session_drift.rb
320
322
  - app/controllers/error_logs_controller.rb
321
323
  - app/controllers/magic_links_controller.rb
322
324
  - app/controllers/navbar_controller.rb
@@ -335,6 +337,7 @@ files:
335
337
  - app/controllers/studio/models_controller.rb
336
338
  - app/controllers/studio/onboarding_controller.rb
337
339
  - app/controllers/studio/profiles_controller.rb
340
+ - app/controllers/studio/session_states_controller.rb
338
341
  - app/controllers/style_controller.rb
339
342
  - app/controllers/theme_settings_controller.rb
340
343
  - app/helpers/studio/admin_models_table_helper.rb
@@ -413,6 +416,7 @@ files:
413
416
  - app/views/studio/_fizz_layer.html.erb
414
417
  - app/views/studio/_hold_button.html.erb
415
418
  - app/views/studio/_leveling_activity_assets.html.erb
419
+ - app/views/studio/_session_stamp.html.erb
416
420
  - app/views/studio/admin_models/_arenas_table.html.erb
417
421
  - app/views/studio/admin_models/_teams_table.html.erb
418
422
  - app/views/studio/admin_models/index.html.erb
@@ -575,6 +579,8 @@ files:
575
579
  - lib/studio/profile_sections.rb
576
580
  - lib/studio/redis.rb
577
581
  - lib/studio/s3.rb
582
+ - lib/studio/session_fingerprint.rb
583
+ - lib/studio/session_state.rb
578
584
  - lib/studio/sidebar_sections.rb
579
585
  - lib/studio/theme_resolver.rb
580
586
  - lib/studio/ui_primitives.rb