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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +79 -1
- data/README.md +51 -1
- data/app/javascript/phlex/reactive/reactive_controller.js +80 -4
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/dsl.rb +62 -3
- data/lib/phlex/reactive/component/helpers.rb +16 -6
- data/lib/phlex/reactive/component.rb +6 -1
- data/lib/phlex/reactive/js.rb +55 -14
- data/lib/phlex/reactive/response.rb +4 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +4 -0
- metadata +1 -1
|
@@ -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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -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
|
-
#
|
|
173
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/phlex/reactive.rb
CHANGED
|
@@ -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
|
|