clickwrap 0.0.0 → 0.1.0

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.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +90 -0
  3. data/CHANGELOG.md +612 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +241 -0
  7. data/app/controllers/clickwrap/application_controller.rb +79 -0
  8. data/app/controllers/clickwrap/captures_controller.rb +145 -0
  9. data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
  10. data/app/controllers/clickwrap/receipts_controller.rb +115 -0
  11. data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
  12. data/app/helpers/clickwrap/engine_helper.rb +97 -0
  13. data/app/views/clickwrap/captures/show.html.erb +34 -0
  14. data/app/views/clickwrap/receipts/index.html.erb +38 -0
  15. data/app/views/clickwrap/receipts/show.html.erb +91 -0
  16. data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
  17. data/app/views/clickwrap/shared/_fields.html.erb +100 -0
  18. data/app/views/clickwrap/shared/_statement.html.erb +105 -0
  19. data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
  20. data/config/locales/en.yml +160 -0
  21. data/config/locales/es.yml +138 -0
  22. data/config/routes.rb +41 -0
  23. data/exe/clickwrap +374 -0
  24. data/guides/README.md +30 -0
  25. data/guides/accessibility.md +249 -0
  26. data/guides/consent-and-lifecycle.md +299 -0
  27. data/guides/integrating.md +610 -0
  28. data/guides/integrity.md +212 -0
  29. data/guides/migrating.md +335 -0
  30. data/guides/naming.md +320 -0
  31. data/guides/organizations.md +320 -0
  32. data/guides/receipts-and-verification.md +415 -0
  33. data/guides/request-evidence.md +512 -0
  34. data/guides/retention-and-legal-holds.md +438 -0
  35. data/lib/clickwrap/actor_proxy.rb +147 -0
  36. data/lib/clickwrap/anonymous_actor.rb +47 -0
  37. data/lib/clickwrap/authority.rb +174 -0
  38. data/lib/clickwrap/canonical_json.rb +216 -0
  39. data/lib/clickwrap/capture/event_builder.rb +220 -0
  40. data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
  41. data/lib/clickwrap/capture.rb +650 -0
  42. data/lib/clickwrap/configuration.rb +1129 -0
  43. data/lib/clickwrap/controller_helpers.rb +758 -0
  44. data/lib/clickwrap/current_state.rb +282 -0
  45. data/lib/clickwrap/digest.rb +125 -0
  46. data/lib/clickwrap/doctor.rb +418 -0
  47. data/lib/clickwrap/document_definition.rb +255 -0
  48. data/lib/clickwrap/document_renderer.rb +83 -0
  49. data/lib/clickwrap/document_renderers/markdown.rb +175 -0
  50. data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
  51. data/lib/clickwrap/dsl/policy_builder.rb +462 -0
  52. data/lib/clickwrap/dsl/retention_builder.rb +89 -0
  53. data/lib/clickwrap/durable_commit_callback.rb +37 -0
  54. data/lib/clickwrap/engine.rb +184 -0
  55. data/lib/clickwrap/errors.rb +181 -0
  56. data/lib/clickwrap/form_builder_extensions.rb +341 -0
  57. data/lib/clickwrap/front_matter.rb +67 -0
  58. data/lib/clickwrap/identifier.rb +112 -0
  59. data/lib/clickwrap/import/external_receipt.rb +241 -0
  60. data/lib/clickwrap/import/fine_print.rb +290 -0
  61. data/lib/clickwrap/import/legacy.rb +450 -0
  62. data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
  63. data/lib/clickwrap/integrity/anchor.rb +130 -0
  64. data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
  65. data/lib/clickwrap/integrity/attestor.rb +221 -0
  66. data/lib/clickwrap/integrity/chain.rb +313 -0
  67. data/lib/clickwrap/integrity/timestamp.rb +143 -0
  68. data/lib/clickwrap/ip_geolocation/location.rb +112 -0
  69. data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
  70. data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
  71. data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
  72. data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
  73. data/lib/clickwrap/ip_geolocation.rb +16 -0
  74. data/lib/clickwrap/lifecycle.rb +534 -0
  75. data/lib/clickwrap/linter.rb +382 -0
  76. data/lib/clickwrap/localized_text.rb +101 -0
  77. data/lib/clickwrap/macros.rb +203 -0
  78. data/lib/clickwrap/models/application_record.rb +20 -0
  79. data/lib/clickwrap/models/chain_head.rb +79 -0
  80. data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
  81. data/lib/clickwrap/models/disposition_plan.rb +208 -0
  82. data/lib/clickwrap/models/document.rb +46 -0
  83. data/lib/clickwrap/models/document_version.rb +163 -0
  84. data/lib/clickwrap/models/event.rb +743 -0
  85. data/lib/clickwrap/models/event_document.rb +79 -0
  86. data/lib/clickwrap/models/event_statement.rb +92 -0
  87. data/lib/clickwrap/models/external_action.rb +150 -0
  88. data/lib/clickwrap/models/integrity_attestation.rb +90 -0
  89. data/lib/clickwrap/models/legal_hold.rb +81 -0
  90. data/lib/clickwrap/models/policy_revision.rb +115 -0
  91. data/lib/clickwrap/models/presentation.rb +59 -0
  92. data/lib/clickwrap/models/receipt_access.rb +53 -0
  93. data/lib/clickwrap/models/recording_sequence.rb +21 -0
  94. data/lib/clickwrap/models/request_evidence.rb +378 -0
  95. data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
  96. data/lib/clickwrap/models/statement_state.rb +130 -0
  97. data/lib/clickwrap/pending_receipt.rb +177 -0
  98. data/lib/clickwrap/policy.rb +283 -0
  99. data/lib/clickwrap/presentation_manifest.rb +210 -0
  100. data/lib/clickwrap/presenter.rb +716 -0
  101. data/lib/clickwrap/privacy.rb +419 -0
  102. data/lib/clickwrap/protected_outcome.rb +120 -0
  103. data/lib/clickwrap/receipt.rb +606 -0
  104. data/lib/clickwrap/receipt_html.rb +235 -0
  105. data/lib/clickwrap/receipt_verifier.rb +978 -0
  106. data/lib/clickwrap/reference.rb +44 -0
  107. data/lib/clickwrap/registration.rb +236 -0
  108. data/lib/clickwrap/registry.rb +54 -0
  109. data/lib/clickwrap/remediation_token.rb +155 -0
  110. data/lib/clickwrap/request_evidence_extractor.rb +590 -0
  111. data/lib/clickwrap/request_evidence_policy.rb +261 -0
  112. data/lib/clickwrap/retention/applier.rb +231 -0
  113. data/lib/clickwrap/retention/disposition.rb +221 -0
  114. data/lib/clickwrap/retention/planner.rb +502 -0
  115. data/lib/clickwrap/retention_class.rb +97 -0
  116. data/lib/clickwrap/reviewed_text.rb +28 -0
  117. data/lib/clickwrap/schema_requirements.rb +196 -0
  118. data/lib/clickwrap/services/authorize_external_action.rb +149 -0
  119. data/lib/clickwrap/services/load_policies.rb +69 -0
  120. data/lib/clickwrap/services/publish_documents.rb +251 -0
  121. data/lib/clickwrap/services/validate_policy_references.rb +166 -0
  122. data/lib/clickwrap/statement.rb +248 -0
  123. data/lib/clickwrap/subject_fingerprint.rb +28 -0
  124. data/lib/clickwrap/submission.rb +169 -0
  125. data/lib/clickwrap/system_actor.rb +31 -0
  126. data/lib/clickwrap/test_helpers.rb +676 -0
  127. data/lib/clickwrap/testing.rb +211 -0
  128. data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
  129. data/lib/clickwrap/verification.rb +504 -0
  130. data/lib/clickwrap/version.rb +12 -1
  131. data/lib/clickwrap/view_helpers.rb +190 -0
  132. data/lib/clickwrap/vocabulary.rb +294 -0
  133. data/lib/clickwrap.rb +497 -7
  134. data/lib/generators/clickwrap/document_generator.rb +164 -0
  135. data/lib/generators/clickwrap/hardening_generator.rb +177 -0
  136. data/lib/generators/clickwrap/install_generator.rb +1287 -0
  137. data/lib/generators/clickwrap/link_generator.rb +56 -0
  138. data/lib/generators/clickwrap/policy_generator.rb +118 -0
  139. data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
  140. data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
  141. data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
  142. data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
  143. data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
  144. data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
  145. data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
  146. data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
  147. data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
  148. data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
  149. data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
  150. data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
  151. data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
  152. data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
  153. data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
  154. data/lib/generators/clickwrap/views_generator.rb +101 -0
  155. data/lib/tasks/clickwrap.rake +569 -0
  156. metadata +311 -16
@@ -0,0 +1,758 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+
5
+ module Clickwrap
6
+ # What a host controller gets for free: the submission envelope, the two
7
+ # capture verbs, the authentication description, and the `requires_clickwrap`
8
+ # gate.
9
+ #
10
+ # These are thin on purpose. Every one of them forwards to the same public
11
+ # service API a background job, a console, or a native API endpoint would
12
+ # call; the controller layer only supplies the two things it is the only one
13
+ # holding — the current HTTP request and the parsed submission envelope.
14
+ # Nothing here decides anything about the evidence itself.
15
+ module ControllerHelpers
16
+ extend ActiveSupport::Concern
17
+
18
+ included do
19
+ # The view-layer DSL does not exist on ActionController::API, and an
20
+ # API-only host that bundles this gem still loads every controller in
21
+ # production. Guarding keeps such a host bootable; an HTML host gets the
22
+ # helpers. (Same idiom as the sessions gem.)
23
+ if respond_to?(:helper_method)
24
+ helper_method :clickwrap_errors, :present_clickwrap, :clickwrap_document_version_path_for_presentation
25
+ end
26
+ end
27
+
28
+ class_methods do
29
+ # The controller gate:
30
+ #
31
+ # class BillingController < ApplicationController
32
+ # requires_clickwrap :current_terms, only: :show
33
+ # end
34
+ #
35
+ # An HTML or Turbo visitor is redirected to the mounted capture screen and
36
+ # returned to where they were going. A JSON/API client gets a structured
37
+ # `clickwrap_required` response with the endpoint that can satisfy it.
38
+ #
39
+ # A gate with nowhere to send people is a dead end, so this refuses to
40
+ # compile without either the engine mounted or an explicit
41
+ # `remediation_path:` the host owns. Everything else in `**options` is
42
+ # passed straight through to `before_action` (`only:`, `except:`, `if:`).
43
+ def requires_clickwrap(policy_key, remediation_path: nil, subject_with: nil,
44
+ acting_for_with: nil, **before_action_options)
45
+ ControllerHelpers.validate_gate_resolver!(:subject_with, subject_with)
46
+ ControllerHelpers.validate_gate_resolver!(:acting_for_with, acting_for_with)
47
+ # The dead-end check is REGISTERED here, not run here.
48
+ #
49
+ # Rails eager-loads controllers before it draws the route set, so a
50
+ # class body in production can run while `Rails.application.routes` is
51
+ # still empty — and a check that ran now would fail on a perfectly
52
+ # well-configured application just because it looked too early. So the
53
+ # gate records itself, an `after_initialize` sweep checks every
54
+ # registered gate once the routes exist, and the request path checks
55
+ # again as a backstop for a controller that was autoloaded later.
56
+ #
57
+ # The developer still gets the same sentence at boot. It just comes from
58
+ # a moment when the answer is knowable.
59
+ ControllerHelpers.register_gate(
60
+ policy_key,
61
+ remediation_path: remediation_path,
62
+ subject_with: subject_with,
63
+ acting_for_with: acting_for_with,
64
+ gate: "#{name || "an anonymous controller"}.requires_clickwrap"
65
+ )
66
+
67
+ before_action(**before_action_options) do
68
+ clickwrap_gate!(policy_key, remediation_path: remediation_path,
69
+ subject_with: subject_with,
70
+ acting_for_with: acting_for_with)
71
+ end
72
+ end
73
+ end
74
+
75
+ Gate = Data.define(:policy_key, :remediation_path, :subject_with,
76
+ :acting_for_with, :gate)
77
+
78
+ # Gates register themselves from controller class bodies, and Rails is free
79
+ # to autoload — and, in development, reload — controllers from more than one
80
+ # thread. Two of them writing a bare Hash at the same time is the kind of
81
+ # corruption that shows up once, in somebody else's production, as a gate
82
+ # that quietly stopped being registered. Registry and Identifier already
83
+ # take a lock for exactly this; so does this.
84
+ #
85
+ # Writes take the lock and reads do not: reads happen on every gated
86
+ # request, and a snapshot taken a microsecond before a reload is a snapshot
87
+ # of a valid state either way.
88
+ REGISTRY_MUTEX = Mutex.new
89
+
90
+ # Created here rather than lazily, so there is no `||=` on a read path for
91
+ # two threads to race: both would build a collection and one would silently
92
+ # lose whatever it had already put in.
93
+ @registered_gates = {}
94
+ @verified_gates = Set.new
95
+
96
+ class << self
97
+ # The current actor, through the host's configured controller method.
98
+ # Shared by the host-facing helper and the engine's own controllers so
99
+ # there is exactly one answer to "who is acting" in the whole web layer.
100
+ def resolve_current_actor(controller)
101
+ method_name = Clickwrap.config.current_actor_method_name
102
+
103
+ unless controller.respond_to?(method_name, true)
104
+ raise ConfigurationError,
105
+ "Clickwrap can't find ##{method_name} on #{controller.class.name}. Set " \
106
+ "`config.current_actor_method_name` in config/initializers/clickwrap.rb to the " \
107
+ "controller method that returns the signed-in " \
108
+ "#{Clickwrap.config.actor_class_name} (:current_user by default, which is what " \
109
+ "Devise and the Rails authentication generator both provide)."
110
+ end
111
+
112
+ controller.send(method_name)
113
+ end
114
+
115
+ # Resolves ambient tenant context through the policy that will bind it.
116
+ # This is shared by form rendering, custom presentations, captures, gates,
117
+ # and remediation tokens so they cannot disagree about whether a current
118
+ # organization belongs in this evidence identity.
119
+ def resolve_current_tenant(controller, policy)
120
+ candidate = Clickwrap.config.find_current_tenant_with.call(controller)
121
+ policy.tenant_from_controller(candidate)
122
+ end
123
+
124
+ # Remembers a declared gate so it can be checked once the route set
125
+ # exists. Re-declaring the same gate (a development reload) replaces the
126
+ # entry rather than accumulating duplicates.
127
+ def register_gate(policy_key, remediation_path:, gate:, subject_with: nil,
128
+ acting_for_with: nil)
129
+ entry = Gate.new(
130
+ policy_key: policy_key.to_s,
131
+ remediation_path: remediation_path,
132
+ subject_with: subject_with,
133
+ acting_for_with: acting_for_with,
134
+ gate: gate
135
+ )
136
+
137
+ REGISTRY_MUTEX.synchronize { registered_gates[[gate, policy_key.to_s]] = entry }
138
+ entry
139
+ end
140
+
141
+ attr_reader :registered_gates
142
+
143
+ # Run from the engine's `after_initialize`, when the host's routes are
144
+ # drawn and the answer is actually knowable. Iterates a snapshot, so a
145
+ # controller autoloading on another thread mid-sweep cannot make this
146
+ # raise about the collection instead of about a gate.
147
+ def verify_registered_gates!
148
+ REGISTRY_MUTEX.synchronize { registered_gates.values }.each do |entry|
149
+ verify_remediation_is_possible!(
150
+ entry.policy_key,
151
+ remediation_path: entry.remediation_path,
152
+ subject_with: entry.subject_with,
153
+ acting_for_with: entry.acting_for_with,
154
+ gate: entry.gate
155
+ )
156
+ end
157
+ end
158
+
159
+ def verify_remediation_is_possible!(policy_key, remediation_path:, gate:,
160
+ subject_with: nil, acting_for_with: nil)
161
+ if subject_with && !Clickwrap.config.remediation_subject_authorization_configured?
162
+ raise ConfigurationError,
163
+ "#{gate} :#{policy_key} resolves a subject with `subject_with:`, but the host " \
164
+ "has not configured `authorize_clickwrap_remediation_subject_with`. That " \
165
+ "server-side callback must decide whether the current actor may complete this " \
166
+ "policy for the resolved subject."
167
+ end
168
+
169
+ if acting_for_with && !Clickwrap.config.remediation_represented_party_authorization_configured?
170
+ raise ConfigurationError,
171
+ "#{gate} :#{policy_key} resolves a represented party with `acting_for_with:`, " \
172
+ "but the host has not configured " \
173
+ "`authorize_clickwrap_remediation_represented_party_with`."
174
+ end
175
+
176
+ return true if remediation_path
177
+ return true unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
178
+ return true if load_host_routes == :unavailable
179
+
180
+ # Only a success is remembered, and only to keep a gated request from
181
+ # rescanning the route set every time. A mount that later disappears
182
+ # would surface as an ordinary routing error, which is loud enough.
183
+ return true if verified_gates.include?(policy_key.to_s)
184
+
185
+ if engine_is_mounted?
186
+ REGISTRY_MUTEX.synchronize { verified_gates << policy_key.to_s }
187
+ return true
188
+ end
189
+
190
+ raise ConfigurationError,
191
+ "#{gate} :#{policy_key} would have no way to be satisfied. A required gate needs " \
192
+ "somewhere to send the person it stops, and Clickwrap::Engine is not mounted, so " \
193
+ "there is no capture screen to redirect them to. Either mount it:\n\n " \
194
+ "mount Clickwrap::Engine => \"/agreements\"\n\n" \
195
+ "or point this gate at a page you own:\n\n " \
196
+ "requires_clickwrap :#{policy_key}, remediation_path: \"/support/agreements\"\n\n" \
197
+ "A gate that blocks an action with no route to unblocking it is a dead end, and " \
198
+ "Clickwrap will not compile one."
199
+ end
200
+
201
+ # Rails names a mounted engine's URL-helper proxy after the engine's
202
+ # railtie name, so `mounted_helpers` defining `clickwrap` is the same fact
203
+ # as "this application mounted us" — and it is a fact Rails maintains
204
+ # rather than one inferred by walking route objects, whose wrapping has
205
+ # changed shape more than once across versions. The route scan stays as a
206
+ # second opinion for anything unusual.
207
+ def engine_is_mounted?
208
+ helpers = ::Rails.application.routes.mounted_helpers
209
+ return true if helpers&.method_defined?(:clickwrap)
210
+
211
+ ::Rails.application.routes.routes.any? { |route| mounts_clickwrap_engine?(route) }
212
+ rescue StandardError
213
+ false
214
+ end
215
+
216
+ # Memoized successes only, so the worst a lost race can cost is one extra
217
+ # route scan — but the collection itself is still written under the lock.
218
+ attr_reader :verified_gates
219
+
220
+ # The one refusal this gem cannot afford to soften. Both paths that build
221
+ # a document link — the presenter's own fallback and a controller with no
222
+ # `clickwrap` mounted-helper proxy — end at the engine's prefix-less URL
223
+ # helpers, which answer with a path that resolves to nothing on an
224
+ # application that never mounted the engine.
225
+ #
226
+ # That path does not merely render badly. It is signed into the
227
+ # presentation manifest, digested, and recorded as the exact document the
228
+ # person was offered, so the evidence would cite a 404 for as long as it
229
+ # is kept. Nothing downstream can detect that later: the digest is over
230
+ # the wrong link, and it is perfectly valid.
231
+ def assert_engine_can_resolve_document_links!(version = nil)
232
+ return if engine_is_mounted?
233
+
234
+ subject = version ? "document version #{version.id}" : "this document"
235
+
236
+ raise ConfigurationError,
237
+ "Clickwrap will not sign a document link that resolves to nothing. The link for " \
238
+ "#{subject} can only be built from Clickwrap::Engine's own routes, and this " \
239
+ "application does not mount the engine — so the URL would 404, and it would be " \
240
+ "signed into the presentation manifest and kept as the exact document that was " \
241
+ "offered. Mount the engine:\n\n " \
242
+ "mount Clickwrap::Engine => \"/agreements\"\n\n" \
243
+ "or route the documents yourself and bind that route into every presentation:\n\n " \
244
+ "form.clickwrap :signup, document_version_path_with: " \
245
+ "->(version) { legal_document_path(version.id) }"
246
+ end
247
+
248
+ def validate_gate_resolver!(name, resolver)
249
+ return if resolver.nil? || resolver.is_a?(Symbol) || resolver.respond_to?(:call)
250
+
251
+ raise ConfigurationError,
252
+ "#{name} must be a controller method name (Symbol) or a callable, got #{resolver.inspect}."
253
+ end
254
+
255
+ private
256
+
257
+ def load_host_routes
258
+ return :loaded if ::Rails.application.routes.routes.any?
259
+ return :unavailable unless ::Rails.application.respond_to?(:reload_routes_unless_loaded)
260
+
261
+ ::Rails.application.reload_routes_unless_loaded
262
+
263
+ # Asking is not the same as getting. From Rails 7.2 on,
264
+ # `reload_routes_unless_loaded` is a no-op until the application has
265
+ # finished initializing — and every moment a gate is checked at boot
266
+ # (an eager-loaded class body, `after_initialize`) is before that, because
267
+ # `:set_routes_reloader_hook` runs last. Reporting `:loaded` there would
268
+ # tell a host that mounts the engine that the engine is not mounted, and
269
+ # refuse to boot over it. So report what is actually there and let the
270
+ # check defer to the first request, exactly as documented above.
271
+ ::Rails.application.routes.routes.any? ? :loaded : :unavailable
272
+ rescue StandardError
273
+ :unavailable
274
+ end
275
+
276
+ # `mount Clickwrap::Engine => "/agreements"` stores the engine behind a
277
+ # constraints wrapper, and a host is free to rename the mount with `as:`,
278
+ # so the reliable question is "does any route dispatch to this engine",
279
+ # not "does a particular URL helper exist".
280
+ def mounts_clickwrap_engine?(route)
281
+ target = route.app
282
+ candidates = [target]
283
+ candidates << target.app if target.respond_to?(:app)
284
+
285
+ candidates.any? { |candidate| candidate == Clickwrap::Engine || candidate.is_a?(Clickwrap::Engine) }
286
+ rescue StandardError
287
+ false
288
+ end
289
+ end
290
+
291
+ # The submission envelope this request carried: a signed presentation token
292
+ # and the answers the manifest declared. Memoized because reading it twice
293
+ # in one action should not parse it twice.
294
+ def clickwrap_submission
295
+ @clickwrap_submission ||= Submission.from_params(params)
296
+ end
297
+
298
+ # Server-side validation errors from the last capture attempt in this
299
+ # request, keyed by statement. The reference views read this to re-render a
300
+ # failed submission with the message beside the control it belongs to, with
301
+ # no JavaScript involved.
302
+ def clickwrap_errors
303
+ @clickwrap_errors ||= {}
304
+ end
305
+
306
+ # `Clickwrap.capture!` with the two things only a controller has.
307
+ def capture_clickwrap!(policy_key, **options)
308
+ Clickwrap.capture!(policy_key, **clickwrap_capture_options(policy_key, options))
309
+ end
310
+
311
+ # `Clickwrap.capture_and!` with the same defaults. The block runs inside the
312
+ # same database transaction as the evidence write: if either fails, neither
313
+ # happened.
314
+ def capture_clickwrap_and!(policy_key, **options, &)
315
+ Clickwrap.capture_and!(policy_key, **clickwrap_capture_options(policy_key, options), &)
316
+ end
317
+
318
+ # The refusal-absorbing halves of the pair above, shaped like `save` next
319
+ # to `save!`: a refused submission — stale presentation, unticked control,
320
+ # over-long answer — returns false instead of raising, with the
321
+ # per-statement message already in `clickwrap_errors` (the reference views
322
+ # re-render it beside the control) and the whole refusal in
323
+ # `clickwrap_refusal`, whose `user_facing_message` is a complete sentence
324
+ # fit to put in front of a person:
325
+ #
326
+ # receipt = capture_clickwrap_and(:api_access) { current_user.enable_api_access! }
327
+ # unless receipt
328
+ # flash.now[:alert] = clickwrap_refusal.user_facing_message
329
+ # return render :new, status: :unprocessable_entity
330
+ # end
331
+ #
332
+ # Only REFUSALS are absorbed. Infrastructure failures escape, and so do
333
+ # lifecycle conflicts (ReplayRejected, OneTimeAuthorizationConflict):
334
+ # "this was already done" needs a domain answer — usually "treat it as
335
+ # done" — that no generic rescue can supply honestly.
336
+ #
337
+ # (The TEST helper of the same name, Clickwrap::TestHelpers#capture_clickwrap,
338
+ # deliberately follows the opposite convention: it is a factory verb that
339
+ # raises, because in a test a failed capture is a failed test. The two
340
+ # modules never share an object.)
341
+ def capture_clickwrap(policy_key, **)
342
+ capture_clickwrap!(policy_key, **)
343
+ rescue Clickwrap::CaptureRefused => error
344
+ absorb_clickwrap_capture_refusal(error)
345
+ false
346
+ end
347
+
348
+ def capture_clickwrap_and(policy_key, **, &)
349
+ capture_clickwrap_and!(policy_key, **, &)
350
+ rescue Clickwrap::CaptureRefused => error
351
+ absorb_clickwrap_capture_refusal(error)
352
+ false
353
+ end
354
+
355
+ # The refusal the last non-bang helper in this request absorbed, or nil.
356
+ def clickwrap_refusal
357
+ @clickwrap_refusal
358
+ end
359
+
360
+ # `Clickwrap.authorize_external_action!` with the request, submission,
361
+ # actor, tenant, and authentication context this controller already knows.
362
+ # The optional block is strictly local compatibility/domain work: it runs
363
+ # once, inside the transaction that saves the evidence event and pending
364
+ # outbox row, and receives `pending_action:` and `pending_receipt:`. The
365
+ # provider call happens only after this helper returns.
366
+ def authorize_clickwrap_external_action!(policy_key, **options, &)
367
+ Clickwrap.authorize_external_action!(
368
+ policy_key,
369
+ **clickwrap_capture_options(policy_key, options),
370
+ &
371
+ )
372
+ end
373
+
374
+ # `Clickwrap.present` with the same actor, tenant, and locale defaults the
375
+ # controller capture helpers use. Custom views should call this instead of
376
+ # manually repeating ambient context, which keeps GET and POST binding
377
+ # identical by construction.
378
+ def present_clickwrap(policy_key, **options)
379
+ resolved = options.dup
380
+ resolved[:actor] = clickwrap_current_actor unless resolved.key?(:actor)
381
+ resolved[:tenant] = clickwrap_current_tenant(policy_key) unless resolved.key?(:tenant)
382
+ resolved[:locale] = I18n.locale unless resolved.key?(:locale)
383
+ resolved[:default_document_version_path_with] ||= lambda do |version, declared_link|
384
+ clickwrap_document_version_path_for_presentation(version, declared_link: declared_link)
385
+ end
386
+
387
+ Clickwrap.present(policy_key, **resolved)
388
+ end
389
+
390
+ # The exact URL offered beside a Clickwrap control. A document declared with
391
+ # `link:` is read on the host's own page and that path is used as declared;
392
+ # everything else gets the mounted engine route for the exact published
393
+ # version. Either way, a Hotwire Native request under
394
+ # `config.hotwire_native_document_links = { open_in: :external_browser, … }`
395
+ # gets the same path absolutized against the canonical host, so the
396
+ # document opens outside the WebView instead of destroying the screen the
397
+ # form is on. A host that needs another reviewed routing layer can still
398
+ # override this one method. Whatever this returns is both rendered and
399
+ # signed into the presentation manifest, so the evidence never claims a
400
+ # different target from the link.
401
+ #
402
+ # `declared_link:` is passed by the presenter, which is the only thing that
403
+ # knows which document this version belongs to; a controller calling this
404
+ # directly for an engine link simply omits it.
405
+ def clickwrap_document_version_path_for_presentation(version, declared_link: nil)
406
+ path = declared_link.presence || begin
407
+ ControllerHelpers.assert_engine_can_resolve_document_links!(version) unless respond_to?(:clickwrap)
408
+
409
+ clickwrap_engine_routes.document_version_path(version.id)
410
+ end
411
+
412
+ native_links = Clickwrap.config.hotwire_native_document_links
413
+ if native_links && clickwrap_hotwire_native_request? &&
414
+ Clickwrap.config.hotwire_native_document_link_mode(self) == :external_browser
415
+ "#{Clickwrap.config.hotwire_native_canonical_host}#{path}"
416
+ else
417
+ path
418
+ end
419
+ end
420
+
421
+ # False whenever the host has no Hotwire Native integration at all — the
422
+ # predicate is turbo-rails' own, and its absence means no native app.
423
+ def clickwrap_hotwire_native_request?
424
+ respond_to?(:hotwire_native_app?, true) && send(:hotwire_native_app?)
425
+ end
426
+
427
+ # Signup, for Rails' own authentication generator or any hand-rolled
428
+ # registration door. The pair works exactly like `save` and `save!`:
429
+ #
430
+ # # Absorbs refusals: a stale presentation, an unticked control, or a
431
+ # # failed validation paints the same human sentences the Devise adapter
432
+ # # uses — inline via clickwrap_errors and once on the record's :base —
433
+ # # and returns false, ready for `render :new, status: :unprocessable_entity`.
434
+ # unless register_with_clickwrap(:signup, user: @user) { @user.save! }
435
+ # return render :new, status: :unprocessable_entity
436
+ # end
437
+ #
438
+ # # Raises on refusal, for flows that handle the exceptions themselves:
439
+ # register_with_clickwrap!(:signup, user: @user) { @user.save! }
440
+ #
441
+ # Either way, the account and the evidence that authorized creating it
442
+ # commit together, and an infrastructure failure (EventWriteFailed) always
443
+ # escapes from BOTH forms — a broken database is not a refusal to dress up
444
+ # as validation, and the sign-in, the welcome email, and the redirect that
445
+ # would normally follow simply do not happen. That is the difference
446
+ # between a refused signup and a live account nobody can explain.
447
+ #
448
+ # `user:` is the record the door is about to create. It is spelled `user:`
449
+ # because that is what it is called in every signup controller ever
450
+ # written; pass your actor here whatever its class is actually named.
451
+ def register_with_clickwrap!(policy_key, user:, **options, &)
452
+ refuse_removed_prospective_actor_keyword!(options)
453
+
454
+ result = Clickwrap::Registration.perform(
455
+ policy_key,
456
+ prospective_actor: user,
457
+ http_request: request,
458
+ submission: clickwrap_submission,
459
+ tenant: clickwrap_current_tenant(policy_key),
460
+ registration_flow_id: clickwrap_registration_flow_id(policy_key),
461
+ **options,
462
+ &
463
+ )
464
+
465
+ clear_clickwrap_registration_flow_when_committed(result, policy_key)
466
+ result
467
+ end
468
+
469
+ # Explicitly refused rather than quietly swallowed by the `**` forward,
470
+ # which would pass it straight through to Registration.perform and let a
471
+ # second spelling of the same argument go on working invisibly. One record
472
+ # is being created here; it gets one name.
473
+ def refuse_removed_prospective_actor_keyword!(options)
474
+ return unless options.key?(:prospective_actor)
475
+
476
+ raise ArgumentError,
477
+ "register_with_clickwrap does not take `prospective_actor:`. The record the door is " \
478
+ "about to create is `user:` — pass your actor there whatever its class is named."
479
+ end
480
+ private :refuse_removed_prospective_actor_keyword!
481
+
482
+ def register_with_clickwrap(policy_key, user:, **, &)
483
+ register_with_clickwrap!(policy_key, user: user, **, &)
484
+ rescue *Clickwrap::Registration::REFUSALS => error
485
+ @clickwrap_refusal = Clickwrap::Registration.absorb_refusal(
486
+ error,
487
+ resource: user,
488
+ clickwrap_errors: clickwrap_errors
489
+ )
490
+ false
491
+ end
492
+
493
+ # Creates a new represented party (for example, an organization) and its
494
+ # authority evidence as one transaction. The form can pass the same new
495
+ # record as `acting_for:`; this helper owns the server-side browser-flow
496
+ # binding and clears it only after durable commit.
497
+ def create_represented_party_with_clickwrap(policy_key, represented_party:, **options, &)
498
+ resolved = clickwrap_capture_options(policy_key, options)
499
+ result = Clickwrap.create_represented_party!(
500
+ policy_key,
501
+ represented_party: represented_party,
502
+ represented_party_creation_flow_id:
503
+ clickwrap_represented_party_creation_flow_id(policy_key),
504
+ **resolved,
505
+ &
506
+ )
507
+
508
+ clear_clickwrap_represented_party_creation_flow_when_committed(result, policy_key)
509
+ result
510
+ end
511
+
512
+ # Whatever the host chose to record about how this request was
513
+ # authenticated. Clickwrap does not inspect the session itself: what counts
514
+ # as an authentication context is the host's decision, and the default is an
515
+ # empty hash rather than a guess.
516
+ def clickwrap_authentication_context
517
+ Clickwrap.config.describe_authentication_with.call(self)
518
+ end
519
+
520
+ # Resolves and re-authorizes the signed context handed to a custom
521
+ # `remediation_path:`. The returned object exposes `subject`,
522
+ # `represented_party`, and `return_to`; pass the first two to both
523
+ # presentation and capture. No browser-owned id needs to be permitted.
524
+ def resolve_clickwrap_remediation!(policy_key, token: params[:remediation_token])
525
+ context = RemediationToken.resolve!(
526
+ token,
527
+ policy: Clickwrap.policy!(policy_key),
528
+ actor: clickwrap_current_actor
529
+ )
530
+
531
+ authorize_clickwrap_remediation_context!(
532
+ policy_key,
533
+ actor: clickwrap_current_actor,
534
+ subject: context.subject,
535
+ represented_party: context.represented_party
536
+ )
537
+ context
538
+ end
539
+
540
+ private
541
+
542
+ def absorb_clickwrap_capture_refusal(error)
543
+ @clickwrap_refusal = error
544
+ if error.is_a?(Clickwrap::AnswerInvalid) && error.statement_key.present?
545
+ clickwrap_errors[error.statement_key.to_s] = I18n.t("clickwrap.errors.required_statement")
546
+ end
547
+ error
548
+ end
549
+
550
+ def clickwrap_registration_flow_id(policy_key)
551
+ unless respond_to?(:session)
552
+ raise ConfigurationError,
553
+ "Registration-flow binding needs a controller session. API registrations must " \
554
+ "create their own server-side registration_flow_id and pass it to both " \
555
+ "Clickwrap.present and Clickwrap.register!."
556
+ end
557
+
558
+ flows = (session[:clickwrap_registration_flows] ||= {})
559
+ flows[policy_key.to_s] ||= SecureRandom.uuid
560
+ end
561
+
562
+ def clickwrap_represented_party_creation_flow_id(policy_key)
563
+ unless respond_to?(:session)
564
+ raise ConfigurationError,
565
+ "Represented-party creation needs a controller session. API clients must create " \
566
+ "their own server-side represented_party_creation_flow_id and pass it to both " \
567
+ "Clickwrap.present and Clickwrap.create_represented_party!."
568
+ end
569
+
570
+ flows = (session[:clickwrap_represented_party_creation_flows] ||= {})
571
+ flows[policy_key.to_s] ||= SecureRandom.uuid
572
+ end
573
+
574
+ def clear_clickwrap_registration_flow_id(policy_key)
575
+ session[:clickwrap_registration_flows]&.delete(policy_key.to_s)
576
+ end
577
+
578
+ def clear_clickwrap_registration_flow_when_committed(result, policy_key)
579
+ if result.respond_to?(:when_durably_committed)
580
+ result.when_durably_committed { clear_clickwrap_registration_flow_id(policy_key) }
581
+ elsif result.committed?
582
+ clear_clickwrap_registration_flow_id(policy_key)
583
+ end
584
+ end
585
+
586
+ def clear_clickwrap_represented_party_creation_flow_when_committed(result, policy_key)
587
+ clear = lambda do
588
+ session[:clickwrap_represented_party_creation_flows]&.delete(policy_key.to_s)
589
+ end
590
+
591
+ if result.respond_to?(:when_durably_committed)
592
+ result.when_durably_committed(&clear)
593
+ elsif result.committed?
594
+ clear.call
595
+ end
596
+ end
597
+
598
+ def clickwrap_current_actor
599
+ @clickwrap_current_actor ||= ControllerHelpers.resolve_current_actor(self)
600
+ end
601
+
602
+ def clickwrap_current_tenant(policy_key = nil)
603
+ return Clickwrap.config.find_current_tenant_with.call(self) if policy_key.nil?
604
+
605
+ ControllerHelpers.resolve_current_tenant(self, Clickwrap.policy!(policy_key))
606
+ end
607
+
608
+ def clickwrap_capture_options(policy_key, options)
609
+ resolved = options.dup
610
+ resolved[:http_request] = request unless resolved.key?(:http_request)
611
+ resolved[:submission] = clickwrap_submission unless resolved.key?(:submission)
612
+ resolved[:actor] = clickwrap_current_actor unless resolved.key?(:actor)
613
+ resolved[:tenant] = clickwrap_current_tenant(policy_key) unless resolved.key?(:tenant)
614
+ resolved[:authentication_context] = clickwrap_authentication_context unless
615
+ resolved.key?(:authentication_context)
616
+ resolved
617
+ end
618
+
619
+ # The gate itself. It answers the question "is this actor current for this
620
+ # policy" through the ordinary public verification API — the same one a
621
+ # service object at the domain boundary would call — and remediates when the
622
+ # answer is no.
623
+ #
624
+ # Controller gates improve the flow. They are not the security boundary:
625
+ # anything consequential should still call `Clickwrap.require!` where the
626
+ # action actually happens.
627
+ def clickwrap_gate!(policy_key, remediation_path: nil, subject_with: nil, acting_for_with: nil)
628
+ # A gate inherited from the host's ApplicationController must never gate
629
+ # Clickwrap's own remediation, receipt, withdrawal, or document screens.
630
+ # Those screens are how the person satisfies the gate; redirecting them
631
+ # back to themselves creates a loop and can make the document they must
632
+ # review unreachable. Domain enforcement remains the host's
633
+ # `Clickwrap.require!` call, not this navigation convenience.
634
+ return if clickwrap_engine_controller?
635
+
636
+ ControllerHelpers.verify_remediation_is_possible!(
637
+ policy_key,
638
+ remediation_path: remediation_path,
639
+ subject_with: subject_with,
640
+ acting_for_with: acting_for_with,
641
+ gate: "#{self.class.name}.requires_clickwrap"
642
+ )
643
+
644
+ actor = clickwrap_current_actor
645
+ unless actor
646
+ if clickwrap_prefers_a_structured_response?
647
+ render json: { error: "clickwrap_actor_required", policy: policy_key.to_s }, status: :unauthorized
648
+ else
649
+ head :unauthorized
650
+ end
651
+ return
652
+ end
653
+
654
+ subject = resolve_clickwrap_gate_value(subject_with)
655
+ represented_party = resolve_clickwrap_gate_value(acting_for_with)
656
+ tenant = clickwrap_current_tenant(policy_key)
657
+
658
+ return if Clickwrap.current?(policy_key, actor: actor, tenant: tenant, subject: subject,
659
+ acting_for: represented_party)
660
+
661
+ authorize_clickwrap_remediation_context!(policy_key, actor: actor, subject: subject,
662
+ represented_party: represented_party)
663
+ clickwrap_require_remediation(
664
+ policy_key,
665
+ remediation_path: remediation_path,
666
+ actor: actor,
667
+ tenant: tenant,
668
+ subject: subject,
669
+ represented_party: represented_party
670
+ )
671
+ rescue RemediationNotAuthorized
672
+ # A denial must not disclose that the resolved subject or represented
673
+ # party exists. This is the same not-found posture the standalone screen
674
+ # uses for a swapped, expired, or otherwise invalid signed handoff.
675
+ head :not_found
676
+ end
677
+
678
+ def clickwrap_require_remediation(policy_key, remediation_path:, actor:, tenant:, subject:,
679
+ represented_party:)
680
+ destination = remediation_path || clickwrap_capture_url_for(policy_key)
681
+ token = RemediationToken.issue(
682
+ policy: Clickwrap.policy!(policy_key),
683
+ actor: actor,
684
+ tenant: tenant,
685
+ subject: subject,
686
+ represented_party: represented_party,
687
+ return_to: request.fullpath
688
+ )
689
+ destination = clickwrap_remediation_destination(destination, token)
690
+
691
+ if clickwrap_prefers_a_structured_response?
692
+ render json: {
693
+ error: "clickwrap_required",
694
+ policy: policy_key.to_s,
695
+ presentation_url: destination
696
+ },
697
+ status: :forbidden
698
+ else
699
+ redirect_to destination, allow_other_host: false
700
+ end
701
+ end
702
+
703
+ def clickwrap_capture_url_for(policy_key)
704
+ clickwrap_engine_routes.capture_path(policy_key)
705
+ end
706
+
707
+ def clickwrap_engine_controller?
708
+ defined?(Clickwrap::ApplicationController) && is_a?(Clickwrap::ApplicationController)
709
+ end
710
+
711
+ # The mounted proxy when the host mounted the engine (it carries the mount
712
+ # prefix), the engine's own prefix-less helpers otherwise.
713
+ def clickwrap_engine_routes
714
+ respond_to?(:clickwrap) ? clickwrap : Clickwrap::Engine.routes.url_helpers
715
+ end
716
+
717
+ # Where to come back to once the policy is satisfied. Only this request's
718
+ # own path travels — never a client-supplied URL — so the gate cannot be
719
+ # turned into an open redirect.
720
+ def clickwrap_remediation_destination(destination, token)
721
+ separator = destination.include?("?") ? "&" : "?"
722
+ "#{destination}#{separator}remediation_token=#{CGI.escape(token)}"
723
+ end
724
+
725
+ def resolve_clickwrap_gate_value(resolver)
726
+ return nil if resolver.nil?
727
+ return send(resolver) if resolver.is_a?(Symbol)
728
+ return instance_exec(&resolver) if resolver.arity.zero?
729
+
730
+ resolver.call(self)
731
+ end
732
+
733
+ def authorize_clickwrap_remediation_context!(policy_key, actor:, subject:, represented_party:)
734
+ policy = Clickwrap.policy!(policy_key)
735
+ subject_allowed = Clickwrap.config.authorize_clickwrap_remediation_subject_with.call(
736
+ actor: actor, subject: subject, policy: policy, controller: self
737
+ )
738
+ party_allowed = Clickwrap.config.authorize_clickwrap_remediation_represented_party_with.call(
739
+ actor: actor, represented_party: represented_party, policy: policy, controller: self
740
+ )
741
+ return true if subject_allowed == true && party_allowed == true
742
+
743
+ raise RemediationNotAuthorized,
744
+ "The host did not authorize this actor to remediate the policy for the resolved context."
745
+ end
746
+
747
+ # HTML and Turbo get a redirect they can follow; everything else gets a
748
+ # response it can branch on. An API-only controller never has an HTML
749
+ # rendering path, so it always gets the structured form.
750
+ def clickwrap_prefers_a_structured_response?
751
+ return true if defined?(::ActionController::API) && is_a?(::ActionController::API)
752
+ return true if request.format.json?
753
+
754
+ symbol = request.format.symbol.to_s
755
+ !(request.format.html? || symbol.include?("turbo"))
756
+ end
757
+ end
758
+ end