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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/README.md +3 -0
- data/app/assets/javascripts/studio/session.js +647 -0
- data/app/controllers/concerns/studio/error_handling.rb +10 -3
- data/app/controllers/concerns/studio/session_drift.rb +99 -0
- data/app/controllers/studio/session_states_controller.rb +43 -0
- data/app/models/session_context.rb +32 -0
- data/app/views/layouts/studio/_flash.html.erb +3 -1
- data/app/views/layouts/studio/_head.html.erb +3 -0
- data/app/views/studio/_session_stamp.html.erb +32 -0
- data/app/views/studio/modals/blocks/_success_card.html.erb +1 -1
- data/lib/studio/engine.rb +1 -0
- data/lib/studio/session_fingerprint.rb +109 -0
- data/lib/studio/session_state.rb +109 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +37 -0
- metadata +7 -1
|
@@ -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:
|
|
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:
|
|
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
|
@@ -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
|
data/lib/studio/version.rb
CHANGED
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.
|
|
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
|