Module: Clickwrap::ControllerHelpers
- Extended by:
- ActiveSupport::Concern
- Defined in:
- lib/clickwrap/controller_helpers.rb
Overview
What a host controller gets for free: the submission envelope, the two
capture verbs, the authentication description, and the requires_clickwrap
gate.
These are thin on purpose. Every one of them forwards to the same public service API a background job, a console, or a native API endpoint would call; the controller layer only supplies the two things it is the only one holding — the current HTTP request and the parsed submission envelope. Nothing here decides anything about the evidence itself.
Defined Under Namespace
Classes: Gate
Constant Summary collapse
- REGISTRY_MUTEX =
Gates register themselves from controller class bodies, and Rails is free to autoload — and, in development, reload — controllers from more than one thread. Two of them writing a bare Hash at the same time is the kind of corruption that shows up once, in somebody else's production, as a gate that quietly stopped being registered. Registry and Identifier already take a lock for exactly this; so does this.
Writes take the lock and reads do not: reads happen on every gated request, and a snapshot taken a microsecond before a reload is a snapshot of a valid state either way.
Mutex.new
Class Attribute Summary collapse
-
.registered_gates ⇒ Object
readonly
Returns the value of attribute registered_gates.
-
.verified_gates ⇒ Object
readonly
Memoized successes only, so the worst a lost race can cost is one extra route scan — but the collection itself is still written under the lock.
Class Method Summary collapse
-
.assert_engine_can_resolve_document_links!(version = nil) ⇒ Object
The one refusal this gem cannot afford to soften.
-
.engine_is_mounted? ⇒ Boolean
Rails names a mounted engine's URL-helper proxy after the engine's railtie name, so
mounted_helpersdefiningclickwrapis the same fact as "this application mounted us" — and it is a fact Rails maintains rather than one inferred by walking route objects, whose wrapping has changed shape more than once across versions. -
.register_gate(policy_key, remediation_path:, gate:, subject_with: nil, acting_for_with: nil) ⇒ Object
Remembers a declared gate so it can be checked once the route set exists.
-
.resolve_current_actor(controller) ⇒ Object
The current actor, through the host's configured controller method.
-
.resolve_current_tenant(controller, policy) ⇒ Object
Resolves ambient tenant context through the policy that will bind it.
- .validate_gate_resolver!(name, resolver) ⇒ Object
-
.verify_registered_gates! ⇒ Object
Run from the engine's
after_initialize, when the host's routes are drawn and the answer is actually knowable. - .verify_remediation_is_possible!(policy_key, remediation_path:, gate:, subject_with: nil, acting_for_with: nil) ⇒ Object
Instance Method Summary collapse
-
#authorize_clickwrap_external_action!(policy_key, **options) ⇒ Object
Clickwrap.authorize_external_action!with the request, submission, actor, tenant, and authentication context this controller already knows. -
#capture_clickwrap(policy_key) ⇒ Object
The refusal-absorbing halves of the pair above, shaped like
savenext tosave!: a refused submission — stale presentation, unticked control, over-long answer — returns false instead of raising, with the per-statement message already inclickwrap_errors(the reference views re-render it beside the control) and the whole refusal inclickwrap_refusal, whoseuser_facing_messageis a complete sentence fit to put in front of a person:. -
#capture_clickwrap!(policy_key, **options) ⇒ Object
Clickwrap.capture!with the two things only a controller has. - #capture_clickwrap_and(policy_key) ⇒ Object
-
#capture_clickwrap_and!(policy_key, **options) ⇒ Object
Clickwrap.capture_and!with the same defaults. -
#clickwrap_authentication_context ⇒ Object
Whatever the host chose to record about how this request was authenticated.
-
#clickwrap_document_version_path_for_presentation(version, declared_link: nil) ⇒ Object
The exact URL offered beside a Clickwrap control.
-
#clickwrap_errors ⇒ Object
Server-side validation errors from the last capture attempt in this request, keyed by statement.
-
#clickwrap_hotwire_native_request? ⇒ Boolean
False whenever the host has no Hotwire Native integration at all — the predicate is turbo-rails' own, and its absence means no native app.
-
#clickwrap_refusal ⇒ Object
The refusal the last non-bang helper in this request absorbed, or nil.
-
#clickwrap_submission ⇒ Object
The submission envelope this request carried: a signed presentation token and the answers the manifest declared.
-
#create_represented_party_with_clickwrap(policy_key, represented_party:, **options) ⇒ Object
Creates a new represented party (for example, an organization) and its authority evidence as one transaction.
-
#present_clickwrap(policy_key, **options) ⇒ Object
Clickwrap.presentwith the same actor, tenant, and locale defaults the controller capture helpers use. - #register_with_clickwrap(policy_key, user:) ⇒ Object
-
#register_with_clickwrap!(policy_key, user:, **options) ⇒ Object
Signup, for Rails' own authentication generator or any hand-rolled registration door.
-
#resolve_clickwrap_remediation!(policy_key, token: ) ⇒ Object
Resolves and re-authorizes the signed context handed to a custom
remediation_path:.
Class Attribute Details
.registered_gates ⇒ Object (readonly)
Returns the value of attribute registered_gates.
141 142 143 |
# File 'lib/clickwrap/controller_helpers.rb', line 141 def registered_gates @registered_gates end |
.verified_gates ⇒ Object (readonly)
Memoized successes only, so the worst a lost race can cost is one extra route scan — but the collection itself is still written under the lock.
218 219 220 |
# File 'lib/clickwrap/controller_helpers.rb', line 218 def verified_gates @verified_gates end |
Class Method Details
.assert_engine_can_resolve_document_links!(version = nil) ⇒ Object
The one refusal this gem cannot afford to soften. Both paths that build
a document link — the presenter's own fallback and a controller with no
clickwrap mounted-helper proxy — end at the engine's prefix-less URL
helpers, which answer with a path that resolves to nothing on an
application that never mounted the engine.
That path does not merely render badly. It is signed into the presentation manifest, digested, and recorded as the exact document the person was offered, so the evidence would cite a 404 for as long as it is kept. Nothing downstream can detect that later: the digest is over the wrong link, and it is perfectly valid.
231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 |
# File 'lib/clickwrap/controller_helpers.rb', line 231 def assert_engine_can_resolve_document_links!(version = nil) return if engine_is_mounted? subject = version ? "document version #{version.id}" : "this document" raise ConfigurationError, "Clickwrap will not sign a document link that resolves to nothing. The link for " \ "#{subject} can only be built from Clickwrap::Engine's own routes, and this " \ "application does not mount the engine — so the URL would 404, and it would be " \ "signed into the presentation manifest and kept as the exact document that was " \ "offered. Mount the engine:\n\n " \ "mount Clickwrap::Engine => \"/agreements\"\n\n" \ "or route the documents yourself and bind that route into every presentation:\n\n " \ "form.clickwrap :signup, document_version_path_with: " \ "->(version) { legal_document_path(version.id) }" end |
.engine_is_mounted? ⇒ Boolean
Rails names a mounted engine's URL-helper proxy after the engine's
railtie name, so mounted_helpers defining clickwrap is the same fact
as "this application mounted us" — and it is a fact Rails maintains
rather than one inferred by walking route objects, whose wrapping has
changed shape more than once across versions. The route scan stays as a
second opinion for anything unusual.
207 208 209 210 211 212 213 214 |
# File 'lib/clickwrap/controller_helpers.rb', line 207 def engine_is_mounted? helpers = ::Rails.application.routes.mounted_helpers return true if helpers&.method_defined?(:clickwrap) ::Rails.application.routes.routes.any? { |route| mounts_clickwrap_engine?(route) } rescue StandardError false end |
.register_gate(policy_key, remediation_path:, gate:, subject_with: nil, acting_for_with: nil) ⇒ Object
Remembers a declared gate so it can be checked once the route set exists. Re-declaring the same gate (a development reload) replaces the entry rather than accumulating duplicates.
127 128 129 130 131 132 133 134 135 136 137 138 139 |
# File 'lib/clickwrap/controller_helpers.rb', line 127 def register_gate(policy_key, remediation_path:, gate:, subject_with: nil, acting_for_with: nil) entry = Gate.new( policy_key: policy_key.to_s, remediation_path: remediation_path, subject_with: subject_with, acting_for_with: acting_for_with, gate: gate ) REGISTRY_MUTEX.synchronize { registered_gates[[gate, policy_key.to_s]] = entry } entry end |
.resolve_current_actor(controller) ⇒ Object
The current actor, through the host's configured controller method. Shared by the host-facing helper and the engine's own controllers so there is exactly one answer to "who is acting" in the whole web layer.
100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/clickwrap/controller_helpers.rb', line 100 def resolve_current_actor(controller) method_name = Clickwrap.config.current_actor_method_name unless controller.respond_to?(method_name, true) raise ConfigurationError, "Clickwrap can't find ##{method_name} on #{controller.class.name}. Set " \ "`config.current_actor_method_name` in config/initializers/clickwrap.rb to the " \ "controller method that returns the signed-in " \ "#{Clickwrap.config.actor_class_name} (:current_user by default, which is what " \ "Devise and the Rails authentication generator both provide)." end controller.send(method_name) end |
.resolve_current_tenant(controller, policy) ⇒ Object
Resolves ambient tenant context through the policy that will bind it. This is shared by form rendering, custom presentations, captures, gates, and remediation tokens so they cannot disagree about whether a current organization belongs in this evidence identity.
119 120 121 122 |
# File 'lib/clickwrap/controller_helpers.rb', line 119 def resolve_current_tenant(controller, policy) candidate = Clickwrap.config.find_current_tenant_with.call(controller) policy.tenant_from_controller(candidate) end |
.validate_gate_resolver!(name, resolver) ⇒ Object
248 249 250 251 252 253 |
# File 'lib/clickwrap/controller_helpers.rb', line 248 def validate_gate_resolver!(name, resolver) return if resolver.nil? || resolver.is_a?(Symbol) || resolver.respond_to?(:call) raise ConfigurationError, "#{name} must be a controller method name (Symbol) or a callable, got #{resolver.inspect}." end |
.verify_registered_gates! ⇒ Object
Run from the engine's after_initialize, when the host's routes are
drawn and the answer is actually knowable. Iterates a snapshot, so a
controller autoloading on another thread mid-sweep cannot make this
raise about the collection instead of about a gate.
147 148 149 150 151 152 153 154 155 156 157 |
# File 'lib/clickwrap/controller_helpers.rb', line 147 def verify_registered_gates! REGISTRY_MUTEX.synchronize { registered_gates.values }.each do |entry| verify_remediation_is_possible!( entry.policy_key, remediation_path: entry.remediation_path, subject_with: entry.subject_with, acting_for_with: entry.acting_for_with, gate: entry.gate ) end end |
.verify_remediation_is_possible!(policy_key, remediation_path:, gate:, subject_with: nil, acting_for_with: nil) ⇒ Object
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 |
# File 'lib/clickwrap/controller_helpers.rb', line 159 def verify_remediation_is_possible!(policy_key, remediation_path:, gate:, subject_with: nil, acting_for_with: nil) if subject_with && !Clickwrap.config. raise ConfigurationError, "#{gate} :#{policy_key} resolves a subject with `subject_with:`, but the host " \ "has not configured `authorize_clickwrap_remediation_subject_with`. That " \ "server-side callback must decide whether the current actor may complete this " \ "policy for the resolved subject." end if acting_for_with && !Clickwrap.config. raise ConfigurationError, "#{gate} :#{policy_key} resolves a represented party with `acting_for_with:`, " \ "but the host has not configured " \ "`authorize_clickwrap_remediation_represented_party_with`." end return true if remediation_path return true unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application return true if load_host_routes == :unavailable # Only a success is remembered, and only to keep a gated request from # rescanning the route set every time. A mount that later disappears # would surface as an ordinary routing error, which is loud enough. return true if verified_gates.include?(policy_key.to_s) if engine_is_mounted? REGISTRY_MUTEX.synchronize { verified_gates << policy_key.to_s } return true end raise ConfigurationError, "#{gate} :#{policy_key} would have no way to be satisfied. A required gate needs " \ "somewhere to send the person it stops, and Clickwrap::Engine is not mounted, so " \ "there is no capture screen to redirect them to. Either mount it:\n\n " \ "mount Clickwrap::Engine => \"/agreements\"\n\n" \ "or point this gate at a page you own:\n\n " \ "requires_clickwrap :#{policy_key}, remediation_path: \"/support/agreements\"\n\n" \ "A gate that blocks an action with no route to unblocking it is a dead end, and " \ "Clickwrap will not compile one." end |
Instance Method Details
#authorize_clickwrap_external_action!(policy_key, **options) ⇒ Object
Clickwrap.authorize_external_action! with the request, submission,
actor, tenant, and authentication context this controller already knows.
The optional block is strictly local compatibility/domain work: it runs
once, inside the transaction that saves the evidence event and pending
outbox row, and receives pending_action: and pending_receipt:. The
provider call happens only after this helper returns.
366 367 368 369 370 371 372 |
# File 'lib/clickwrap/controller_helpers.rb', line 366 def (policy_key, **, &) Clickwrap.( policy_key, **(policy_key, ), & ) end |
#capture_clickwrap(policy_key) ⇒ Object
The refusal-absorbing halves of the pair above, shaped like save next
to save!: a refused submission — stale presentation, unticked control,
over-long answer — returns false instead of raising, with the
per-statement message already in clickwrap_errors (the reference views
re-render it beside the control) and the whole refusal in
clickwrap_refusal, whose user_facing_message is a complete sentence
fit to put in front of a person:
receipt = capture_clickwrap_and(:api_access) { current_user.enable_api_access! }
unless receipt
flash.now[:alert] = clickwrap_refusal.
return render :new, status: :unprocessable_entity
end
Only REFUSALS are absorbed. Infrastructure failures escape, and so do lifecycle conflicts (ReplayRejected, OneTimeAuthorizationConflict): "this was already done" needs a domain answer — usually "treat it as done" — that no generic rescue can supply honestly.
(The TEST helper of the same name, Clickwrap::TestHelpers#capture_clickwrap, deliberately follows the opposite convention: it is a factory verb that raises, because in a test a failed capture is a failed test. The two modules never share an object.)
341 342 343 344 345 346 |
# File 'lib/clickwrap/controller_helpers.rb', line 341 def capture_clickwrap(policy_key, **) capture_clickwrap!(policy_key, **) rescue Clickwrap::CaptureRefused => error absorb_clickwrap_capture_refusal(error) false end |
#capture_clickwrap!(policy_key, **options) ⇒ Object
Clickwrap.capture! with the two things only a controller has.
307 308 309 |
# File 'lib/clickwrap/controller_helpers.rb', line 307 def capture_clickwrap!(policy_key, **) Clickwrap.capture!(policy_key, **(policy_key, )) end |
#capture_clickwrap_and(policy_key) ⇒ Object
348 349 350 351 352 353 |
# File 'lib/clickwrap/controller_helpers.rb', line 348 def capture_clickwrap_and(policy_key, **, &) capture_clickwrap_and!(policy_key, **, &) rescue Clickwrap::CaptureRefused => error absorb_clickwrap_capture_refusal(error) false end |
#capture_clickwrap_and!(policy_key, **options) ⇒ Object
Clickwrap.capture_and! with the same defaults. The block runs inside the
same database transaction as the evidence write: if either fails, neither
happened.
314 315 316 |
# File 'lib/clickwrap/controller_helpers.rb', line 314 def capture_clickwrap_and!(policy_key, **, &) Clickwrap.capture_and!(policy_key, **(policy_key, ), &) end |
#clickwrap_authentication_context ⇒ Object
Whatever the host chose to record about how this request was authenticated. Clickwrap does not inspect the session itself: what counts as an authentication context is the host's decision, and the default is an empty hash rather than a guess.
516 517 518 |
# File 'lib/clickwrap/controller_helpers.rb', line 516 def clickwrap_authentication_context Clickwrap.config.describe_authentication_with.call(self) end |
#clickwrap_document_version_path_for_presentation(version, declared_link: nil) ⇒ Object
The exact URL offered beside a Clickwrap control. A document declared with
link: is read on the host's own page and that path is used as declared;
everything else gets the mounted engine route for the exact published
version. Either way, a Hotwire Native request under
config.hotwire_native_document_links = { open_in: :external_browser, … }
gets the same path absolutized against the canonical host, so the
document opens outside the WebView instead of destroying the screen the
form is on. A host that needs another reviewed routing layer can still
override this one method. Whatever this returns is both rendered and
signed into the presentation manifest, so the evidence never claims a
different target from the link.
declared_link: is passed by the presenter, which is the only thing that
knows which document this version belongs to; a controller calling this
directly for an engine link simply omits it.
405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 |
# File 'lib/clickwrap/controller_helpers.rb', line 405 def clickwrap_document_version_path_for_presentation(version, declared_link: nil) path = declared_link.presence || begin ControllerHelpers.assert_engine_can_resolve_document_links!(version) unless respond_to?(:clickwrap) clickwrap_engine_routes.document_version_path(version.id) end native_links = Clickwrap.config.hotwire_native_document_links if native_links && clickwrap_hotwire_native_request? && Clickwrap.config.hotwire_native_document_link_mode(self) == :external_browser "#{Clickwrap.config.hotwire_native_canonical_host}#{path}" else path end end |
#clickwrap_errors ⇒ Object
Server-side validation errors from the last capture attempt in this request, keyed by statement. The reference views read this to re-render a failed submission with the message beside the control it belongs to, with no JavaScript involved.
302 303 304 |
# File 'lib/clickwrap/controller_helpers.rb', line 302 def clickwrap_errors @clickwrap_errors ||= {} end |
#clickwrap_hotwire_native_request? ⇒ Boolean
False whenever the host has no Hotwire Native integration at all — the predicate is turbo-rails' own, and its absence means no native app.
423 424 425 |
# File 'lib/clickwrap/controller_helpers.rb', line 423 def clickwrap_hotwire_native_request? respond_to?(:hotwire_native_app?, true) && send(:hotwire_native_app?) end |
#clickwrap_refusal ⇒ Object
The refusal the last non-bang helper in this request absorbed, or nil.
356 357 358 |
# File 'lib/clickwrap/controller_helpers.rb', line 356 def clickwrap_refusal @clickwrap_refusal end |
#clickwrap_submission ⇒ Object
The submission envelope this request carried: a signed presentation token and the answers the manifest declared. Memoized because reading it twice in one action should not parse it twice.
294 295 296 |
# File 'lib/clickwrap/controller_helpers.rb', line 294 def clickwrap_submission @clickwrap_submission ||= Submission.from_params(params) end |
#create_represented_party_with_clickwrap(policy_key, represented_party:, **options) ⇒ Object
Creates a new represented party (for example, an organization) and its
authority evidence as one transaction. The form can pass the same new
record as acting_for:; this helper owns the server-side browser-flow
binding and clears it only after durable commit.
497 498 499 500 501 502 503 504 505 506 507 508 509 510 |
# File 'lib/clickwrap/controller_helpers.rb', line 497 def create_represented_party_with_clickwrap(policy_key, represented_party:, **, &) resolved = (policy_key, ) result = Clickwrap.create_represented_party!( policy_key, represented_party: represented_party, represented_party_creation_flow_id: clickwrap_represented_party_creation_flow_id(policy_key), **resolved, & ) clear_clickwrap_represented_party_creation_flow_when_committed(result, policy_key) result end |
#present_clickwrap(policy_key, **options) ⇒ Object
Clickwrap.present with the same actor, tenant, and locale defaults the
controller capture helpers use. Custom views should call this instead of
manually repeating ambient context, which keeps GET and POST binding
identical by construction.
378 379 380 381 382 383 384 385 386 387 388 |
# File 'lib/clickwrap/controller_helpers.rb', line 378 def present_clickwrap(policy_key, **) resolved = .dup resolved[:actor] = clickwrap_current_actor unless resolved.key?(:actor) resolved[:tenant] = clickwrap_current_tenant(policy_key) unless resolved.key?(:tenant) resolved[:locale] = I18n.locale unless resolved.key?(:locale) resolved[:default_document_version_path_with] ||= lambda do |version, declared_link| clickwrap_document_version_path_for_presentation(version, declared_link: declared_link) end Clickwrap.present(policy_key, **resolved) end |
#register_with_clickwrap(policy_key, user:) ⇒ Object
482 483 484 485 486 487 488 489 490 491 |
# File 'lib/clickwrap/controller_helpers.rb', line 482 def register_with_clickwrap(policy_key, user:, **, &) register_with_clickwrap!(policy_key, user: user, **, &) rescue *Clickwrap::Registration::REFUSALS => error @clickwrap_refusal = Clickwrap::Registration.absorb_refusal( error, resource: user, clickwrap_errors: clickwrap_errors ) false end |
#register_with_clickwrap!(policy_key, user:, **options) ⇒ Object
Signup, for Rails' own authentication generator or any hand-rolled
registration door. The pair works exactly like save and save!:
# Absorbs refusals: a stale presentation, an unticked control, or a
# failed validation paints the same human sentences the Devise adapter
# uses — inline via clickwrap_errors and once on the record's :base —
# and returns false, ready for `render :new, status: :unprocessable_entity`.
unless register_with_clickwrap(:signup, user: @user) { @user.save! }
return render :new, status: :unprocessable_entity
end
# Raises on refusal, for flows that handle the exceptions themselves:
register_with_clickwrap!(:signup, user: @user) { @user.save! }
Either way, the account and the evidence that authorized creating it commit together, and an infrastructure failure (EventWriteFailed) always escapes from BOTH forms — a broken database is not a refusal to dress up as validation, and the sign-in, the welcome email, and the redirect that would normally follow simply do not happen. That is the difference between a refused signup and a live account nobody can explain.
user: is the record the door is about to create. It is spelled user:
because that is what it is called in every signup controller ever
written; pass your actor here whatever its class is actually named.
451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 |
# File 'lib/clickwrap/controller_helpers.rb', line 451 def register_with_clickwrap!(policy_key, user:, **, &) refuse_removed_prospective_actor_keyword!() result = Clickwrap::Registration.perform( policy_key, prospective_actor: user, http_request: request, submission: clickwrap_submission, tenant: clickwrap_current_tenant(policy_key), registration_flow_id: clickwrap_registration_flow_id(policy_key), **, & ) clear_clickwrap_registration_flow_when_committed(result, policy_key) result end |
#resolve_clickwrap_remediation!(policy_key, token: ) ⇒ Object
Resolves and re-authorizes the signed context handed to a custom
remediation_path:. The returned object exposes subject,
represented_party, and return_to; pass the first two to both
presentation and capture. No browser-owned id needs to be permitted.
524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 |
# File 'lib/clickwrap/controller_helpers.rb', line 524 def resolve_clickwrap_remediation!(policy_key, token: params[:remediation_token]) context = RemediationToken.resolve!( token, policy: Clickwrap.policy!(policy_key), actor: clickwrap_current_actor ) ( policy_key, actor: clickwrap_current_actor, subject: context.subject, represented_party: context.represented_party ) context end |