studio-engine 0.74.2 → 0.74.3
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 +46 -0
- data/app/views/studio/modals/_saving.html.erb +2 -1
- data/app/views/studio/modals/_scoped_host.html.erb +3 -1
- data/app/views/studio/modals/auth/_resend_footer.html.erb +3 -1
- data/app/views/studio/modals/blocks/_age_gate.html.erb +3 -1
- data/app/views/studio/modals/blocks/_birthday.html.erb +7 -0
- data/app/views/studio/modals/blocks/_close_x.html.erb +3 -1
- data/app/views/studio/modals/blocks/_cta_redirect.html.erb +3 -1
- data/app/views/studio/modals/blocks/_entry_confirmed.html.erb +3 -1
- data/app/views/studio/modals/blocks/_free_entry_earned.html.erb +3 -1
- data/app/views/studio/modals/blocks/_leveling_activity.html.erb +3 -1
- data/app/views/studio/modals/blocks/_onchain_success.html.erb +3 -1
- data/app/views/studio/modals/blocks/_shell.html.erb +3 -1
- data/app/views/studio/modals/onboarding/_first_name.html.erb +16 -30
- data/app/views/studio/modals/templates/_action.html.erb +2 -1
- data/app/views/studio/modals/templates/_form.html.erb +2 -1
- data/app/views/studio/modals/templates/_status.html.erb +2 -1
- data/app/views/studio/modals/templates/_success.html.erb +2 -1
- data/app/views/studio/modals/templates/_wizard.html.erb +2 -1
- data/lib/studio/js_identifier.rb +88 -0
- data/lib/studio/js_literal.rb +3 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2719af0f10410f44f1ac53e1a17fe244de4a9c4ecae8eb94715c75a69450839b
|
|
4
|
+
data.tar.gz: 9c705e4cb7e9816594d2fc13732ea5449f1523e02645a1108bb4b1f1d50d887f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 18833874f77e97c16f56c20135ecdb897d8f7d47316c2d122947bfdbd71d23117f825d51fe3a62ff417099c1051d6ea0aead4650715f9212aa68b5520d266333
|
|
7
|
+
data.tar.gz: 85568864c586c37ef1f17edefb9f23776b1f5f2089c09f331139000e88cd2dd1e47db031d01a456c3a9b267f8aced700b9221c04d0d4e1809c4381f4b0e5f020
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,52 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
6
6
|
|
|
7
7
|
### Fixed
|
|
8
8
|
|
|
9
|
+
- **A store name that is not a JS identifier half-worked, silently, in seventeen
|
|
10
|
+
partials — it now raises.** `$store.<%= modal_store %>.close()` splices the local
|
|
11
|
+
in as a bare NAME, so `modal_store: "my store"` emitted
|
|
12
|
+
`$store.my store.close()`: a SyntaxError that Alpine mounts as a no-op which still
|
|
13
|
+
renders every element. `Studio::JsIdentifier.validate!` is the new shared contract
|
|
14
|
+
(`/\A[A-Za-z_$][A-Za-z0-9_$]*\z/`), called once per partial at the fetch.
|
|
15
|
+
|
|
16
|
+
**THIS IS A BEHAVIOUR TIGHTENING, NOT A REPAIR, and it is breaking.** A host that
|
|
17
|
+
passed a non-identifier store name got a working page with a dead button; it now
|
|
18
|
+
gets an `ArgumentError` at render. That is the right trade — loud beats silent —
|
|
19
|
+
but it is a 500 where there was a 200. **Blast radius is zero, measured:** every
|
|
20
|
+
explicit store name in mcritchie-studio, turf-monster, rolio and this engine is
|
|
21
|
+
`modals`, `dsModals`, `emailModals` or `profileModals`, and no consumer app passes
|
|
22
|
+
one at all. **Rollback is one file:** make `validate!` return its argument instead
|
|
23
|
+
of raising and every partial is back to the old leniency, with no call site to
|
|
24
|
+
revisit.
|
|
25
|
+
|
|
26
|
+
**ESCAPING IS THE WRONG REPAIR HERE, which is why this is a second module rather
|
|
27
|
+
than a second method on `Studio::JsLiteral`.** `escape_javascript` also escapes
|
|
28
|
+
`$`, so the legal name `dsModals$2` comes back as `dsModals\$2` and
|
|
29
|
+
`$store.dsModals\$2.close()` is a different SyntaxError — the same dead card on a
|
|
30
|
+
value that was never hostile. A pattern rather than an allowlist, deliberately: an
|
|
31
|
+
allowlist would be a shared gem enumerating its own consumers, and the next app to
|
|
32
|
+
mount a page-scoped host would be refused by its own dependency.
|
|
33
|
+
|
|
34
|
+
**MEASURED, AND TWO OF THE LISTED SITES WERE LEFT ALONE.** Nineteen partials fetch
|
|
35
|
+
a store-name local. Counted on the tree: 36 splices of a store name in identifier
|
|
36
|
+
position — 32 in ERB, 4 assembled in Ruby — across 17 files. Rendering a hostile
|
|
37
|
+
value through all nineteen (rather than reading them) showed 17 emitting at least
|
|
38
|
+
one DEAD splice, one already refusing, and one unaffected. Seventeen now carry the
|
|
39
|
+
guard: 16 new, plus `modals/onboarding/_first_name` moved onto the shared one.
|
|
40
|
+
`modals/_crop_photo` is the unaffected one — its name is a JS string argument
|
|
41
|
+
resolved with `$store[name]`, a lookup where a space is as legal as a letter, and
|
|
42
|
+
it was already escaped, so demanding an identifier would break a host for nothing.
|
|
43
|
+
`blocks/_birthday` has no splice of its own — it hands the name to `Alpine.store()`
|
|
44
|
+
and forwards it to `blocks/_shell`, which validates — so a second guard there would
|
|
45
|
+
have been one that survives deletion.
|
|
46
|
+
|
|
47
|
+
**NOTHING MOVED FOR A LEGAL NAME.** All nineteen partials rendered before and
|
|
48
|
+
after, against the default, `dsModals` and `ds_Modals$2`, are byte-identical
|
|
49
|
+
(186,310 bytes each): a value matching the pattern carries no character ERB
|
|
50
|
+
escapes, and the validator returns a plain String so ERB's escaping stays armed
|
|
51
|
+
for the day the pattern widens. `modals/onboarding/_first_name` carried this
|
|
52
|
+
regex inline as the worked example and now calls the shared one — same pattern,
|
|
53
|
+
same error, same message.
|
|
54
|
+
|
|
9
55
|
- **The email banner assembled two attributes INCLUDING THEIR OWN QUOTES in Ruby,
|
|
10
56
|
so ERB escaping never ran on them.** `_layered_banner.html.erb` wrote
|
|
11
57
|
`background="..."` and `bgcolor="..."` as Ruby strings and marked them
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
apps omit this; the /admin/style demo passes an expression
|
|
11
11
|
(e.g. close) so the specimen self-terminates after min_duration
|
|
12
12
|
instead of spinning forever. %>
|
|
13
|
-
|
|
13
|
+
<%# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier. %>
|
|
14
|
+
<% saving_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:store, "modals"), local: :store) %>
|
|
14
15
|
<div>
|
|
15
16
|
<%= render "studio/modals/blocks/processing_card",
|
|
16
17
|
title_key: "$store.#{saving_store}.current()?.props?.title || 'Saving…'",
|
|
@@ -46,7 +46,9 @@
|
|
|
46
46
|
rest of it onto the page as visible text. That shipped once.)
|
|
47
47
|
%>
|
|
48
48
|
<%
|
|
49
|
-
|
|
49
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
50
|
+
scoped_store = Studio::JsIdentifier.validate!(
|
|
51
|
+
local_assigns.fetch(:store), local: :store)
|
|
50
52
|
card_class = local_assigns.fetch(:card_class,
|
|
51
53
|
"bg-surface rounded-xl border border-subtle shadow-2xl p-6 max-w-sm w-full")
|
|
52
54
|
%>
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
modal_store — Alpine store name backing close(). Default "modals".
|
|
13
13
|
%>
|
|
14
14
|
<%
|
|
15
|
-
|
|
15
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
16
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
17
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
16
18
|
%>
|
|
17
19
|
<button @click="resendMagicLink()" :disabled="props.submitting === 'magic-link' || (props.resendCooldown || 0) > 0"
|
|
18
20
|
class="block mx-auto text-sm text-primary hover:text-primary-300 underline underline-offset-2 disabled:opacity-50 disabled:no-underline">
|
|
@@ -62,7 +62,9 @@
|
|
|
62
62
|
birthday_modal_id = local_assigns.fetch(:birthday_modal_id, "birthday")
|
|
63
63
|
back_label = local_assigns.fetch(:back_label, "Update your Birthday")
|
|
64
64
|
title = local_assigns[:title]
|
|
65
|
-
|
|
65
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
66
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
67
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
66
68
|
%>
|
|
67
69
|
<div x-data="{
|
|
68
70
|
now: Date.now(),
|
|
@@ -80,6 +80,13 @@
|
|
|
80
80
|
fine_print = local_assigns.fetch(:fine_print,
|
|
81
81
|
validates ? "We use your date of birth only to confirm eligibility."
|
|
82
82
|
: "We use your date of birth only to know when to wish you a happy birthday.")
|
|
83
|
+
# NOT VALIDATED HERE, deliberately. This partial never splices the store name into
|
|
84
|
+
# identifier position: it reaches birthdayModal as a JS STRING (escaped below) and
|
|
85
|
+
# the factory looks it up with Alpine.store(name), where any character is legal.
|
|
86
|
+
# The identifier contract is owed by blocks/_shell, which this card renders one
|
|
87
|
+
# line further down and which validates it — so a hostile value still raises,
|
|
88
|
+
# once, at the site that actually splices it. A second guard here would be a
|
|
89
|
+
# redundant one: it would survive being deleted.
|
|
83
90
|
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
84
91
|
demo = local_assigns.fetch(:demo, false)
|
|
85
92
|
demo_underage = local_assigns.fetch(:demo_underage, false)
|
|
@@ -42,7 +42,9 @@
|
|
|
42
42
|
label — aria-label. Default "Close".
|
|
43
43
|
%>
|
|
44
44
|
<%
|
|
45
|
-
|
|
45
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
46
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
47
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
46
48
|
label = local_assigns.fetch(:label, "Close")
|
|
47
49
|
%>
|
|
48
50
|
<button type="button" @click="$store.<%= modal_store %>.close()"
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
href_key = local_assigns.fetch(:href_key)
|
|
26
26
|
label_html = local_assigns.fetch(:label_html, "Continue")
|
|
27
27
|
duration_seconds = local_assigns.fetch(:duration_seconds, 5)
|
|
28
|
-
|
|
28
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
29
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
30
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
29
31
|
%>
|
|
30
32
|
<div x-data="{
|
|
31
33
|
_redirectTimer: null,
|
|
@@ -60,7 +60,9 @@
|
|
|
60
60
|
that does nothing.
|
|
61
61
|
%>
|
|
62
62
|
<%
|
|
63
|
-
|
|
63
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
64
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
65
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
64
66
|
props_expr = local_assigns.fetch(:props_expr,
|
|
65
67
|
"(function () { var c = $store.#{modal_store}.current(); return (c && c.props) || {}; })()")
|
|
66
68
|
above_seeds = local_assigns[:above_seeds]
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
close_label — dismiss link text. Default "Close".
|
|
20
20
|
%>
|
|
21
21
|
<%
|
|
22
|
-
|
|
22
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
23
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
24
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
23
25
|
icon_emoji = local_assigns.fetch(:icon_emoji, "🎉")
|
|
24
26
|
title_key = local_assigns.fetch(:title_key, "props.level ? ('Level ' + props.level) : 'Free Entry Earned'")
|
|
25
27
|
subtitle = local_assigns.fetch(:subtitle, "You leveled up and earned a reward. Keep earning more with each level.")
|
|
@@ -91,7 +91,9 @@
|
|
|
91
91
|
free_entry_label = local_assigns.fetch(:free_entry_label, "Free Entry Earned 🎟️")
|
|
92
92
|
seeds_per_level = local_assigns.fetch(:seeds_per_level, 100)
|
|
93
93
|
leveling = local_assigns.fetch(:leveling, Studio.feature?(:leveling))
|
|
94
|
-
|
|
94
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
95
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
96
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
95
97
|
demo = local_assigns.fetch(:demo, false)
|
|
96
98
|
# consent_label — render a consent checkbox that gates the action (newsletter
|
|
97
99
|
# join). next_label / next_open — an optional "Next Quest" button in the
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
cta_label_key = local_assigns.fetch(:cta_label_key)
|
|
28
28
|
cta_href_key = local_assigns.fetch(:cta_href_key)
|
|
29
29
|
cluster_param = local_assigns.fetch(:cluster_param, "")
|
|
30
|
-
|
|
30
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
31
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
32
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
31
33
|
%>
|
|
32
34
|
<div>
|
|
33
35
|
<%= render "studio/modals/blocks/card_header",
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
<%
|
|
14
14
|
shell_title = local_assigns[:title]
|
|
15
15
|
shell_dismissible = local_assigns.fetch(:dismissible, true)
|
|
16
|
-
|
|
16
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
17
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
18
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
17
19
|
%>
|
|
18
20
|
<div>
|
|
19
21
|
<% if shell_title || shell_dismissible %>
|
|
@@ -104,8 +104,8 @@
|
|
|
104
104
|
sites: the props getter, finish's close, and the Ruby-built dismiss_action
|
|
105
105
|
emitted into the x's click handler. Escaping is the WRONG repair here: an
|
|
106
106
|
escaped identifier is a different SyntaxError and the same dead card.
|
|
107
|
-
Repair: refuse a value that is not a JS identifier, which
|
|
108
|
-
|
|
107
|
+
Repair: refuse a value that is not a JS identifier, which one call to
|
|
108
|
+
Studio::JsIdentifier.validate! does at the source, covering all three sites.
|
|
109
109
|
|
|
110
110
|
A PATTERN, NOT AN ALLOWLIST, and the reason is worth keeping: an allowlist would
|
|
111
111
|
be this engine enumerating its own consumers, so the next app to mount a
|
|
@@ -116,7 +116,9 @@
|
|
|
116
116
|
|
|
117
117
|
NOTE THE SAME LOCAL TAKES THE OTHER REPAIR ELSEWHERE. blocks/_birthday and
|
|
118
118
|
blocks/_leveling_activity pass modal_store in STRING position and escape it,
|
|
119
|
-
correctly. The shape decides the repair, never the name of the local.
|
|
119
|
+
correctly. The shape decides the repair, never the name of the local. And where
|
|
120
|
+
a store name reaches ONLY a string — blocks/_birthday hands it to Alpine.store(),
|
|
121
|
+
a lookup by string — no identifier is owed and none is demanded.
|
|
120
122
|
%>
|
|
121
123
|
<%
|
|
122
124
|
submit_path = local_assigns.fetch(:submit_path, "/onboarding/first_name")
|
|
@@ -163,37 +165,21 @@
|
|
|
163
165
|
typed_placeholder = !placeholder_names.nil?
|
|
164
166
|
max_length = local_assigns.fetch(:max_length, Studio::FULL_NAME_MAX_LENGTH)
|
|
165
167
|
progress = local_assigns.fetch(:progress, nil)
|
|
166
|
-
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
167
168
|
# VALIDATED, NOT ESCAPED — and that distinction is the whole point of this local.
|
|
168
169
|
# Every other value inside the x-data is a JS STRING and is repaired by escaping.
|
|
169
170
|
# This one is spliced in as a bare NAME (`$store.<name>.current()`), so the same
|
|
170
|
-
# repair breaks it: escape_javascript
|
|
171
|
-
#
|
|
172
|
-
#
|
|
171
|
+
# repair breaks it: escape_javascript backslash-escapes the apostrophe, and an
|
|
172
|
+
# escaped apostrophe is not a rescued identifier — it is a different SyntaxError,
|
|
173
|
+
# the same silently dead card reached a longer way.
|
|
173
174
|
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
# ASCII IdentifierName, which is what member access after a dot accepts. It
|
|
183
|
-
# admits every store name a host would plausibly write and rejects every
|
|
184
|
-
# character that could leave identifier position — quote, apostrophe, backslash,
|
|
185
|
-
# dot, space, semicolon, angle bracket.
|
|
186
|
-
#
|
|
187
|
-
# IT RAISES RATHER THAN FALLING BACK TO THE DEFAULT. A silent fallback is the
|
|
188
|
-
# worse of the two repairs: the card would mount, look perfect, and talk to a
|
|
189
|
-
# store that is not the host's — which is the SILENT-brick failure class this
|
|
190
|
-
# guard exists to leave behind. A host sees this the first time it renders.
|
|
191
|
-
unless modal_store.to_s.match?(/\A[A-Za-z_$][A-Za-z0-9_$]*\z/)
|
|
192
|
-
raise ArgumentError,
|
|
193
|
-
"modal_store must be a JS identifier (it is spliced into $store.<name>); " \
|
|
194
|
-
"got #{modal_store.inspect}. Escaping it would not help — an escaped " \
|
|
195
|
-
"identifier is a different SyntaxError, and the card would mount dead."
|
|
196
|
-
end
|
|
175
|
+
# THE REGEX AND THE ARGUMENT USED TO LIVE HERE, INLINE. This card was the first
|
|
176
|
+
# site to need them, so it carried the worked example; the engine has sixteen more
|
|
177
|
+
# splices of a store name in identifier position, and the contract now lives in ONE
|
|
178
|
+
# place. Studio::JsIdentifier holds the pattern, why it is a pattern and not an
|
|
179
|
+
# allowlist, and why it raises rather than falling back to the default. Behaviour
|
|
180
|
+
# here is unchanged: same pattern, same ArgumentError, same message.
|
|
181
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
182
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
197
183
|
done_event = local_assigns.fetch(:done_event, "onboarding-step-done")
|
|
198
184
|
# THE SAME HAZARD empty_error_js CARRIES, on the three remaining locals that land
|
|
199
185
|
# inside a JS SINGLE-quoted literal in the x-data below. A bare apostrophe in any
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
icon_color :primary / :success / :warning to card_header.
|
|
8
8
|
|
|
9
9
|
Local: modal_store — Alpine store name backing close(). Default "modals". %>
|
|
10
|
-
|
|
10
|
+
<%# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier. %>
|
|
11
|
+
<% modal_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:modal_store, "modals"), local: :modal_store) %>
|
|
11
12
|
<div x-data>
|
|
12
13
|
<%= render "studio/modals/blocks/card_header",
|
|
13
14
|
size: :lg,
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
+ close ×. Ported from turf-monster.
|
|
5
5
|
|
|
6
6
|
Local: modal_store — Alpine store name backing close(). Default "modals". %>
|
|
7
|
-
|
|
7
|
+
<%# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier. %>
|
|
8
|
+
<% modal_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:modal_store, "modals"), local: :modal_store) %>
|
|
8
9
|
<div x-data="{ value: '' }">
|
|
9
10
|
<%= render layout: "studio/modals/blocks/shell", locals: { title: "Form Template", modal_store: modal_store } do %>
|
|
10
11
|
<p class="text-sm text-muted mb-4">
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
at 1.6s so a viewer can step through both states.
|
|
7
7
|
|
|
8
8
|
Local: modal_store — Alpine store name backing close(). Default "modals". %>
|
|
9
|
-
|
|
9
|
+
<%# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier. %>
|
|
10
|
+
<% modal_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:modal_store, "modals"), local: :modal_store) %>
|
|
10
11
|
<div x-data="{
|
|
11
12
|
state: 'loading',
|
|
12
13
|
start() {
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
use studio/modals/blocks/_onchain_success instead. Ported from turf-monster.
|
|
6
6
|
|
|
7
7
|
Local: modal_store — Alpine store name backing close(). Default "modals". %>
|
|
8
|
-
|
|
8
|
+
<%# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier. %>
|
|
9
|
+
<% modal_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:modal_store, "modals"), local: :modal_store) %>
|
|
9
10
|
<div x-data>
|
|
10
11
|
<%= render "studio/modals/blocks/card_header",
|
|
11
12
|
size: :lg,
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
primary CTA pulling forward; Back as a subtle link, hidden on step 1.
|
|
9
9
|
|
|
10
10
|
Local: modal_store — Alpine store name backing close(). Default "modals". %>
|
|
11
|
-
|
|
11
|
+
<%# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier. %>
|
|
12
|
+
<% modal_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:modal_store, "modals"), local: :modal_store) %>
|
|
12
13
|
<div x-data="{ step: 1, total: 3 }">
|
|
13
14
|
<div class="relative mb-3 -mt-2">
|
|
14
15
|
<h3 class="text-heading font-bold text-lg leading-tight text-center pt-1">Wizard Template</h3>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module Studio
|
|
2
|
+
# ONE home for the CONTRACT a host-supplied value must meet when a partial
|
|
3
|
+
# splices it into JS **identifier position** — a bare NAME rather than a string:
|
|
4
|
+
#
|
|
5
|
+
# <button @click="$store.<%= modal_store %>.close()">
|
|
6
|
+
#
|
|
7
|
+
# THE SIBLING OF Studio::JsLiteral, AND ITS OPPOSITE. Both exist for the same
|
|
8
|
+
# silent failure: a host value that makes an Alpine expression a SyntaxError, so
|
|
9
|
+
# the component mounts as a NO-OP that still renders every element. Perfect
|
|
10
|
+
# markup, dead card, nothing raised and nothing logged. The two differ on the
|
|
11
|
+
# only question that matters — WHAT THE VALUE IS:
|
|
12
|
+
#
|
|
13
|
+
# STRING position — the value is DATA inside a JS literal. Any character is
|
|
14
|
+
# legal; the repair is to ESCAPE it (Studio::JsLiteral.in_attribute).
|
|
15
|
+
# IDENTIFIER position — the value is a NAME. Most characters are not legal at
|
|
16
|
+
# all; the repair is to REFUSE it, which is this module.
|
|
17
|
+
#
|
|
18
|
+
# ESCAPING IS ACTIVELY WRONG HERE, which is why this is a second module rather
|
|
19
|
+
# than a second method on the first. escape_javascript also escapes `$`, so a
|
|
20
|
+
# perfectly legal store name like `dsModals$2` comes back as `dsModals\$2` and
|
|
21
|
+
# `$store.dsModals\$2.close()` is a SyntaxError — the same dead card, reached a
|
|
22
|
+
# longer way, on a value that was never hostile. And for a value that IS hostile,
|
|
23
|
+
# `$store.a\'b.close()` is not a rescued identifier either. There is no escaping
|
|
24
|
+
# of identifier position; there is only validity.
|
|
25
|
+
#
|
|
26
|
+
# A PATTERN, NOT AN ALLOWLIST, and the reason outlives this engine. An allowlist
|
|
27
|
+
# would be a shared primitive enumerating its own CONSUMERS — "modals" and the
|
|
28
|
+
# style guide's "dsModals" today — so the next app to mount a page-scoped host
|
|
29
|
+
# would be refused by its own dependency until a gem release admitted the name.
|
|
30
|
+
# The real contract is narrower and stateless: the value is spliced into
|
|
31
|
+
# member-access position, so it must be an identifier, and WHICH identifier is
|
|
32
|
+
# none of the engine's business.
|
|
33
|
+
#
|
|
34
|
+
# IT RAISES RATHER THAN FALLING BACK TO A DEFAULT. A silent fallback is the worse
|
|
35
|
+
# of the two repairs: the card would mount, look perfect, and talk to a store that
|
|
36
|
+
# is not the host's — the same SILENT class this guard exists to leave behind,
|
|
37
|
+
# only now with a working-looking page in front of it. A host meets this the first
|
|
38
|
+
# time it renders, in development, with the local named.
|
|
39
|
+
#
|
|
40
|
+
# WHAT IT DOES NOT COVER. A value in string position that is ALSO a store name
|
|
41
|
+
# (blocks/_birthday's `store: '<%= j modal_store %>'`, which reaches
|
|
42
|
+
# `Alpine.store(name)` — a lookup by string, where any character is legal) needs no
|
|
43
|
+
# identifier. Validate where the value reaches a SPLICE, not everywhere the word
|
|
44
|
+
# "store" appears; test/views/js_identifier_locals_test.rb carries the census.
|
|
45
|
+
module JsIdentifier
|
|
46
|
+
# ASCII IdentifierName — what member access after a dot accepts. It admits every
|
|
47
|
+
# store name a host would plausibly write (`modals`, `dsModals`, `ds_Modals$2`)
|
|
48
|
+
# and rejects every character that could leave identifier position: quote,
|
|
49
|
+
# apostrophe, backslash, dot, space, semicolon, angle bracket, and the empty
|
|
50
|
+
# string.
|
|
51
|
+
#
|
|
52
|
+
# DELIBERATELY NOT THE FULL ECMAScript GRAMMAR, which admits most of Unicode.
|
|
53
|
+
# Widening it is a decision, not a bug fix — and it would have to be taken
|
|
54
|
+
# together with the marking note on #validate! below, because the value's safety
|
|
55
|
+
# under ERB is what the narrow set buys.
|
|
56
|
+
PATTERN = /\A[A-Za-z_$][A-Za-z0-9_$]*\z/
|
|
57
|
+
|
|
58
|
+
module_function
|
|
59
|
+
|
|
60
|
+
# Return +value+ as a String, or raise ArgumentError naming +local+.
|
|
61
|
+
#
|
|
62
|
+
# modal_store = Studio::JsIdentifier.validate!(
|
|
63
|
+
# local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
64
|
+
#
|
|
65
|
+
# +local+ is REQUIRED and has no default on purpose. Fifteen of the engine's
|
|
66
|
+
# call sites name this local `modal_store` and two name it `store`; a default
|
|
67
|
+
# that is right fifteen times out of seventeen is exactly how the other two
|
|
68
|
+
# would hand a host the wrong local to go and fix.
|
|
69
|
+
#
|
|
70
|
+
# RETURNS A PLAIN STRING, NOT AN html_safe ONE, and that is load-bearing rather
|
|
71
|
+
# than an omission. A value that satisfies PATTERN contains no character ERB
|
|
72
|
+
# escapes, so ERB's pass over it is a byte-for-byte no-op and the identifier
|
|
73
|
+
# reaches the browser exactly as the host wrote it — the marking would buy
|
|
74
|
+
# nothing today. What it would cost is the day PATTERN is widened: ERB's
|
|
75
|
+
# escaping is the second layer that would still be standing, and a value marked
|
|
76
|
+
# safe here skips it. So the pattern is the only thing holding the line, and it
|
|
77
|
+
# is holding it alone by choice, in one place.
|
|
78
|
+
def validate!(value, local:)
|
|
79
|
+
name = "#{value}"
|
|
80
|
+
return name if name.match?(PATTERN)
|
|
81
|
+
|
|
82
|
+
raise ArgumentError,
|
|
83
|
+
"#{local} must be a JS identifier (it is spliced into $store.<name>); " \
|
|
84
|
+
"got #{value.inspect}. Escaping it would not help — an escaped " \
|
|
85
|
+
"identifier is a different SyntaxError, and the card would mount dead."
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/studio/js_literal.rb
CHANGED
|
@@ -80,8 +80,9 @@ module Studio
|
|
|
80
80
|
# NAME — `$store.<%= modal_store %>.close()` — must be VALIDATED, never escaped:
|
|
81
81
|
# escape_javascript also escapes `$`, so a legitimate store name like `dsModals$2`
|
|
82
82
|
# comes back mangled and the card dies anyway. The SHAPE of the splice decides the
|
|
83
|
-
# repair, never the name of the local.
|
|
84
|
-
#
|
|
83
|
+
# repair, never the name of the local. That repair is Studio::JsIdentifier, this
|
|
84
|
+
# module's sibling and its opposite; studio/modals/onboarding/_first_name carries
|
|
85
|
+
# the worked example of both in one partial.
|
|
85
86
|
module JsLiteral
|
|
86
87
|
extend ActionView::Helpers::JavaScriptHelper
|
|
87
88
|
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -9,6 +9,7 @@ require "studio/environment_banner"
|
|
|
9
9
|
require "studio/theme_resolver"
|
|
10
10
|
require "studio/ui_primitives"
|
|
11
11
|
require "studio/js_literal"
|
|
12
|
+
require "studio/js_identifier"
|
|
12
13
|
require "studio/sidebar_sections"
|
|
13
14
|
require "studio/profile_sections"
|
|
14
15
|
require "studio/profile_image"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.74.
|
|
4
|
+
version: 0.74.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -568,6 +568,7 @@ files:
|
|
|
568
568
|
- lib/studio/geo/lookup.rb
|
|
569
569
|
- lib/studio/image_cache.rb
|
|
570
570
|
- lib/studio/ip_locations.rb
|
|
571
|
+
- lib/studio/js_identifier.rb
|
|
571
572
|
- lib/studio/js_literal.rb
|
|
572
573
|
- lib/studio/link_resolution.rb
|
|
573
574
|
- lib/studio/link_token.rb
|