Module: Phlex::Reactive

Defined in:
lib/phlex/reactive.rb,
lib/phlex/reactive/js.rb,
lib/phlex/reactive/mcp.rb,
lib/phlex/reactive/defer.rb,
lib/phlex/reactive/reply.rb,
lib/phlex/reactive/doctor.rb,
lib/phlex/reactive/engine.rb,
lib/phlex/reactive/stream.rb,
lib/phlex/reactive/version.rb,
lib/phlex/reactive/response.rb,
lib/phlex/reactive/component.rb,
lib/phlex/reactive/inspector.rb,
lib/phlex/reactive/mcp/runner.rb,
lib/phlex/reactive/mcp/server.rb,
lib/phlex/reactive/streamable.rb,
lib/phlex/reactive/param_schema.rb,
lib/phlex/reactive/test_helpers.rb,
lib/phlex/reactive/authorization.rb,
lib/phlex/reactive/component/dsl.rb,
lib/phlex/reactive/mcp/base_tool.rb,
lib/phlex/reactive/component/lazy.rb,
lib/phlex/reactive/log_subscriber.rb,
lib/phlex/reactive/inspector/report.rb,
lib/phlex/reactive/component/helpers.rb,
lib/phlex/reactive/component/identity.rb,
lib/phlex/reactive/component/registry.rb,
lib/phlex/reactive/deferred_render_job.rb,
lib/phlex/reactive/mcp/tools/find_tool.rb,
lib/phlex/reactive/mcp/tools/config_tool.rb,
lib/phlex/reactive/mcp/tools/doctor_tool.rb,
lib/phlex/reactive/test_helpers/matchers.rb,
lib/phlex/reactive/mcp/tools/actions_tool.rb,
lib/phlex/reactive/mcp/tools/components_tool.rb,
app/controllers/phlex/reactive/actions_controller.rb,
lib/generators/phlex/reactive/claude/claude_generator.rb,
lib/generators/phlex/reactive/install/install_generator.rb,
lib/generators/phlex/reactive/component/component_generator.rb

Overview

phlex-reactive: reactive Phlex components for Rails.

Two cooperating mixins, one client runtime, one endpoint:

* Phlex::Reactive::Streamable — gives a component a stable `id` and class
methods to render itself as a Turbo Stream (`.replace`, `.append`, ...)
and to broadcast itself (`.broadcast_replace_to`, ...). The server->client
half (controller responses + background broadcasts).

* Phlex::Reactive::Component — declares client-invokable `action`s and
emits a signed identity token + the wiring the generic `reactive`
Stimulus controller needs. The client->server half (clicks, form input).

Both halves converge on ONE re-render unit: the component, targeted by its id. See the README for the mental model and examples.

Defined Under Namespace

Modules: Authorization, Component, Defer, Generators, Inspector, MCP, Streamable, TestHelpers Classes: ActionContext, ActionsController, AuthorizationNotVerified, DeferredRenderJob, Doctor, Engine, Error, InvalidToken, JS, LogSubscriber, ParamSchema, Reply, Response, Stream, UnknownParamType

Constant Summary collapse

IDENTITY_PURPOSE =

Purpose string bound into every identity token's signature so a token minted for phlex-reactive can't be replayed against another verifier use.

"phlex-reactive/identity"
DEFER_PURPOSE =

Purpose string for DEFER tokens (issue #165) — the short-TTL identity a reply.defer directive carries so the client can fetch the expensive render off the actor's critical path. A distinct purpose makes the two token families non-interchangeable BY SIGNATURE: an action token is rejected at the defer endpoint (it must not become a render oracle) and a defer token can never invoke an action (it carries no action grant).

"phlex-reactive/defer"
DEFER_TRANSPORTS =

The defer_transport values (issue #165): :auto picks push (pgbus durable one-shot stream + ActiveJob) when capable, else pull (parallel fetch); :fetch forces pull; :stream requests push but still degrades to pull with a warning when the capability is absent (degrade, never break).

%i[auto fetch stream].freeze
TOKEN_VERSION =

The current identity-token payload version (issue #111), stamped into every signed token under the "v" key. It exists so the NEXT breaking shape change (a rename, per-token expiry, a nonce) can upgrade tokens already in flight instead of breaking every open page at deploy. A token minted before this existed carries no "v" — treated as version 0 (today's shape). Bump this and register a register_token_upgrader(old_version) when you change the shape.

1
INSTRUMENTATION_NAMESPACE =

The ActiveSupport::Notifications namespace for the gem's hot-path events (issue #107): action.phlex_reactive, render.phlex_reactive, broadcast.phlex_reactive. APM tools (AppSignal, Datadog, Skylight) auto-subscribe to *.phlex_reactive and get component-level visibility. Payloads carry NAMES/outcome/sizes ONLY — never the token, params, or state.

"phlex_reactive"
ACTIONS_CONTROLLER =

The controller a correctly-mounted action path resolves to. Used by the route guard below.

"phlex/reactive/actions"
VERSION =
"0.9.5"

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.action_pathObject



142
143
144
# File 'lib/phlex/reactive.rb', line 142

def action_path
  @action_path ||= "/reactive/actions"
end

.authorization_errorsObject

Exception classes the action endpoint renders as 403. Append your authorization library's error (Pundit::NotAuthorizedError, ActionPolicy::Unauthorized, ...).



134
135
136
# File 'lib/phlex/reactive.rb', line 134

def authorization_errors
  @authorization_errors
end

.authorization_methodsObject



219
220
221
222
223
# File 'lib/phlex/reactive.rb', line 219

def authorization_methods
  return @authorization_methods if defined?(@authorization_methods)

  %i[authorize! authorize allowed_to?]
end

.base_controller_nameObject



650
651
652
# File 'lib/phlex/reactive.rb', line 650

def base_controller_name
  @base_controller_name ||= "ActionController::Base"
end

.debugObject



188
189
190
191
192
# File 'lib/phlex/reactive.rb', line 188

def debug
  return @debug if defined?(@debug)

  false
end

.defer_job_queueObject



411
412
413
# File 'lib/phlex/reactive.rb', line 411

def defer_job_queue
  @defer_job_queue ||= "default"
end

.defer_pathObject



381
382
383
# File 'lib/phlex/reactive.rb', line 381

def defer_path
  @defer_path ||= "/reactive/defer"
end

.defer_token_ttlObject



373
374
375
# File 'lib/phlex/reactive.rb', line 373

def defer_token_ttl
  @defer_token_ttl ||= 120
end

.error_flashObject

A user-visible flash rendered on every endpoint rescue path (issue #100). Default nil = today's behavior (a bare head, or the verbose_errors plain-text diagnostic). Set a lambda ->(kind) { "message" } (kind is :tampered/:unknown_class/:not_reactive_class/:forbidden/:not_found) and the ActionsController ALSO renders a turbo-stream flash into flash_target — at the SAME status it returns today (statuses never change). Composes with verbose_errors: the turbo-stream flash wins the response body, the diagnostic still goes to the log.



561
562
563
# File 'lib/phlex/reactive.rb', line 561

def error_flash
  @error_flash
end

.flash_componentObject

Component class used to render STRING flash content (issue #77). Instantiated as flash_component.new(level:, content:) and rendered through the existing render path, replacing the built-in

wrapper. Default nil (the built-in wrapper). Phlex component content passed to reply.flash always renders verbatim and bypasses this.


569
570
571
# File 'lib/phlex/reactive.rb', line 569

def flash_component
  @flash_component
end

.flash_targetObject

DOM id of the host-app container a Response#flash appends into. Default "flash"; override to match your layout's flash region.



547
548
549
# File 'lib/phlex/reactive.rb', line 547

def flash_target
  @flash_target ||= "flash"
end

.log_eventsObject



170
171
172
173
174
# File 'lib/phlex/reactive.rb', line 170

def log_events
  return @log_events if defined?(@log_events)

  false
end

.rendererObject



337
338
339
# File 'lib/phlex/reactive.rb', line 337

def renderer
  @renderer ||= defined?(::ActionController::Base) ? ::ActionController::Base : nil
end

.verbose_errorsObject



157
158
159
160
161
# File 'lib/phlex/reactive.rb', line 157

def verbose_errors
  return @verbose_errors if defined?(@verbose_errors)

  defined?(::Rails.env) && ::Rails.env.local?
end

.verifierObject



333
334
335
# File 'lib/phlex/reactive.rb', line 333

def verifier
  @verifier ||= default_verifier
end

.verify_authorizedObject



204
205
206
207
208
# File 'lib/phlex/reactive.rb', line 204

def verify_authorized
  return @verify_authorized if defined?(@verify_authorized)

  true
end

Class Method Details

.action_route_ok?(path = action_path) ⇒ Boolean

True when a POST to path resolves to the gem's ActionsController. A host catch-all route (match "*path", ...) appended above the engine's route SHADOWS it, so every reactive POST 404s and none of the controller runs — the opaque "is the endpoint even mounted?" failure (issue #26). A false here is the signal. Returns false (not raise) when nothing matches.

Returns:

  • (Boolean)


749
750
751
752
753
754
755
756
757
758
759
760
761
# File 'lib/phlex/reactive.rb', line 749

def action_route_ok?(path = action_path)
  return false unless defined?(::Rails) && ::Rails.application

  # At after_initialize (when the boot guard runs) the host's routes may not
  # be drawn yet, so recognize_path would see an incomplete set and report a
  # false shadow. Force-load routes first (idempotent — no-op if already
  # loaded), so the check is correct whether it runs at boot or at runtime.
  ensure_routes_loaded
  recognized = ::Rails.application.routes.recognize_path(path, method: :post)
  recognized[:controller] == ACTIONS_CONTROLLER
rescue ActionController::RoutingError, ActiveRecord::RecordNotFound
  false
end

.around_action(&block) ⇒ Object

Register a COMPONENT-AWARE around_action wrapper (issue #112). The block is folded into the endpoint BETWEEN with_connection_id and the action's transaction — so it sees the resolved component instance, the declared action name, and the coerced params (an ActionContext), and a rejection NEVER opens a transaction. This is the seam for audit logging, component-aware rate limiting, and assertions; the base controller (base_controller_name) remains the seam for HTTP-layer concerns (auth, CSRF, coarse per-IP rate limiting) that don't need the resolved action.

Phlex::Reactive.around_action do |ctx, &action|
RateLimiter.check!(ctx.request.remote_ip, ctx.action_name) # raise -> 403
result = action.call
AuditLog.record!(actor: Current.user, action: ctx.action_name)
result   # <- REQUIRED: return the continuation's value
end

CONTRACT — each wrapper MUST return action.call's value. The endpoint type-checks the action's return for a Phlex::Reactive::Response; a wrapper that returns its logger's result instead silently downgrades every reply to the implicit self-replace. Wrappers nest in registration order with the LAST-registered outermost. Register in an initializer.

Raises:

  • (::ArgumentError)


313
314
315
316
317
# File 'lib/phlex/reactive.rb', line 313

def around_action(&block)
  raise ::ArgumentError, "Phlex::Reactive.around_action requires a block" unless block

  around_actions << block
end

.around_actionsObject

The registered around_action wrappers, oldest first. The endpoint folds them so the last-registered runs outermost; an empty stack is the default hot path (the controller short-circuits with return yield).



322
323
324
# File 'lib/phlex/reactive.rb', line 322

def around_actions
  @around_actions ||= []
end

.base_controllerObject



654
655
656
# File 'lib/phlex/reactive.rb', line 654

def base_controller
  base_controller_name.constantize
end

.current_connection_idObject

The acting client's SSE connection id during an action, or nil. Set by the ActionsController from the X-Pgbus-Connection header. A component action passes exclude: Phlex::Reactive.current_connection_id (or the reactive_connection_id helper) to suppress the actor's own broadcast echo.



728
729
730
# File 'lib/phlex/reactive.rb', line 728

def current_connection_id
  Thread.current[:phlex_reactive_connection_id]
end

.current_defer_bindingObject

The acting client's defer binding during a request, or nil. Set by the ActionsController from defer_binding_for(request) — threaded exactly like current_connection_id.



473
474
475
# File 'lib/phlex/reactive.rb', line 473

def current_defer_binding
  Thread.current[:phlex_reactive_defer_binding]
end

.defer_binding_for(request) ⇒ Object

Resolve a request to its defer binding: the id of an ALREADY-PERSISTED session, else nil. The exists? gate is load-bearing — a bare session.id LAZILY generates an id even for an empty, never-written session, and that id is NOT persisted (no Set-Cookie), so two requests for the same read-only page get DIFFERENT lazy ids and a bound token minted on one is rejected on the other. Only a persisted session (an authenticated app wrote one at login) has a stable id across the mint (page render / action) and the verify (defer endpoint) — exactly the case where cross-actor exchange is the real threat. A read-only page with no session mints + verifies UNBOUND consistently (the TTL + authorize! remain the bound). Tolerant of a store that lazily raises: degrade to nil (unbound), never a 500. Override to bind to your own actor identity (a stable user id, an API-token digest) — recommended for a token-authenticated API with no cookie session.



499
500
501
502
503
504
505
506
# File 'lib/phlex/reactive.rb', line 499

def defer_binding_for(request)
  session = request.session
  return nil unless session.respond_to?(:exists?) && session.exists?

  session.id&.to_s
rescue StandardError
  nil
end

.defer_purposeObject

The purpose string for the CURRENT actor's defer tokens: the base DEFER_PURPOSE, plus the binding when one is present. Binding is folded into the PURPOSE (not the payload) so it's part of the signature's domain separation — a mismatched binding is a verification failure, not a value the endpoint must remember to compare.



465
466
467
468
# File 'lib/phlex/reactive.rb', line 465

def defer_purpose
  binding = current_defer_binding
  binding ? "#{DEFER_PURPOSE}/#{binding}" : DEFER_PURPOSE
end

.defer_push_capable?Boolean

Everything the defer PUSH lane needs at runtime: streams-capable pgbus, server-side signed-src minting (SignedName.sign — the element's src is built off-request), and ActiveJob to run the render off the request thread. Anything missing → the pull lane (which is always available).

Returns:

  • (Boolean)


537
538
539
540
541
542
543
# File 'lib/phlex/reactive.rb', line 537

def defer_push_capable?
  return false unless pgbus_streams?
  return false unless defined?(::Pgbus::Streams::SignedName) &&
                      ::Pgbus::Streams::SignedName.respond_to?(:sign)

  defined?(::ActiveJob::Base) ? true : false
end

.defer_transportObject

How deferred segments reach the actor: :auto (push iff capable, else pull), :fetch (always pull), :stream (push; degrades to pull with a warning when the capability is absent). Validated at assignment — a typo'd transport must fail at the initializer, not silently at reply time. nil resets to the default.



390
391
392
# File 'lib/phlex/reactive.rb', line 390

def defer_transport
  @defer_transport ||= :auto
end

.defer_transport=(value) ⇒ Object



394
395
396
397
398
399
400
401
402
403
# File 'lib/phlex/reactive.rb', line 394

def defer_transport=(value)
  value = value&.to_sym
  unless value.nil? || DEFER_TRANSPORTS.include?(value)
    raise ::ArgumentError,
      "Phlex::Reactive.defer_transport must be one of #{DEFER_TRANSPORTS.map(&:inspect).join(", ")} " \
      "(got #{value.inspect})"
  end

  @defer_transport = value
end

.freeze_param_types!Object

Freeze the registry so no further param_type registration is accepted — the initializer-only contract. Called by the engine's after_initialize. Idempotent.



266
267
268
269
# File 'lib/phlex/reactive.rb', line 266

def freeze_param_types!
  param_types.freeze
  @param_types_frozen = true
end

.instrument(event, payload = {}) ⇒ Object

Emit an <event>.phlex_reactive ActiveSupport::Notifications event around a block, yielding the mutable payload so a rescue can finalize the outcome (issue #107). ASN.instrument is cheap when nothing is subscribed (the hot paths depend on this — proven by the render bench), so this wraps the render/broadcast/action paths unconditionally. The payload must carry NAMES/outcome/sizes ONLY — never token/params/state.



288
289
290
# File 'lib/phlex/reactive.rb', line 288

def instrument(event, payload = {}, &)
  ::ActiveSupport::Notifications.instrument("#{event}.#{INSTRUMENTATION_NAMESPACE}", payload, &)
end

.off_request_view_contextObject

The off-request view context for the current thread, built once and reused for both the stream builder and standalone component renders. Cached PER THREAD, not per process: an ActionView context carries mutable output_buffer/view_flow state (render_in's capture swaps it), so sharing one instance across threads can interleave content on a threaded server. Rebuilt when the renderer object changes or the generation is bumped (reset_stream_builder! / Rails code reload), so a reloaded controller is never served stale.



604
605
606
# File 'lib/phlex/reactive.rb', line 604

def off_request_view_context
  off_request_view_context_cache[:view_context]
end

.off_request_view_context_generationObject



624
625
626
# File 'lib/phlex/reactive.rb', line 624

def off_request_view_context_generation
  @off_request_view_context_generation ||= 0
end

.param_type(name, &block) ⇒ Object

Register an app-defined param type (issue #109). The block receives the raw client value and returns the coerced value — or Phlex::Reactive:: ParamSchema::DROP to reject it (the keyword default then applies, keeping the drop-don't-fabricate contract). Register in an INITIALIZER: the registry is frozen after boot (freeze_param_types!), so a runtime registration raises, and a schema referencing the type is validated at declaration.

# config/initializers/phlex_reactive.rb
Phlex::Reactive.param_type(:money) do |v|
/\A\d+(\.\d{1,2})?\z/.match?(v.to_s) ? BigDecimal(v) : Phlex::Reactive::ParamSchema::DROP
end
# then: action :charge, params: { amount: :money }

Raises:

  • (::ArgumentError)


238
239
240
241
242
243
244
245
246
247
248
# File 'lib/phlex/reactive.rb', line 238

def param_type(name, &block)
  raise ::ArgumentError, "Phlex::Reactive.param_type requires a block" unless block

  if param_types_frozen?
    raise Error, "Phlex::Reactive.param_type(#{name.inspect}) called after boot — the param-type " \
                 "registry is frozen once the app is initialized. Register custom param types in an " \
                 "initializer (config/initializers/phlex_reactive.rb)."
  end

  param_types[name.to_sym] = block
end

.param_type?(name) ⇒ Boolean

A declared type symbol is known iff it's a registry key. ParamSchema asks this at compile so an unknown symbol raises loudly at declaration.

Returns:

  • (Boolean)


259
260
261
# File 'lib/phlex/reactive.rb', line 259

def param_type?(name)
  param_types.key?(name.to_sym)
end

.param_typesObject

The param-type registry: Symbol => callable(value) -> coerced | DROP. Seeded lazily with the built-ins; ParamSchema.compile validates every declared type symbol against it, and #coerce dispatches through it.



253
254
255
# File 'lib/phlex/reactive.rb', line 253

def param_types
  @param_types ||= Phlex::Reactive::ParamSchema.built_in_types.dup
end

.param_types_frozen?Boolean

Returns:

  • (Boolean)


271
272
273
# File 'lib/phlex/reactive.rb', line 271

def param_types_frozen?
  @param_types_frozen ||= false
end

.pgbus?Boolean

Necessary but NOT sufficient — the Streams entrypoint exists.

Returns:

  • (Boolean)


514
515
516
517
518
# File 'lib/phlex/reactive.rb', line 514

def pgbus?
  return false unless defined?(::Pgbus)

  ::Pgbus.respond_to?(:stream)
end

.pgbus_streams?Boolean

The reactive-Streams capability: Stream#broadcast accepts :exclude (the pgbus >= 0.9.2 shape). This is the gate that prevents ArgumentError: unknown keyword :exclude on an old pgbus.

Returns:

  • (Boolean)


523
524
525
526
527
528
529
530
531
# File 'lib/phlex/reactive.rb', line 523

def pgbus_streams?
  return false unless pgbus?
  return false unless defined?(::Pgbus::Streams::Stream)

  ::Pgbus::Streams::Stream.instance_method(:broadcast)
    .parameters.any? { |_type, name| name == :exclude }
rescue ::NameError
  false
end

.register_token_upgrader(from_version, &block) ⇒ Object

Register an upgrader that rewrites a payload signed at from_version into the shape of from_version + 1 (issue #111). Register in load order at boot when you bump TOKEN_VERSION; the block receives the payload hash and returns the migrated hash (the "v" stamp is applied by upgrade_token, so the block only reshapes the data). Example, for a future count → n rename:

Phlex::Reactive.register_token_upgrader(0) do |payload|
payload.merge("s" => { "n" => payload.dig("s", "count") })
end

Raises:

  • (::ArgumentError)


706
707
708
709
710
# File 'lib/phlex/reactive.rb', line 706

def register_token_upgrader(from_version, &block)
  raise ::ArgumentError, "register_token_upgrader requires a block" unless block

  token_upgraders[Integer(from_version)] = block
end

.render(component) ⇒ Object

Render a Phlex component to HTML with a full (off-request) view context. Uses phlex-rails' #render_in against the memoized view context — a direct component.call that skips ActionController's renderer.render machinery (~2x faster, ~half the allocations), with the same HTML and full helper access (dom_id/url_for/t/csrf). Used for a Phlex component embedded as Response#with content.



577
578
579
580
581
582
# File 'lib/phlex/reactive.rb', line 577

def render(component)
  # A machinery render (issue #165): a reactive_lazy component embedded
  # in a Response/flash renders its REAL template — the lazy shell is
  # for the page-embedded initial mount only.
  Phlex::Reactive::Defer.with_real_render { component.render_in(off_request_view_context) }
end

.request_bound_view_context(controller_class) ⇒ Object

Build an off-request view context whose controller has a REAL request.

A bare controller.new.view_context (the naive off-request context) has request == nil, so any request-dependent helper raises undefined method 'env' for nil — form_authenticity_token, protect_against_forgery?, and host-aware URL helpers all read request.env (issue #42). We replicate exactly what ActionController::Renderer#render does to set up its mock request — build an ActionDispatch::Request from the renderer's env (which derives the host from the routes' default_url_options), bind the routes, and attach it — then return the controller's view context instead of rendering a template. The result keeps the 0.4.0 render_in speedup (no renderer.render machinery) while restoring the request those helpers need.



354
355
356
357
358
359
360
361
362
363
# File 'lib/phlex/reactive.rb', line 354

def request_bound_view_context(controller_class)
  ar_renderer = controller_class.renderer
  request = ::ActionDispatch::Request.new(ar_renderer.send(:env_for_request))
  request.routes = controller_class._routes

  instance = controller_class.new
  instance.set_request!(request)
  instance.set_response!(controller_class.make_response!(request))
  instance.view_context
end

.reset_around_actions!Object

Drop all registered around_action wrappers. For test isolation (the shipped hook — specs registering a temporary wrapper reset around every example); never called in production.



329
330
331
# File 'lib/phlex/reactive.rb', line 329

def reset_around_actions!
  @around_actions = []
end

.reset_param_types!Object

Drop the registry back to the built-ins and unfreeze it. For tests that register a temporary type; never called in production.



277
278
279
280
# File 'lib/phlex/reactive.rb', line 277

def reset_param_types!
  @param_types = Phlex::Reactive::ParamSchema.built_in_types.dup
  @param_types_frozen = false
end

.reset_stream_builder!Object Also known as: reset_flash_builder!

Invalidate the per-thread context + builder for ALL threads by bumping the generation; each thread rebuilds lazily on next use. Registered on Rails' reloader by the engine; also used by specs. Thread-safe (an integer bump, no shared structure to tear down). Renamed from reset_flash_builder! (issue #113); the old name stays a permanent alias below so the engine's to_prepare hook keeps working.



614
615
616
# File 'lib/phlex/reactive.rb', line 614

def reset_stream_builder!
  @off_request_view_context_generation = off_request_view_context_generation + 1
end

.reset_token_upgraders!Object

Drop all registered upgraders. For tests that register a temporary one.



719
720
721
# File 'lib/phlex/reactive.rb', line 719

def reset_token_upgraders!
  @token_upgraders = {}
end

.sign(payload) ⇒ Object

Signs a payload hash into an identity token, stamping the current TOKEN_VERSION (issue #111). The "v" key is the ONLY thing added — a from_identity that ignores unknown keys is unaffected.



671
672
673
# File 'lib/phlex/reactive.rb', line 671

def sign(payload)
  verifier.generate(payload.merge("v" => TOKEN_VERSION), purpose: IDENTITY_PURPOSE)
end

.sign_defer(payload, unbound: false) ⇒ Object

Signs a defer payload (issue #165): same shape + version stamp as the identity token, but purpose-scoped to DEFER_PURPOSE and expiring after defer_token_ttl. verify/verify_defer are therefore mutually exclusive by construction — see the DEFER_PURPOSE comment. SECURITY (the leaked-token exchange): the defer endpoint re-renders the real component, whose root carries a fresh NON-expiring identity (action) token — so a leaked defer token replayed by ANOTHER actor within its TTL would hand that actor a permanent action token for the same identity. The purpose is therefore ALSO scoped to the minting actor's binding (the session id, via with_defer_binding), so a defer token minted under binding A fails verification under binding B — the exchange can't cross actors. Unbound (no session — the ActionController::Base default) is the documented today-behavior; authorize! in the action stays the real authority in every case. Sign a defer token. unbound: true (the reactive_lazy channel) mints under the PLAIN DEFER_PURPOSE, never the /binding suffix — because a lazy shell renders during the PAGE render, on a fresh visit where the session doesn't exist yet (Rails establishes it DURING that response), so it can't be bound; and it lives in the actor's own page, a small leak surface. reply.defer tokens (the default) mint under the current actor's binding — they live in an action HTTP response that can transit proxies/ logs, the real cross-infrastructure leak vector. See docs/security + the README defer security note. authorize! in the action is the real authority for the harvested-action-token step in both cases.



439
440
441
442
# File 'lib/phlex/reactive.rb', line 439

def sign_defer(payload, unbound: false)
  purpose = unbound ? DEFER_PURPOSE : defer_purpose
  verifier.generate(payload.merge("v" => TOKEN_VERSION), purpose:, expires_in: defer_token_ttl)
end

.stream_builderObject Also known as: flash_builder

A Turbo::Streams::TagBuilder bound to an off-request view context, used to build standalone streams not tied to a specific component's id — a Response flash append, a reactive_collection row removal, a count companion update, an also_update companion. Cached PER THREAD alongside the context it's bound to (see off_request_view_context for why per-thread). Renamed from flash_builder (issue #113): the builder does far more than flashes, so the name misled. flash_builder stays a permanent alias below so the engine's to_prepare and app code keep working.



592
593
594
# File 'lib/phlex/reactive.rb', line 592

def stream_builder
  off_request_view_context_cache[:builder]
end

.token_upgradersObject

from_version => callable(payload) -> migrated payload. Sparse: an entry exists only for a version that actually changed shape.



714
715
716
# File 'lib/phlex/reactive.rb', line 714

def token_upgraders
  @token_upgraders ||= {}
end

.upgrade_token(payload) ⇒ Object

Migrate a verified payload from whatever version it was signed at up to TOKEN_VERSION (issue #111). Runs the registered upgraders oldest → current, then stamps the payload to the current version. Contract:

* no "v"  → version 0 (the shape from before versioning existed). With no
v0 upgrader registered this is a pure passthrough — introducing
versioning invalidates NOTHING already in flight.
* v == current → returned as-is (the hot path — one integer compare).
* v  > current → nil. A rolled-back deploy verifying a token minted by
NEWER code must not guess the newer shape; returning nil fails closed
through the endpoint's existing `|| raise(InvalidToken)` → 400.


685
686
687
688
689
690
691
692
693
694
695
# File 'lib/phlex/reactive.rb', line 685

def upgrade_token(payload)
  version = payload.fetch("v", 0)
  # "v" is inside the signed blob, so only our own key could produce a
  # malformed one — but fail closed rather than 500 on the comparison
  # (String vs Integer) or silently treat a negative "v" as legacy.
  return nil unless version.is_a?(::Integer) && version >= 0
  return payload if version == TOKEN_VERSION
  return nil if version > TOKEN_VERSION

  upgrade_from(payload, version)
end

.verify(token) ⇒ Object

Returns the verified, version-upgraded payload hash, or nil if the token is invalid (bad signature/purpose) OR carries a version this code doesn't understand. The single verify choke point (issue #111): after the signature check we run upgrade_token so an older-shape payload is migrated to the current shape before from_identity ever sees it.



663
664
665
666
# File 'lib/phlex/reactive.rb', line 663

def verify(token)
  payload = verifier.verified(token, purpose: IDENTITY_PURPOSE)
  payload && upgrade_token(payload)
end

.verify_defer(token) ⇒ Object

Returns the verified, version-upgraded defer payload, or nil when the token is tampered, expired, carries the wrong purpose (an action token OR a BOUND token minted under a DIFFERENT actor binding), or a version this code doesn't understand — all fail closed through the endpoint's InvalidToken → 400 path. Tries the current-binding purpose first (the reply.defer, actor-bound case), then falls back to the plain DEFER_PURPOSE (the unbound lazy case) — so an unbound lazy token resolves under ANY binding, while a bound token minted under session-A still fails under session-B (it was signed with /session-A, which matches NEITHER /session-B nor the plain purpose).



454
455
456
457
458
# File 'lib/phlex/reactive.rb', line 454

def verify_defer(token)
  payload = verifier.verified(token, purpose: defer_purpose)
  payload ||= verifier.verified(token, purpose: DEFER_PURPOSE) if current_defer_binding
  payload && upgrade_token(payload)
end

.warn_unless_action_route_mounted!(path: action_path, logger: default_logger) ⇒ Object

Log a clear warning (once, at boot) when the action path doesn't resolve to the gem controller — pointing at the catch-all shadow rather than leaving an adopter to guess. Called from the engine's after_initialize.



766
767
768
769
770
771
772
773
774
775
776
# File 'lib/phlex/reactive.rb', line 766

def warn_unless_action_route_mounted!(path: action_path, logger: default_logger)
  return if action_route_ok?(path)
  return unless logger

  logger.warn(
    "[phlex-reactive] POST #{path} does not resolve to #{ACTIONS_CONTROLLER}. " \
    "A host catch-all route (e.g. match \"*path\", ...) likely shadows it, so reactive " \
    "actions will 404. Exempt #{path.delete_prefix("/")} from the catch-all, or set " \
    "Phlex::Reactive.action_path to an unshadowed path. See the README integration section."
  )
end

.with_connection_id(connection_id) ⇒ Object



732
733
734
735
736
737
738
# File 'lib/phlex/reactive.rb', line 732

def with_connection_id(connection_id)
  previous = Thread.current[:phlex_reactive_connection_id]
  Thread.current[:phlex_reactive_connection_id] = connection_id.presence
  yield
ensure
  Thread.current[:phlex_reactive_connection_id] = previous
end

.with_defer_binding(binding) ⇒ Object



477
478
479
480
481
482
483
# File 'lib/phlex/reactive.rb', line 477

def with_defer_binding(binding)
  previous = Thread.current[:phlex_reactive_defer_binding]
  Thread.current[:phlex_reactive_defer_binding] = binding.presence
  yield
ensure
  Thread.current[:phlex_reactive_defer_binding] = previous
end