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

Class Method Summary collapse

Instance Method Summary collapse

Class Attribute Details

.registered_gatesObject (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_gatesObject (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.

Raises:



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.

Returns:

  • (Boolean)


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

Raises:



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

Raises:



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.remediation_subject_authorization_configured?
    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.remediation_represented_party_authorization_configured?
    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 authorize_clickwrap_external_action!(policy_key, **options, &)
  Clickwrap.authorize_external_action!(
    policy_key,
    **clickwrap_capture_options(policy_key, options),
    &
  )
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.user_facing_message
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, **options)
  Clickwrap.capture!(policy_key, **clickwrap_capture_options(policy_key, options))
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, **options, &)
  Clickwrap.capture_and!(policy_key, **clickwrap_capture_options(policy_key, options), &)
end

#clickwrap_authentication_contextObject

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_errorsObject

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.

Returns:

  • (Boolean)


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_refusalObject

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_submissionObject

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:, **options, &)
  resolved = clickwrap_capture_options(policy_key, options)
  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, **options)
  resolved = options.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:, **options, &)
  refuse_removed_prospective_actor_keyword!(options)

  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),
    **options,
    &
  )

  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
  )

  authorize_clickwrap_remediation_context!(
    policy_key,
    actor: clickwrap_current_actor,
    subject: context.subject,
    represented_party: context.represented_party
  )
  context
end