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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6694226dcc9e100149692ef60162478d4927a69280343c5047c0222517f1f68c
4
- data.tar.gz: 8e6b5c07f8bb5fa60466049f321c7be0a09168fb750766202a5d122734cb6699
3
+ metadata.gz: 2719af0f10410f44f1ac53e1a17fe244de4a9c4ecae8eb94715c75a69450839b
4
+ data.tar.gz: 9c705e4cb7e9816594d2fc13732ea5449f1523e02645a1108bb4b1f1d50d887f
5
5
  SHA512:
6
- metadata.gz: 6ec39596a087d56cc64f3a211ea349bf6a00b7a21c6eb1d1c70b36136dda3f3ab7f8fe1a1f61b26593661283f12707501727d9f3d5119f01b3fa28995ece7bd7
7
- data.tar.gz: 3d65093bf62c9088976d9ea56e756cabd7e01e65aaae06205419e337a0d8b6a1a1b93c60b93631bf724d254f01bcdc5fb0c7360c50525c1f519b35b6b7b49946
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
- <% saving_store = local_assigns.fetch(:store, "modals") %>
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
- scoped_store = local_assigns.fetch(:store)
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
- modal_store = local_assigns.fetch(:modal_store, "modals")
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
- modal_store = local_assigns.fetch(:modal_store, "modals")
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
- modal_store = local_assigns.fetch(:modal_store, "modals")
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
- modal_store = local_assigns.fetch(:modal_store, "modals")
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
- modal_store = local_assigns.fetch(:modal_store, "modals")
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
- modal_store = local_assigns.fetch(:modal_store, "modals")
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
- modal_store = local_assigns.fetch(:modal_store, "modals")
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
- modal_store = local_assigns.fetch(:modal_store, "modals")
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
- modal_store = local_assigns.fetch(:modal_store, "modals")
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 the Ruby block
108
- below does once, at the source, so all three sites are covered.
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 turns `a'b` into `a\'b`, and
171
- # `$store.a\'b.current()` is not a rescued identifier, it is a different
172
- # SyntaxError — the same silently dead card, reached a longer way.
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
- # A PATTERN RATHER THAN AN ALLOWLIST, deliberately. An allowlist would be this
175
- # engine enumerating its own CONSUMERS "modals" and the style guide's
176
- # "dsModals" today so the next app to mount a page-scoped host would be refused
177
- # by its own dependency until a gem release admitted the name. That is backwards
178
- # for a shared primitive. The real contract is narrower and stateless: this is
179
- # spliced into member-access position, so it must be a JS identifier, and WHICH
180
- # identifier is none of the engine's business.
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
- <% modal_store = local_assigns.fetch(:modal_store, "modals") %>
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
- <% modal_store = local_assigns.fetch(:modal_store, "modals") %>
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
- <% modal_store = local_assigns.fetch(:modal_store, "modals") %>
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
- <% modal_store = local_assigns.fetch(:modal_store, "modals") %>
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
- <% modal_store = local_assigns.fetch(:modal_store, "modals") %>
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
@@ -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. studio/modals/onboarding/_first_name
84
- # carries the worked example of both.
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
 
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.74.2"
2
+ VERSION = "0.74.3"
3
3
  end
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.2
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-08 00:00:00.000000000 Z
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