studio-engine 0.74.11 → 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 +103 -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/_host.html.erb +7 -2
- data/app/views/studio/modals/_scoped_host.html.erb +5 -2
- data/app/views/studio/modals/blocks/_card_header.html.erb +12 -6
- data/app/views/studio/modals/blocks/_success_card.html.erb +12 -7
- data/lib/studio/engine.rb +1 -0
- data/lib/studio/partial_block.rb +50 -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 +38 -0
- metadata +9 -2
|
@@ -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 %>
|
|
@@ -838,8 +838,13 @@
|
|
|
838
838
|
:class="$store.modals.cardClasses()">
|
|
839
839
|
<%# Consumer-provided content registrations. Each block typically
|
|
840
840
|
contains a <template x-if="$store.modals.current().id === 'X'">
|
|
841
|
-
render "modals/X" </template>.
|
|
842
|
-
|
|
841
|
+
render "modals/X" </template>.
|
|
842
|
+
NOT `yield if block_given?`. In a partial block_given? is always true,
|
|
843
|
+
and a caller-less yield returns the layout's content, so a host
|
|
844
|
+
rendered from a layout with no block (an app whose modals all live in
|
|
845
|
+
_host_extras) put the whole page body inside the card. See
|
|
846
|
+
Studio::PartialBlock. %>
|
|
847
|
+
<%= Studio::PartialBlock.content(self, yield) %>
|
|
843
848
|
|
|
844
849
|
<%# APP-WIDE modal registrations — the second seam. The block above is
|
|
845
850
|
per-CALLSITE: an app rendering this host from two layouts (a live one
|
|
@@ -364,8 +364,11 @@
|
|
|
364
364
|
@keydown.escape.window="$store.<%= scoped_store %>.current() && $store.<%= scoped_store %>.current().props.dismissible !== false && $store.<%= scoped_store %>.close()"
|
|
365
365
|
@click.self="$store.<%= scoped_store %>.current() && $store.<%= scoped_store %>.current().props.dismissible !== false && $store.<%= scoped_store %>.close()">
|
|
366
366
|
<div class="<%= card_class %> max-h-[85dvh] overflow-y-auto" :class="$store.<%= scoped_store %>.cardClasses()">
|
|
367
|
-
<%# Consumer-provided registrations — one <template x-if> per modal id.
|
|
368
|
-
|
|
367
|
+
<%# Consumer-provided registrations — one <template x-if> per modal id.
|
|
368
|
+
Not `yield if block_given?`: a partial's block_given? is always true,
|
|
369
|
+
and a caller-less yield returns the layout's whole page body. See
|
|
370
|
+
Studio::PartialBlock. %>
|
|
371
|
+
<%= Studio::PartialBlock.content(self, yield) %>
|
|
369
372
|
</div>
|
|
370
373
|
</div>
|
|
371
374
|
</template>
|
|
@@ -21,10 +21,16 @@
|
|
|
21
21
|
for processing / loading states.
|
|
22
22
|
size: :md (default) OR :lg (celebration end-state, text-3xl title).
|
|
23
23
|
|
|
24
|
-
Block: if given, the block's content replaces the subtitle area.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
Block: if given, the block's content replaces the subtitle area. The subtitle
|
|
25
|
+
locals outrank it.
|
|
26
|
+
|
|
27
|
+
NEVER TEST THE BLOCK WITH block_given?. In a Rails partial it is always true,
|
|
28
|
+
and a yield with no caller block returns the LAYOUT's content. This branch
|
|
29
|
+
used to read `elsif block_given?`, so a header rendered from the layout with
|
|
30
|
+
no subtitle yielded the whole page body into the modal: every signed-in
|
|
31
|
+
turf-monster page for a user allowed to rename carried two copies of itself
|
|
32
|
+
(modal-header-yields-whole-page, 2026-09-16). Studio::PartialBlock
|
|
33
|
+
(lib/studio/partial_block.rb) tells a real block from that fallback.
|
|
28
34
|
%>
|
|
29
35
|
<%
|
|
30
36
|
icon_color = local_assigns[:icon_color] || 'primary'
|
|
@@ -77,7 +83,7 @@
|
|
|
77
83
|
<p class="<%= sub_cls %>" x-text="<%= subtitle_key %>"></p>
|
|
78
84
|
<% elsif local_assigns[:subtitle] %>
|
|
79
85
|
<p class="<%= sub_cls %>"><%= subtitle %></p>
|
|
80
|
-
<% elsif
|
|
81
|
-
<p class="<%= sub_cls %>"><%=
|
|
86
|
+
<% elsif (caller_block = Studio::PartialBlock.content(self, yield)) %>
|
|
87
|
+
<p class="<%= sub_cls %>"><%= caller_block %></p>
|
|
82
88
|
<% end %>
|
|
83
89
|
</div>
|
|
@@ -73,6 +73,11 @@
|
|
|
73
73
|
large_title = !!local_assigns[:large_title]
|
|
74
74
|
use_drain = has_redirect && !!local_assigns[:cta_drain]
|
|
75
75
|
slot_position = local_assigns.fetch(:slot_position, :below_cta).to_sym
|
|
76
|
+
# The caller's block, captured ONCE, or nil when there is none. Never
|
|
77
|
+
# block_given?: in a partial it is always true, and a caller-less yield
|
|
78
|
+
# returns the layout's content, so a card rendered from a layout with no
|
|
79
|
+
# block re-emitted the whole page body under its CTA. See Studio::PartialBlock.
|
|
80
|
+
caller_block = Studio::PartialBlock.content(self, yield)
|
|
76
81
|
|
|
77
82
|
data_attr = "{
|
|
78
83
|
_remaining: #{redirect_secs},
|
|
@@ -179,7 +184,7 @@
|
|
|
179
184
|
<a :href="'https://explorer.solana.com/tx/' + (<%= tx_signature_key %>) + (typeof clusterParam !== 'undefined' ? clusterParam : '')"
|
|
180
185
|
target="_blank" rel="noopener"
|
|
181
186
|
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border border-subtle hover:border-primary/50 transition group"
|
|
182
|
-
style="background:
|
|
187
|
+
style="background: rgb(var(--color-primary-500-rgb) / 0.06);">
|
|
183
188
|
<svg width="14" height="11" viewBox="0 0 397 311" fill="none">
|
|
184
189
|
<defs>
|
|
185
190
|
<linearGradient id="studio-solana-grad" x1="361" y1="-9" x2="153" y2="389" gradientUnits="userSpaceOnUse">
|
|
@@ -231,8 +236,8 @@
|
|
|
231
236
|
card has always run its kickoff timer and seeds bar above the button for
|
|
232
237
|
that reason, and this is what lets the engine's card do the same instead of
|
|
233
238
|
the app forking one to keep the order. %>
|
|
234
|
-
<% if
|
|
235
|
-
<div class="mb-4"><%=
|
|
239
|
+
<% if caller_block && slot_position == :above_cta %>
|
|
240
|
+
<div class="mb-4"><%= caller_block %></div>
|
|
236
241
|
<% end %>
|
|
237
242
|
|
|
238
243
|
<%# Primary CTA — three flavors:
|
|
@@ -288,9 +293,9 @@
|
|
|
288
293
|
<% end %>
|
|
289
294
|
|
|
290
295
|
<%# ...or below it, which stays the DEFAULT: every existing caller renders here
|
|
291
|
-
and none of them asked to move.
|
|
292
|
-
one of these two branches
|
|
293
|
-
<% if
|
|
294
|
-
<div class="mt-3"><%=
|
|
296
|
+
and none of them asked to move. The block is captured once at the top, and
|
|
297
|
+
exactly one of these two branches renders it. %>
|
|
298
|
+
<% if caller_block && slot_position != :above_cta %>
|
|
299
|
+
<div class="mt-3"><%= caller_block %></div>
|
|
295
300
|
<% end %>
|
|
296
301
|
</div>
|
data/lib/studio/engine.rb
CHANGED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The content of the block a CALLER passed to a partial, or nil when it passed
|
|
5
|
+
# none. The engine's slot-taking partials ask this, never `block_given?`.
|
|
6
|
+
#
|
|
7
|
+
# WHY block_given? IS THE WRONG QUESTION IN A PARTIAL. ActionView compiles a
|
|
8
|
+
# template into a method and ALWAYS invokes that method with a block:
|
|
9
|
+
# PartialRenderer passes `{ |*name| view._layout_for(*name, &block) }` whether
|
|
10
|
+
# or not the render that reached the partial carried one. So `block_given?` is
|
|
11
|
+
# true in every partial. A `yield` with no caller block then falls through to
|
|
12
|
+
# `_layout_for`'s other branch, the LAYOUT's content. While the page's own
|
|
13
|
+
# template renders, that is empty. Once the layout renders, it is THE WHOLE
|
|
14
|
+
# PAGE BODY.
|
|
15
|
+
#
|
|
16
|
+
# MEASURED 2026-09-16 (modal-header-yields-whole-page). turf-monster renders
|
|
17
|
+
# the modal host from its layout. The username modal's plain "Saved" card
|
|
18
|
+
# reached blocks/_card_header with a nil subtitle, and the header's
|
|
19
|
+
# `elsif block_given?` yielded the entire page a second time inside a
|
|
20
|
+
# <template>: two copies of the page body on /, /contests and /account for
|
|
21
|
+
# every signed-in user allowed to rename. Nothing painted, nothing raised, and
|
|
22
|
+
# every request returned 200.
|
|
23
|
+
#
|
|
24
|
+
# HOW THIS ANSWERS IT. Rails gives a template no way to ask whether its caller
|
|
25
|
+
# passed a block, so the partial yields and hands the result in:
|
|
26
|
+
#
|
|
27
|
+
# caller_block = Studio::PartialBlock.content(self, yield)
|
|
28
|
+
#
|
|
29
|
+
# A caller-less yield can only return the layout's content or nothing, so a
|
|
30
|
+
# yield equal to `content_for(:layout)` is that fallback, not a block. A blank
|
|
31
|
+
# yield also reads as no block: an empty slot wrapper is never what a caller
|
|
32
|
+
# meant.
|
|
33
|
+
#
|
|
34
|
+
# THIS FIXES THE PREDICATE; THE BLOCK CONTRACT IS UNCHANGED. A caller that
|
|
35
|
+
# passes a non-blank block renders exactly as before, so no consumer edits a
|
|
36
|
+
# call site. A NEW slot should still prefer a named local that names a partial
|
|
37
|
+
# (solana-studio's wallet picker does), which needs no predicate at all.
|
|
38
|
+
module PartialBlock
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# view — the partial's view context (`self` inside the template).
|
|
42
|
+
# yielded — the partial's own `yield`, evaluated where the partial calls this.
|
|
43
|
+
def content(view, yielded)
|
|
44
|
+
return nil if yielded.blank?
|
|
45
|
+
return nil if yielded == view.content_for(:layout)
|
|
46
|
+
|
|
47
|
+
yielded
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -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