phlex-reactive 0.9.0 → 0.9.2

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.
@@ -20,6 +20,14 @@ module Phlex
20
20
  module DSL
21
21
  extend ActiveSupport::Concern
22
22
 
23
+ # A cross-root mirror target must be a single ID selector (issue #159)
24
+ # — "#" + a CSS identifier, nothing else. Not a class, not an attribute
25
+ # selector, not `*`, not a compound/descendant/list selector: a
26
+ # compromised reducer must not be able to scribble text across the
27
+ # page. The client interpreter enforces the SAME shape (two-sided
28
+ # default-deny, mirroring the attr allowlist posture).
29
+ MIRROR_ID_SELECTOR = /\A#[A-Za-z_][\w-]*\z/
30
+
23
31
  class_methods do
24
32
  # Declare the ActiveRecord (GlobalID-able) record this component is
25
33
  # rebuilt from. The signed token carries its GlobalID; the server
@@ -165,15 +173,45 @@ module Phlex
165
173
  #
166
174
  # import { setComputeReducer } from "phlex/reactive/compute"
167
175
  # setComputeReducer("payment_split", ({ allowance, cash, leasing, total }, { changed }) => ({ … }))
168
- def reactive_compute(name, inputs: nil, outputs: nil, reducer: nil)
169
- return reactive_compute_def(name) if inputs.nil? && outputs.nil?
176
+ #
177
+ # `mirror:` (issue #159) declares CROSS-ROOT text mirrors — the opt-in
178
+ # escape from root isolation (issue #15) for a derived value shown in a
179
+ # recap OUTSIDE the computing root (another tab pane, a sticky footer):
180
+ #
181
+ # reactive_compute :split,
182
+ # inputs: %i[a b total],
183
+ # outputs: %i[a b],
184
+ # mirror: { sum_a: "#sum_a", sum_total: ["#sum_total", "#footer-total"] }
185
+ #
186
+ # Each key is a compute name — a declared input (its identity mirror
187
+ # also paints cross-root) or a reducer-result key (an extra text-only
188
+ # output). Each value is one or more DOCUMENT-WIDE id selectors the
189
+ # value is painted into via textContent (XSS-safe, change-guarded,
190
+ # never innerHTML, never a field write). Id selectors ONLY — a class/
191
+ # attribute/compound selector raises here (declared, not arbitrary;
192
+ # the client interpreter re-checks the same shape).
193
+ def reactive_compute(name, inputs: nil, outputs: nil, reducer: nil, mirror: nil)
194
+ if inputs.nil? && outputs.nil?
195
+ # The bare form is the GETTER — a mirror: passed here would be
196
+ # silently dropped, so refuse it LOUDLY (declare-time validation,
197
+ # same posture as normalize_compute_mirror's selector check).
198
+ unless mirror.nil?
199
+ raise ArgumentError,
200
+ "#{self}: reactive_compute(#{name.inspect}, mirror: ...) without inputs:/outputs: " \
201
+ "is the getter form — the mirror would be silently dropped. Declare the mirror " \
202
+ "alongside inputs:/outputs:."
203
+ end
204
+
205
+ return reactive_compute_def(name)
206
+ end
170
207
 
171
208
  input_names, input_types = normalize_compute_inputs(inputs)
172
209
  Registry.write_entry(
173
210
  self, :computes, name.to_sym,
174
211
  ComputeDefinition.new(
175
212
  name: name.to_sym, inputs: input_names, input_types:,
176
- outputs: Array(outputs).map(&:to_sym), reducer: (reducer || name).to_s
213
+ outputs: Array(outputs).map(&:to_sym), reducer: (reducer || name).to_s,
214
+ mirror: normalize_compute_mirror(mirror)
177
215
  )
178
216
  )
179
217
  end
@@ -210,6 +248,27 @@ module Phlex
210
248
  end
211
249
  end
212
250
 
251
+ # Normalize `mirror:` to { name => [id selectors] } (nil passes
252
+ # through — no mirror declared). Each value may be one selector or a
253
+ # list; every selector is validated LOUDLY at declare time against
254
+ # MIRROR_ID_SELECTOR (id selectors only).
255
+ def normalize_compute_mirror(mirror)
256
+ return nil if mirror.nil?
257
+
258
+ mirror.to_h do |name, selectors|
259
+ list = Array(selectors).map(&:to_s)
260
+ list.each do
261
+ next if it.match?(MIRROR_ID_SELECTOR)
262
+
263
+ raise ArgumentError,
264
+ "#{self}: mirror target #{it.inspect} for #{name.inspect} must be a single " \
265
+ "id selector (\"#some-id\") — cross-root text mirrors are declared, allowlisted " \
266
+ "targets, never arbitrary selectors"
267
+ end
268
+ [name.to_sym, list.freeze]
269
+ end.freeze
270
+ end
271
+
213
272
  # Rebuild a component instance from a verified identity payload. Called
214
273
  # by the action endpoint after the token signature is verified.
215
274
  #
@@ -422,13 +422,23 @@ module Phlex
422
422
  definition = self.class.reactive_compute_def(name)
423
423
  raise Error, "#{self.class} has no reactive_compute #{name.inspect}" unless definition
424
424
 
425
- {
426
- data: {
427
- reactive_compute_reducer_param: definition.reducer,
428
- reactive_compute_inputs_param: compute_inputs_param(definition),
429
- reactive_compute_outputs_param: definition.outputs.map(&:to_s).to_json
430
- }
425
+ data = {
426
+ reactive_compute_reducer_param: definition.reducer,
427
+ reactive_compute_inputs_param: compute_inputs_param(definition),
428
+ reactive_compute_outputs_param: definition.outputs.map(&:to_s).to_json
431
429
  }
430
+ # Declared cross-root text mirrors (issue #159) ride as a JSON object of
431
+ # name → [id selectors]; omitted entirely when undeclared so the shipped
432
+ # wire stays byte-identical.
433
+ data[:reactive_compute_mirror_param] = compute_mirror_param(definition) if definition.mirror
434
+
435
+ { data: }
436
+ end
437
+
438
+ # The mirror param wire (issue #159): { "sum_a" => ["#sum_a"], … } — the
439
+ # values are ALWAYS arrays so the client parses one uniform shape.
440
+ def compute_mirror_param(definition)
441
+ definition.mirror.transform_keys(&:to_s).to_json
432
442
  end
433
443
 
434
444
  # The inputs param wire (issue #104). Untyped (array form) → a JSON ARRAY of
@@ -86,7 +86,12 @@ module Phlex
86
86
  # { name => type } hash for the typed HASH form (:string reads the field
87
87
  # value raw, :number coerces). `inputs` stays the ordered name list either
88
88
  # way, so iteration order is preserved and the array-form wire is unchanged.
89
- ComputeDefinition = Data.define(:name, :inputs, :outputs, :reducer, :input_types)
89
+ #
90
+ # `mirror` (issue #159) is nil when undeclared, else a { name => [id
91
+ # selectors] } map — DECLARED cross-root text mirrors, validated to id
92
+ # selectors only at declare time (the server half of the two-sided
93
+ # default-deny; the client interpreter re-checks the shape).
94
+ ComputeDefinition = Data.define(:name, :inputs, :outputs, :reducer, :input_types, :mirror)
90
95
 
91
96
  # A declared add/remove-row collection (issue #35): the list contract tied
92
97
  # into one unit — the per-row item component, the container DOM id rows
@@ -44,6 +44,44 @@ module Phlex
44
44
  URL_BEARING_ATTRS = %w[href src srcdoc action formaction xlink:href].freeze
45
45
  EVENT_HANDLER_ATTR = /\Aon/i
46
46
 
47
+ # The attr ops whose args carry a "name" the allowlist must gate. Used to
48
+ # re-validate a RAW [op, args] list (the js([...]) / broadcast_js_to([...])
49
+ # escape hatch) that skips the builder's build-time attr_args check.
50
+ ATTR_NAME_OPS = %w[set_attr remove_attr toggle_attr].freeze
51
+
52
+ # Validate a raw ops list ([[op, args], ...] as passed to js/broadcast_js_to
53
+ # without the builder) against the attribute allowlist, so the escape hatch
54
+ # gets the SAME server-side default-deny as the JS chain (defense in depth;
55
+ # the client also enforces it). Non-attr ops and malformed entries pass
56
+ # through untouched — the client interpreter default-denies unknown ops.
57
+ def self.assert_ops_allowed!(list)
58
+ Array(list).each do |op, args|
59
+ next unless ATTR_NAME_OPS.include?(op.to_s) && args.is_a?(::Hash)
60
+
61
+ name = args["name"] || args[:name]
62
+ assert_allowed_attr(name.to_s) if name
63
+ end
64
+ end
65
+
66
+ # The attribute-name allowlist (issue #96), case-insensitive. Refuses
67
+ # event-handler (on*), URL-bearing, and style attributes. Enforced at build
68
+ # time by the instance builder AND on the raw-list escape hatch — two-sided
69
+ # default-deny with the client interpreter.
70
+ def self.assert_allowed_attr(name)
71
+ lower = name.downcase
72
+ if name.match?(EVENT_HANDLER_ATTR)
73
+ raise ArgumentError,
74
+ "#{self}: attribute #{name.inspect} is an event handler (on*) — refused (XSS). " \
75
+ "Client attr ops target hidden/disabled/open/selected/aria-*/data-* and classes."
76
+ end
77
+ return unless URL_BEARING_ATTRS.include?(lower) || lower == "style"
78
+
79
+ raise ArgumentError,
80
+ "#{self}: attribute #{name.inspect} is refused — URL-bearing attributes " \
81
+ "(#{URL_BEARING_ATTRS.join(", ")}) and `style` can't be set from client ops " \
82
+ "(injection surface). Use classes for styling; target aria-*/data-*/boolean attrs."
83
+ end
84
+
47
85
  # The accumulated [name, args] op pairs, oldest first. Frozen.
48
86
  attr_reader :ops
49
87
 
@@ -119,6 +157,20 @@ module Phlex
119
157
  append("focus_first", target_args(to, global:))
120
158
  end
121
159
 
160
+ # --- Text content (issue #159) ---
161
+ #
162
+ # text(to, value) — set the target's textContent (stringified; nil clears).
163
+ # XSS-safe by construction: textContent only, NEVER innerHTML — strictly
164
+ # less powerful than set_attr. Pair with `global: true` to paint a value
165
+ # into a node OUTSIDE the component's root (the cross-root text escape,
166
+ # e.g. a read-only recap in another tab pane).
167
+
168
+ def text(to, value, global: false)
169
+ args = { "to" => normalize_target(to), "value" => value.to_s }
170
+ args["global"] = true if global
171
+ append("text", args.freeze)
172
+ end
173
+
122
174
  # --- Dispatch a bubbling CustomEvent (issue #96) ---
123
175
  #
124
176
  # dispatch(name, to: nil, detail: {}) — emit a bubbling CustomEvent so other
@@ -169,21 +221,10 @@ module Phlex
169
221
  args.freeze
170
222
  end
171
223
 
172
- # The attribute-name allowlist (issue #96), case-insensitive. Enforced here
173
- # AND in the client interpreter two-sided default-deny.
224
+ # Build-time attr-name allowlist for the instance builder — delegates to the
225
+ # shared class-method check (also used by the raw-list escape hatch).
174
226
  def assert_allowed_attr(name)
175
- lower = name.downcase
176
- if name.match?(EVENT_HANDLER_ATTR)
177
- raise ArgumentError,
178
- "#{self.class}: attribute #{name.inspect} is an event handler (on*) — refused (XSS). " \
179
- "Client attr ops target hidden/disabled/open/selected/aria-*/data-* and classes."
180
- end
181
- return unless URL_BEARING_ATTRS.include?(lower) || lower == "style"
182
-
183
- raise ArgumentError,
184
- "#{self.class}: attribute #{name.inspect} is refused — URL-bearing attributes " \
185
- "(#{URL_BEARING_ATTRS.join(", ")}) and `style` can't be set from client ops " \
186
- "(injection surface). Use classes for styling; target aria-*/data-*/boolean attrs."
227
+ self.class.assert_allowed_attr(name)
187
228
  end
188
229
 
189
230
  # A transition must be exactly [during, from, to] class lists (strings).
@@ -275,6 +275,10 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
275
275
  list = Array(ops)
276
276
  raise ArgumentError, "js(...) got no ops — a dead reactive:js stream" if list.empty?
277
277
 
278
+ # The builder validates attr names at build time; a raw [op, args]
279
+ # list skips that, so re-apply the allowlist here (defense in depth —
280
+ # the client interpreter also refuses on*/URL/style attrs).
281
+ Phlex::Reactive::JS.assert_ops_allowed!(list)
278
282
  list.to_json
279
283
  end
280
284
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.9.0"
5
+ VERSION = "0.9.2"
6
6
  end
7
7
  end
@@ -446,6 +446,10 @@ module Phlex
446
446
  # through the endpoint's existing `|| raise(InvalidToken)` → 400.
447
447
  def upgrade_token(payload)
448
448
  version = payload.fetch("v", 0)
449
+ # "v" is inside the signed blob, so only our own key could produce a
450
+ # malformed one — but fail closed rather than 500 on the comparison
451
+ # (String vs Integer) or silently treat a negative "v" as legacy.
452
+ return nil unless version.is_a?(::Integer) && version >= 0
449
453
  return payload if version == TOKEN_VERSION
450
454
  return nil if version > TOKEN_VERSION
451
455
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson