phlex-reactive 0.9.3 → 0.9.5
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 +212 -0
- data/README.md +282 -2
- data/app/controllers/phlex/reactive/actions_controller.rb +120 -10
- data/app/javascript/phlex/reactive/inspect.js +225 -0
- data/app/javascript/phlex/reactive/inspect.min.js +4 -0
- data/app/javascript/phlex/reactive/inspect.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +659 -10
- 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/generators/phlex/reactive/claude/USAGE +15 -0
- data/lib/generators/phlex/reactive/claude/claude_generator.rb +86 -0
- data/lib/generators/phlex/reactive/install/install_generator.rb +3 -0
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +56 -0
- data/lib/phlex/reactive/authorization.rb +118 -0
- data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +105 -0
- data/lib/phlex/reactive/component/dsl.rb +70 -0
- data/lib/phlex/reactive/component/helpers.rb +348 -21
- data/lib/phlex/reactive/component/identity.rb +10 -1
- data/lib/phlex/reactive/component/lazy.rb +79 -0
- data/lib/phlex/reactive/component/registry.rb +7 -1
- data/lib/phlex/reactive/component.rb +1 -0
- data/lib/phlex/reactive/defer.rb +363 -0
- data/lib/phlex/reactive/deferred_render_job.rb +116 -0
- data/lib/phlex/reactive/doctor.rb +79 -11
- data/lib/phlex/reactive/engine.rb +16 -2
- data/lib/phlex/reactive/inspector/report.rb +140 -0
- data/lib/phlex/reactive/inspector.rb +253 -0
- data/lib/phlex/reactive/log_subscriber.rb +10 -0
- data/lib/phlex/reactive/mcp/base_tool.rb +57 -0
- data/lib/phlex/reactive/mcp/runner.rb +24 -0
- data/lib/phlex/reactive/mcp/server.rb +47 -0
- data/lib/phlex/reactive/mcp/tools/actions_tool.rb +43 -0
- data/lib/phlex/reactive/mcp/tools/components_tool.rb +39 -0
- data/lib/phlex/reactive/mcp/tools/config_tool.rb +74 -0
- data/lib/phlex/reactive/mcp/tools/doctor_tool.rb +36 -0
- data/lib/phlex/reactive/mcp/tools/find_tool.rb +66 -0
- data/lib/phlex/reactive/mcp.rb +58 -0
- data/lib/phlex/reactive/reply.rb +18 -0
- data/lib/phlex/reactive/response.rb +47 -2
- data/lib/phlex/reactive/streamable.rb +5 -1
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +253 -3
- data/lib/tasks/phlex_reactive.rake +28 -0
- metadata +22 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module MCP
|
|
6
|
+
module Tools
|
|
7
|
+
# phlex_reactive_config — a REDACTED config summary. It reports the
|
|
8
|
+
# page-stable, non-secret configuration a developer needs to understand
|
|
9
|
+
# the install, and NEVER the verifier, secret_key_base, or any token. The
|
|
10
|
+
# allowlist below is explicit: a field is reported only if it appears here.
|
|
11
|
+
class ConfigTool < BaseTool
|
|
12
|
+
tool_name "phlex_reactive_config"
|
|
13
|
+
title "phlex-reactive config"
|
|
14
|
+
description <<~DESC
|
|
15
|
+
A redacted summary of the phlex-reactive configuration: gem version,
|
|
16
|
+
action_path, base_controller_name, renderer, verify_authorized +
|
|
17
|
+
authorization_methods, verbose_errors/debug/log_events, flash_target,
|
|
18
|
+
and whether pgbus is present/streams-capable. NEVER reports the
|
|
19
|
+
verifier, secret_key_base, or any signed token. Read-only.
|
|
20
|
+
DESC
|
|
21
|
+
|
|
22
|
+
input_schema(properties: {}, required: [])
|
|
23
|
+
|
|
24
|
+
def self.call(server_context: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
25
|
+
json_response(
|
|
26
|
+
version: Phlex::Reactive::VERSION,
|
|
27
|
+
action_path: Phlex::Reactive.action_path,
|
|
28
|
+
base_controller_name: Phlex::Reactive.base_controller_name,
|
|
29
|
+
renderer: renderer_name,
|
|
30
|
+
verify_authorized: Phlex::Reactive.verify_authorized,
|
|
31
|
+
authorization_methods: Phlex::Reactive.authorization_methods,
|
|
32
|
+
authorization_errors: Phlex::Reactive.authorization_errors.map(&:to_s),
|
|
33
|
+
verbose_errors: Phlex::Reactive.verbose_errors,
|
|
34
|
+
debug: Phlex::Reactive.debug,
|
|
35
|
+
log_events: Phlex::Reactive.log_events,
|
|
36
|
+
flash_target: Phlex::Reactive.flash_target,
|
|
37
|
+
pgbus: pgbus?,
|
|
38
|
+
pgbus_streams: pgbus_streams?
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The renderer is a controller CLASS (or nil) — report its name, never
|
|
43
|
+
# the object (which could inspect to something with state).
|
|
44
|
+
def self.renderer_name
|
|
45
|
+
renderer = Phlex::Reactive.renderer
|
|
46
|
+
renderer.respond_to?(:name) ? renderer.name : renderer.class.name
|
|
47
|
+
rescue StandardError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Is pgbus present and stream-capable? Delegates to the config readers
|
|
52
|
+
# when they exist; otherwise probes directly (defined? + respond_to?),
|
|
53
|
+
# so the report is correct regardless of pgbus version.
|
|
54
|
+
# Delegate to the gem's own capability gates (issue #165) — the
|
|
55
|
+
# canonical runtime probes (`pgbus_streams?` inspects
|
|
56
|
+
# Pgbus::Streams::Stream#broadcast's parameters directly, never a live
|
|
57
|
+
# stream call). Reported as plain booleans; degrade to false if the
|
|
58
|
+
# readers ever raise.
|
|
59
|
+
def self.pgbus?
|
|
60
|
+
!!Phlex::Reactive.pgbus?
|
|
61
|
+
rescue StandardError
|
|
62
|
+
false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.pgbus_streams?
|
|
66
|
+
!!Phlex::Reactive.pgbus_streams?
|
|
67
|
+
rescue StandardError
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module MCP
|
|
6
|
+
module Tools
|
|
7
|
+
# phlex_reactive_doctor — the install validator's checks as structured
|
|
8
|
+
# JSON (name/status/message/fix), the same data `bin/rails
|
|
9
|
+
# phlex_reactive:doctor` prints as text.
|
|
10
|
+
class DoctorTool < BaseTool
|
|
11
|
+
tool_name "phlex_reactive_doctor"
|
|
12
|
+
title "phlex-reactive doctor"
|
|
13
|
+
description <<~DESC
|
|
14
|
+
Validate the phlex-reactive install and return each check as structured
|
|
15
|
+
JSON: name, status (ok/fail/unknown), message, and a fix line for any
|
|
16
|
+
non-passing check. The same checks `bin/rails phlex_reactive:doctor`
|
|
17
|
+
prints — route, Stimulus registration, csrf, verifier round-trip, base
|
|
18
|
+
controller, every action has a method, stable #id, and the advisory
|
|
19
|
+
authorization heuristic. Read-only.
|
|
20
|
+
DESC
|
|
21
|
+
|
|
22
|
+
input_schema(properties: {}, required: [])
|
|
23
|
+
|
|
24
|
+
def self.call(server_context: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
25
|
+
eager_load_app!
|
|
26
|
+
doctor = Phlex::Reactive::Doctor.new
|
|
27
|
+
checks = doctor.checks.map do
|
|
28
|
+
{ name: it.name, status: it.status, message: it.message, fix: it.fix }
|
|
29
|
+
end
|
|
30
|
+
json_response(checks: checks, ok: !doctor.failures?)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module MCP
|
|
6
|
+
module Tools
|
|
7
|
+
# phlex_reactive_find — fuzzy-search components by name and return the
|
|
8
|
+
# ranked matches, each with full per-action detail INCLUDING the Prism-
|
|
9
|
+
# extracted method definition source. The tool for "where is X defined and
|
|
10
|
+
# what does its action do?".
|
|
11
|
+
class FindTool < BaseTool
|
|
12
|
+
tool_name "phlex_reactive_find"
|
|
13
|
+
title "phlex-reactive find"
|
|
14
|
+
description <<~DESC
|
|
15
|
+
Fuzzy-find reactive components by name (exact > prefix > substring >
|
|
16
|
+
subsequence, on both the demodulized and full name). Returns the ranked
|
|
17
|
+
matches, each with its actions' param schema, source location,
|
|
18
|
+
authorization heuristic, and the full method-definition SOURCE
|
|
19
|
+
(extracted with Prism). Read-only.
|
|
20
|
+
|
|
21
|
+
NOTE: unlike the other tools (which report names/paths/schemas only),
|
|
22
|
+
this one returns the action's source code verbatim — so anything
|
|
23
|
+
hardcoded in an action body is surfaced too. Don't embed secrets or
|
|
24
|
+
credentials in action definitions (read them from ENV/credentials).
|
|
25
|
+
DESC
|
|
26
|
+
|
|
27
|
+
input_schema(
|
|
28
|
+
properties: {
|
|
29
|
+
query: { type: "string", description: "The search string (component name fragment)." }
|
|
30
|
+
},
|
|
31
|
+
required: ["query"]
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def self.call(query:, server_context: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
35
|
+
eager_load_app!
|
|
36
|
+
matches = Phlex::Reactive::Inspector.find(query).map { match_hash(it) }
|
|
37
|
+
json_response(query: query, matches: matches)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.match_hash(info)
|
|
41
|
+
{
|
|
42
|
+
name: info.name,
|
|
43
|
+
path: Phlex::Reactive::Inspector::Report.location_str(info.path),
|
|
44
|
+
record_key: info.record_key,
|
|
45
|
+
state_keys: info.state_keys,
|
|
46
|
+
actions: info.actions.map { action_detail(it) }
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.action_detail(action)
|
|
51
|
+
{
|
|
52
|
+
name: action.name,
|
|
53
|
+
params: action.params,
|
|
54
|
+
source_location: Phlex::Reactive::Inspector::Report.location_str(action.source_location),
|
|
55
|
+
authorization_call_detected: action.authorization_call_detected?,
|
|
56
|
+
# The full method SOURCE — this is the one field that exposes an
|
|
57
|
+
# action body verbatim (see the tool description). A secret hardcoded
|
|
58
|
+
# in an action would surface here; keep secrets out of action bodies.
|
|
59
|
+
definition: action.definition
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# The read-only diagnostic MCP server (issue #168): a stdio server exposing
|
|
6
|
+
# five read-only tools (doctor, components, actions, find, config) so Claude
|
|
7
|
+
# Code inside a HOST app can introspect the live reactive registry when
|
|
8
|
+
# debugging. It mirrors pgbus's MCP subtree.
|
|
9
|
+
#
|
|
10
|
+
# The `mcp` gem is OPTIONAL and NEVER a gemspec runtime dependency — this
|
|
11
|
+
# whole subtree references the optional gem's constants (MCP::Tool,
|
|
12
|
+
# MCP::Server), so it is kept OUT of the Zeitwerk loader (loader.ignore in
|
|
13
|
+
# lib/phlex/reactive.rb) and loaded explicitly via MCP.load!.
|
|
14
|
+
#
|
|
15
|
+
# Phlex::Reactive::MCP.load! # lazy-require the gem + the tool tree
|
|
16
|
+
# Phlex::Reactive::MCP::Runner.run # drive the stdio transport
|
|
17
|
+
#
|
|
18
|
+
# Entry point for a host app: `bin/rails phlex_reactive:mcp` (a rake task, not
|
|
19
|
+
# an exe — introspection needs the booted Rails app). Consumer .mcp.json:
|
|
20
|
+
#
|
|
21
|
+
# { "mcpServers": { "phlex-reactive": {
|
|
22
|
+
# "command": "bin/rails", "args": ["phlex_reactive:mcp"] } } }
|
|
23
|
+
module MCP
|
|
24
|
+
# Every file under lib/phlex/reactive/mcp/, in DEPENDENCY ORDER (base_tool
|
|
25
|
+
# before the tools that subclass it; the tools before the server that lists
|
|
26
|
+
# them; the server before the runner that builds it). Loaded by load! after
|
|
27
|
+
# the optional gem itself.
|
|
28
|
+
REQUIRES = %w[
|
|
29
|
+
phlex/reactive/mcp/base_tool
|
|
30
|
+
phlex/reactive/mcp/tools/doctor_tool
|
|
31
|
+
phlex/reactive/mcp/tools/components_tool
|
|
32
|
+
phlex/reactive/mcp/tools/actions_tool
|
|
33
|
+
phlex/reactive/mcp/tools/find_tool
|
|
34
|
+
phlex/reactive/mcp/tools/config_tool
|
|
35
|
+
phlex/reactive/mcp/server
|
|
36
|
+
phlex/reactive/mcp/runner
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# Lazy-require the optional `mcp` gem, then the tool tree in dependency
|
|
42
|
+
# order. Idempotent (plain require). ONLY `require "mcp"` is rescued — a
|
|
43
|
+
# LoadError from an internal path propagates verbatim so a typo isn't
|
|
44
|
+
# masked as "add the mcp gem".
|
|
45
|
+
def load!
|
|
46
|
+
begin
|
|
47
|
+
require "mcp"
|
|
48
|
+
rescue LoadError
|
|
49
|
+
raise Phlex::Reactive::Error,
|
|
50
|
+
"The phlex-reactive MCP diagnostic server needs the `mcp` gem. " \
|
|
51
|
+
"Add `gem \"mcp\"` to your Gemfile (a dev/test group is fine) and run `bundle install`."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
REQUIRES.each { require it }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/phlex/reactive/reply.rb
CHANGED
|
@@ -99,6 +99,24 @@ module Phlex
|
|
|
99
99
|
def streams(*strings)
|
|
100
100
|
Response.streams(@component, *strings)
|
|
101
101
|
end
|
|
102
|
+
|
|
103
|
+
# Defer an expensive segment (issue #165): `reply.defer(Totals.new(...))`.
|
|
104
|
+
#
|
|
105
|
+
# With NO prior verb, the bound component's token is refreshed via a tiny
|
|
106
|
+
# token-only stream (Response.streams), NOT a full self-replace: a full
|
|
107
|
+
# replace would re-render the acting component SYNCHRONOUSLY on the request
|
|
108
|
+
# thread — and if you defer your OWN subject (`reply.defer(self)`), that is
|
|
109
|
+
# the very render you meant to move OFF the critical path, so the defer
|
|
110
|
+
# would be silently defeated (a double render). The token-only refresh
|
|
111
|
+
# rolls the signed identity forward without any synchronous render; the
|
|
112
|
+
# deferred segment's directive rides alongside. Chain off a verb when you
|
|
113
|
+
# DO want a synchronous piece too:
|
|
114
|
+
#
|
|
115
|
+
# reply.streams(volume_cell).defer(SessionTotals.new(workout: @workout))
|
|
116
|
+
# reply.replace.defer(Totals.new(order: @order)) # explicit self re-render
|
|
117
|
+
def defer(component, placeholder: nil, morph: false)
|
|
118
|
+
Response.streams(@component).defer(component, placeholder:, morph:)
|
|
119
|
+
end
|
|
102
120
|
end
|
|
103
121
|
end
|
|
104
122
|
end
|
|
@@ -312,16 +312,29 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
|
|
|
312
312
|
# NOT trip refresh_token? — it exists only to default #js's target to the
|
|
313
313
|
# bound component's id, so reply.morph.js(js.focus("@root")) scopes to the
|
|
314
314
|
# morphed root without the caller repeating the id.
|
|
315
|
+
# No deferred segments — the shared default so the common (non-defer)
|
|
316
|
+
# reply never allocates an empty array per Response.
|
|
317
|
+
NO_SEGMENTS = [].freeze
|
|
318
|
+
|
|
315
319
|
def initialize(streams: [], redirect_url: nil, render_self: true, token_component: nil,
|
|
316
|
-
subject_component: nil)
|
|
320
|
+
subject_component: nil, deferred_segments: NO_SEGMENTS)
|
|
317
321
|
@streams = streams.freeze
|
|
318
322
|
@redirect_url = redirect_url
|
|
319
323
|
@render_self = render_self
|
|
320
324
|
@token_component = token_component
|
|
321
325
|
@subject_component = subject_component
|
|
326
|
+
@deferred_segments = deferred_segments.freeze
|
|
322
327
|
freeze
|
|
323
328
|
end
|
|
324
329
|
|
|
330
|
+
# The recorded reply.defer segments (issue #165), in call order. The
|
|
331
|
+
# Response only RECORDS them; the endpoint (via the Defer module) builds
|
|
332
|
+
# the placeholder + directive streams AFTER the action's transaction
|
|
333
|
+
# committed and picks the delivery lane.
|
|
334
|
+
attr_reader :deferred_segments
|
|
335
|
+
|
|
336
|
+
def deferred? = !@deferred_segments.empty?
|
|
337
|
+
|
|
325
338
|
# Append extra turbo-stream strings (a sibling component, a flash).
|
|
326
339
|
# Returns a NEW Response (immutable).
|
|
327
340
|
def stream(*more)
|
|
@@ -330,7 +343,39 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
|
|
|
330
343
|
redirect_url: @redirect_url,
|
|
331
344
|
render_self: @render_self,
|
|
332
345
|
token_component: @token_component,
|
|
333
|
-
subject_component: @subject_component
|
|
346
|
+
subject_component: @subject_component,
|
|
347
|
+
deferred_segments: @deferred_segments
|
|
348
|
+
)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Record a DEFERRED segment (issue #165): `component`'s render is taken
|
|
352
|
+
# off the actor's critical path — the reply carries a pending marker (or
|
|
353
|
+
# `placeholder:`) plus a delivery directive, and the real HTML reaches
|
|
354
|
+
# the SAME actor when the render completes (pull fetch or pgbus push,
|
|
355
|
+
# picked at the endpoint). `morph: true` makes the arrival morph in place.
|
|
356
|
+
# Returns a NEW Response (immutable). Chain it like any other verb:
|
|
357
|
+
#
|
|
358
|
+
# reply.streams(volume_cell_stream).defer(SessionTotals.new(workout:))
|
|
359
|
+
#
|
|
360
|
+
# Dead constructs fail HERE, loudly: defer on a redirect (the client is
|
|
361
|
+
# navigating away), a non-reactive component (its identity could never be
|
|
362
|
+
# rebuilt at render time), a bogus placeholder type.
|
|
363
|
+
def defer(component, placeholder: nil, morph: false)
|
|
364
|
+
if redirect?
|
|
365
|
+
raise Phlex::Reactive::Error,
|
|
366
|
+
"reply.defer on a redirect reply is dead — the client is navigating away, " \
|
|
367
|
+
"so the deferred render could never land"
|
|
368
|
+
end
|
|
369
|
+
Phlex::Reactive::Defer.validate_segment!(component, placeholder)
|
|
370
|
+
|
|
371
|
+
self.class.new(
|
|
372
|
+
streams: @streams,
|
|
373
|
+
redirect_url: @redirect_url,
|
|
374
|
+
render_self: @render_self,
|
|
375
|
+
token_component: @token_component,
|
|
376
|
+
subject_component: @subject_component,
|
|
377
|
+
deferred_segments: @deferred_segments +
|
|
378
|
+
[Phlex::Reactive::Defer::Segment.new(component:, placeholder:, morph:)]
|
|
334
379
|
)
|
|
335
380
|
end
|
|
336
381
|
|
|
@@ -157,7 +157,11 @@ module Phlex
|
|
|
157
157
|
# known only after rendering, so we mutate the payload inside the block.
|
|
158
158
|
event = { component: name, bytesize: 0 }
|
|
159
159
|
Phlex::Reactive.instrument("render", event) do
|
|
160
|
-
|
|
160
|
+
# Machinery renders are REAL (issue #165): a reactive_lazy component
|
|
161
|
+
# emits its actual template here — the lazy shell is for the
|
|
162
|
+
# page-embedded initial mount only. Two fiber-local writes; the
|
|
163
|
+
# render bench holds flat.
|
|
164
|
+
html = Phlex::Reactive::Defer.with_real_render { component.render_in(turbo_view_context) }
|
|
161
165
|
event[:bytesize] = html.bytesize
|
|
162
166
|
html
|
|
163
167
|
end
|
data/lib/phlex/reactive.rb
CHANGED
|
@@ -56,10 +56,34 @@ module Phlex
|
|
|
56
56
|
# the stdlib one an app catches.
|
|
57
57
|
class UnknownParamType < ::ArgumentError; end
|
|
58
58
|
|
|
59
|
+
# Raised when the default-ON verify_authorized guard (issue #168) finds an
|
|
60
|
+
# action completed WITHOUT any authorization call — the fail-closed
|
|
61
|
+
# presence-side complement to authorization_errors. It fires INSIDE the
|
|
62
|
+
# action's transaction, so the mutation rolls back, and it is NOT rescued
|
|
63
|
+
# into a 4xx: it bubbles as a developer error (a 500 your error tracker
|
|
64
|
+
# must see) because a missing authorize! is a bug, not a client fault. The
|
|
65
|
+
# message names the component#action and all three remedies (call an
|
|
66
|
+
# authorization method / mark_authorized! / declare skip_verify_authorized).
|
|
67
|
+
class AuthorizationNotVerified < Error; end
|
|
68
|
+
|
|
59
69
|
# Purpose string bound into every identity token's signature so a token
|
|
60
70
|
# minted for phlex-reactive can't be replayed against another verifier use.
|
|
61
71
|
IDENTITY_PURPOSE = "phlex-reactive/identity"
|
|
62
72
|
|
|
73
|
+
# Purpose string for DEFER tokens (issue #165) — the short-TTL identity a
|
|
74
|
+
# reply.defer directive carries so the client can fetch the expensive
|
|
75
|
+
# render off the actor's critical path. A distinct purpose makes the two
|
|
76
|
+
# token families non-interchangeable BY SIGNATURE: an action token is
|
|
77
|
+
# rejected at the defer endpoint (it must not become a render oracle) and a
|
|
78
|
+
# defer token can never invoke an action (it carries no action grant).
|
|
79
|
+
DEFER_PURPOSE = "phlex-reactive/defer"
|
|
80
|
+
|
|
81
|
+
# The defer_transport values (issue #165): :auto picks push (pgbus durable
|
|
82
|
+
# one-shot stream + ActiveJob) when capable, else pull (parallel fetch);
|
|
83
|
+
# :fetch forces pull; :stream requests push but still degrades to pull with
|
|
84
|
+
# a warning when the capability is absent (degrade, never break).
|
|
85
|
+
DEFER_TRANSPORTS = %i[auto fetch stream].freeze
|
|
86
|
+
|
|
63
87
|
# The current identity-token payload version (issue #111), stamped into every
|
|
64
88
|
# signed token under the "v" key. It exists so the NEXT breaking shape change
|
|
65
89
|
# (a rename, per-token expiry, a nonce) can upgrade tokens already in flight
|
|
@@ -167,6 +191,37 @@ module Phlex
|
|
|
167
191
|
false
|
|
168
192
|
end
|
|
169
193
|
|
|
194
|
+
# verify_authorized (issue #168): the default-ON runtime guard. When true,
|
|
195
|
+
# an action that completes without any authorization call raises
|
|
196
|
+
# AuthorizationNotVerified inside its transaction (fail-closed — the
|
|
197
|
+
# mutation rolls back). Default ON is a deliberate, pre-1.0 breaking change:
|
|
198
|
+
# a forgotten authorize! becomes a loud 500, not a silent hole. Opt out per
|
|
199
|
+
# component with `skip_verify_authorized`, mark a bespoke check with
|
|
200
|
+
# `mark_authorized!`, or turn it off globally here. The `defined?` guard
|
|
201
|
+
# (not `||=`) makes an explicit `= false` stick.
|
|
202
|
+
attr_writer :verify_authorized
|
|
203
|
+
|
|
204
|
+
def verify_authorized
|
|
205
|
+
return @verify_authorized if defined?(@verify_authorized)
|
|
206
|
+
|
|
207
|
+
true
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The method names verify_authorized's interceptor wraps to detect an
|
|
211
|
+
# authorization call (issue #168). Defaults to the common set across
|
|
212
|
+
# authorization libraries — Pundit (`authorize`), CanCanCan (`authorize!`),
|
|
213
|
+
# ActionPolicy (`authorize!`/`allowed_to?`). Set in an initializer to match
|
|
214
|
+
# your app's helper. `mark_authorized!` always counts, regardless of this
|
|
215
|
+
# list. The `defined?` guard makes an explicit override (including a
|
|
216
|
+
# narrower list) stick.
|
|
217
|
+
attr_writer :authorization_methods
|
|
218
|
+
|
|
219
|
+
def authorization_methods
|
|
220
|
+
return @authorization_methods if defined?(@authorization_methods)
|
|
221
|
+
|
|
222
|
+
%i[authorize! authorize allowed_to?]
|
|
223
|
+
end
|
|
224
|
+
|
|
170
225
|
# Register an app-defined param type (issue #109). The block receives the
|
|
171
226
|
# raw client value and returns the coerced value — or Phlex::Reactive::
|
|
172
227
|
# ParamSchema::DROP to reject it (the keyword default then applies, keeping
|
|
@@ -307,6 +362,186 @@ module Phlex
|
|
|
307
362
|
instance.view_context
|
|
308
363
|
end
|
|
309
364
|
|
|
365
|
+
# --- Deferred reply segments (issue #165) --------------------------
|
|
366
|
+
|
|
367
|
+
# Lifetime (seconds) of a defer token. It only needs to cover the
|
|
368
|
+
# reply→fetch gap (or reply→job→SSE on the push lane), so it stays short:
|
|
369
|
+
# a leaked defer token is a render oracle for exactly one component
|
|
370
|
+
# identity until this expires. nil resets to the default.
|
|
371
|
+
attr_writer :defer_token_ttl
|
|
372
|
+
|
|
373
|
+
def defer_token_ttl
|
|
374
|
+
@defer_token_ttl ||= 120
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# The path the defer endpoint is mounted at (the pull lane's target).
|
|
378
|
+
# Default "/reactive/defer"; set before boot if it collides.
|
|
379
|
+
attr_writer :defer_path
|
|
380
|
+
|
|
381
|
+
def defer_path
|
|
382
|
+
@defer_path ||= "/reactive/defer"
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# How deferred segments reach the actor: :auto (push iff capable, else
|
|
386
|
+
# pull), :fetch (always pull), :stream (push; degrades to pull with a
|
|
387
|
+
# warning when the capability is absent). Validated at assignment — a
|
|
388
|
+
# typo'd transport must fail at the initializer, not silently at reply
|
|
389
|
+
# time. nil resets to the default.
|
|
390
|
+
def defer_transport
|
|
391
|
+
@defer_transport ||= :auto
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def defer_transport=(value)
|
|
395
|
+
value = value&.to_sym
|
|
396
|
+
unless value.nil? || DEFER_TRANSPORTS.include?(value)
|
|
397
|
+
raise ::ArgumentError,
|
|
398
|
+
"Phlex::Reactive.defer_transport must be one of #{DEFER_TRANSPORTS.map(&:inspect).join(", ")} " \
|
|
399
|
+
"(got #{value.inspect})"
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
@defer_transport = value
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# The ActiveJob queue the push lane's DeferredRenderJob runs on. Default
|
|
406
|
+
# "default"; point it at a fast queue in production — a deferred segment
|
|
407
|
+
# is a UX-latency render, and letting it starve behind heavy jobs defeats
|
|
408
|
+
# the point. nil resets to the default.
|
|
409
|
+
attr_writer :defer_job_queue
|
|
410
|
+
|
|
411
|
+
def defer_job_queue
|
|
412
|
+
@defer_job_queue ||= "default"
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# Signs a defer payload (issue #165): same shape + version stamp as the
|
|
416
|
+
# identity token, but purpose-scoped to DEFER_PURPOSE and expiring after
|
|
417
|
+
# defer_token_ttl. verify/verify_defer are therefore mutually exclusive
|
|
418
|
+
# by construction — see the DEFER_PURPOSE comment.
|
|
419
|
+
# SECURITY (the leaked-token exchange): the defer endpoint re-renders the
|
|
420
|
+
# real component, whose root carries a fresh NON-expiring identity (action)
|
|
421
|
+
# token — so a leaked defer token replayed by ANOTHER actor within its TTL
|
|
422
|
+
# would hand that actor a permanent action token for the same identity.
|
|
423
|
+
# The purpose is therefore ALSO scoped to the minting actor's binding (the
|
|
424
|
+
# session id, via with_defer_binding), so a defer token minted under
|
|
425
|
+
# binding A fails verification under binding B — the exchange can't cross
|
|
426
|
+
# actors. Unbound (no session — the ActionController::Base default) is the
|
|
427
|
+
# documented today-behavior; `authorize!` in the action stays the real
|
|
428
|
+
# authority in every case.
|
|
429
|
+
# Sign a defer token. `unbound: true` (the reactive_lazy channel) mints
|
|
430
|
+
# under the PLAIN DEFER_PURPOSE, never the /binding suffix — because a
|
|
431
|
+
# lazy shell renders during the PAGE render, on a fresh visit where the
|
|
432
|
+
# session doesn't exist yet (Rails establishes it DURING that response),
|
|
433
|
+
# so it can't be bound; and it lives in the actor's own page, a small leak
|
|
434
|
+
# surface. reply.defer tokens (the default) mint under the current actor's
|
|
435
|
+
# binding — they live in an action HTTP response that can transit proxies/
|
|
436
|
+
# logs, the real cross-infrastructure leak vector. See docs/security +
|
|
437
|
+
# the README defer security note. `authorize!` in the action is the real
|
|
438
|
+
# authority for the harvested-action-token step in both cases.
|
|
439
|
+
def sign_defer(payload, unbound: false)
|
|
440
|
+
purpose = unbound ? DEFER_PURPOSE : defer_purpose
|
|
441
|
+
verifier.generate(payload.merge("v" => TOKEN_VERSION), purpose:, expires_in: defer_token_ttl)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Returns the verified, version-upgraded defer payload, or nil when the
|
|
445
|
+
# token is tampered, expired, carries the wrong purpose (an action token
|
|
446
|
+
# OR a BOUND token minted under a DIFFERENT actor binding), or a version
|
|
447
|
+
# this code doesn't understand — all fail closed through the endpoint's
|
|
448
|
+
# InvalidToken → 400 path. Tries the current-binding purpose first (the
|
|
449
|
+
# reply.defer, actor-bound case), then falls back to the plain
|
|
450
|
+
# DEFER_PURPOSE (the unbound lazy case) — so an unbound lazy token
|
|
451
|
+
# resolves under ANY binding, while a bound token minted under session-A
|
|
452
|
+
# still fails under session-B (it was signed with /session-A, which
|
|
453
|
+
# matches NEITHER /session-B nor the plain purpose).
|
|
454
|
+
def verify_defer(token)
|
|
455
|
+
payload = verifier.verified(token, purpose: defer_purpose)
|
|
456
|
+
payload ||= verifier.verified(token, purpose: DEFER_PURPOSE) if current_defer_binding
|
|
457
|
+
payload && upgrade_token(payload)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# The purpose string for the CURRENT actor's defer tokens: the base
|
|
461
|
+
# DEFER_PURPOSE, plus the binding when one is present. Binding is folded
|
|
462
|
+
# into the PURPOSE (not the payload) so it's part of the signature's
|
|
463
|
+
# domain separation — a mismatched binding is a verification failure, not
|
|
464
|
+
# a value the endpoint must remember to compare.
|
|
465
|
+
def defer_purpose
|
|
466
|
+
binding = current_defer_binding
|
|
467
|
+
binding ? "#{DEFER_PURPOSE}/#{binding}" : DEFER_PURPOSE
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# The acting client's defer binding during a request, or nil. Set by the
|
|
471
|
+
# ActionsController from defer_binding_for(request) — threaded exactly
|
|
472
|
+
# like current_connection_id.
|
|
473
|
+
def current_defer_binding
|
|
474
|
+
Thread.current[:phlex_reactive_defer_binding]
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def with_defer_binding(binding)
|
|
478
|
+
previous = Thread.current[:phlex_reactive_defer_binding]
|
|
479
|
+
Thread.current[:phlex_reactive_defer_binding] = binding.presence
|
|
480
|
+
yield
|
|
481
|
+
ensure
|
|
482
|
+
Thread.current[:phlex_reactive_defer_binding] = previous
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Resolve a request to its defer binding: the id of an ALREADY-PERSISTED
|
|
486
|
+
# session, else nil. The `exists?` gate is load-bearing — a bare
|
|
487
|
+
# `session.id` LAZILY generates an id even for an empty, never-written
|
|
488
|
+
# session, and that id is NOT persisted (no Set-Cookie), so two requests
|
|
489
|
+
# for the same read-only page get DIFFERENT lazy ids and a bound token
|
|
490
|
+
# minted on one is rejected on the other. Only a persisted session (an
|
|
491
|
+
# authenticated app wrote one at login) has a stable id across the mint
|
|
492
|
+
# (page render / action) and the verify (defer endpoint) — exactly the
|
|
493
|
+
# case where cross-actor exchange is the real threat. A read-only page
|
|
494
|
+
# with no session mints + verifies UNBOUND consistently (the TTL +
|
|
495
|
+
# `authorize!` remain the bound). Tolerant of a store that lazily raises:
|
|
496
|
+
# degrade to nil (unbound), never a 500. Override to bind to your own
|
|
497
|
+
# actor identity (a stable user id, an API-token digest) — recommended for
|
|
498
|
+
# a token-authenticated API with no cookie session.
|
|
499
|
+
def defer_binding_for(request)
|
|
500
|
+
session = request.session
|
|
501
|
+
return nil unless session.respond_to?(:exists?) && session.exists?
|
|
502
|
+
|
|
503
|
+
session.id&.to_s
|
|
504
|
+
rescue StandardError
|
|
505
|
+
nil
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# --- pgbus capability gates (issue #165) ---------------------------
|
|
509
|
+
# Runtime capability detection, never `defined?(Pgbus)` alone or a
|
|
510
|
+
# version string (the core optionality invariant): pgbus < 0.9.2 also
|
|
511
|
+
# defines ::Pgbus, so the Streams gate probes the ACTUAL keyword.
|
|
512
|
+
|
|
513
|
+
# Necessary but NOT sufficient — the Streams entrypoint exists.
|
|
514
|
+
def pgbus?
|
|
515
|
+
return false unless defined?(::Pgbus)
|
|
516
|
+
|
|
517
|
+
::Pgbus.respond_to?(:stream)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# The reactive-Streams capability: Stream#broadcast accepts :exclude
|
|
521
|
+
# (the pgbus >= 0.9.2 shape). This is the gate that prevents
|
|
522
|
+
# `ArgumentError: unknown keyword :exclude` on an old pgbus.
|
|
523
|
+
def pgbus_streams?
|
|
524
|
+
return false unless pgbus?
|
|
525
|
+
return false unless defined?(::Pgbus::Streams::Stream)
|
|
526
|
+
|
|
527
|
+
::Pgbus::Streams::Stream.instance_method(:broadcast)
|
|
528
|
+
.parameters.any? { |_type, name| name == :exclude }
|
|
529
|
+
rescue ::NameError
|
|
530
|
+
false
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# Everything the defer PUSH lane needs at runtime: streams-capable pgbus,
|
|
534
|
+
# server-side signed-src minting (SignedName.sign — the element's src is
|
|
535
|
+
# built off-request), and ActiveJob to run the render off the request
|
|
536
|
+
# thread. Anything missing → the pull lane (which is always available).
|
|
537
|
+
def defer_push_capable?
|
|
538
|
+
return false unless pgbus_streams?
|
|
539
|
+
return false unless defined?(::Pgbus::Streams::SignedName) &&
|
|
540
|
+
::Pgbus::Streams::SignedName.respond_to?(:sign)
|
|
541
|
+
|
|
542
|
+
defined?(::ActiveJob::Base) ? true : false
|
|
543
|
+
end
|
|
544
|
+
|
|
310
545
|
# DOM id of the host-app container a Response#flash appends into.
|
|
311
546
|
# Default "flash"; override to match your layout's flash region.
|
|
312
547
|
def flash_target
|
|
@@ -340,7 +575,10 @@ module Phlex
|
|
|
340
575
|
# access (dom_id/url_for/t/csrf). Used for a Phlex component embedded as
|
|
341
576
|
# Response#with content.
|
|
342
577
|
def render(component)
|
|
343
|
-
|
|
578
|
+
# A machinery render (issue #165): a reactive_lazy component embedded
|
|
579
|
+
# in a Response/flash renders its REAL template — the lazy shell is
|
|
580
|
+
# for the page-embedded initial mount only.
|
|
581
|
+
Phlex::Reactive::Defer.with_real_render { component.render_in(off_request_view_context) }
|
|
344
582
|
end
|
|
345
583
|
|
|
346
584
|
# A Turbo::Streams::TagBuilder bound to an off-request view context, used
|
|
@@ -597,8 +835,8 @@ loader.tag = "phlex-reactive"
|
|
|
597
835
|
lib = File.expand_path("..", __dir__)
|
|
598
836
|
loader.push_dir(lib)
|
|
599
837
|
# js.rb defines JS and component/dsl.rb defines Component::DSL (acronyms), not
|
|
600
|
-
# the default-inflected `Js`/`Dsl`.
|
|
601
|
-
loader.inflector.inflect("js" => "JS", "dsl" => "DSL")
|
|
838
|
+
# the default-inflected `Js`/`Dsl`. mcp.rb defines MCP (issue #168).
|
|
839
|
+
loader.inflector.inflect("js" => "JS", "dsl" => "DSL", "mcp" => "MCP")
|
|
602
840
|
# The gem-name shim (`require "phlex-reactive"`) is a plain require, not a
|
|
603
841
|
# managed file.
|
|
604
842
|
loader.ignore("#{lib}/phlex-reactive.rb")
|
|
@@ -615,9 +853,21 @@ loader.ignore("#{lib}/generators")
|
|
|
615
853
|
# Zeitwerk's naming — test_helpers.rb requires it explicitly (only when RSpec is
|
|
616
854
|
# present), and the loader must ignore it.
|
|
617
855
|
loader.ignore("#{lib}/phlex/reactive/test_helpers/matchers.rb")
|
|
856
|
+
# The MCP diagnostic tool tree (issue #168) subclasses the OPTIONAL `mcp` gem's
|
|
857
|
+
# constants (MCP::Tool) at class-definition time, so the whole mcp/ subdirectory
|
|
858
|
+
# must stay out of the autoloader — Phlex::Reactive::MCP.load! requires it in
|
|
859
|
+
# dependency order only when the gem is present. mcp.rb itself (the load! entry
|
|
860
|
+
# point) references no gem constant at load time, so Zeitwerk autoloads it
|
|
861
|
+
# normally (inflected MCP above); only the gem-dependent subtree is ignored.
|
|
862
|
+
loader.ignore("#{lib}/phlex/reactive/mcp")
|
|
618
863
|
# The engine is required explicitly below (only when Rails is present) and must
|
|
619
864
|
# not be eager-loaded before that.
|
|
620
865
|
loader.do_not_eager_load("#{__dir__}/reactive/engine.rb")
|
|
866
|
+
# The defer push-lane job subclasses ActiveJob::Base, which is NOT a gem
|
|
867
|
+
# dependency — eager-loading it in an app without ActiveJob would raise at
|
|
868
|
+
# boot. The constant autoloads on first reference, which only happens behind
|
|
869
|
+
# Phlex::Reactive.defer_push_capable? (that gate requires ActiveJob::Base).
|
|
870
|
+
loader.do_not_eager_load("#{__dir__}/reactive/deferred_render_job.rb")
|
|
621
871
|
loader.setup
|
|
622
872
|
|
|
623
873
|
require "phlex/reactive/engine" if defined?(Rails::Engine)
|