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,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # Development and test heuristics for the presentation layer.
5
+ #
6
+ # ============================================================================
7
+ # THESE ARE HEURISTICS. They warn; they never raise, never block a render, and
8
+ # never certify anything. A clean run means "none of the specific hazards
9
+ # below were detected in what was inspected" and nothing else.
10
+ #
11
+ # This class must never print, return, or imply "compliant", "enforceable",
12
+ # "legally binding", "accessible", or "approved". Courts assess the complete
13
+ # page in context and accessibility applies to the whole experience, so no
14
+ # library that sees one fragment of one template can make any of those calls.
15
+ # What it can do is notice objectively checkable mistakes — a submit button
16
+ # above the controls, a consent box that arrives already ticked, a document
17
+ # nobody can open — and say so in a full sentence.
18
+ # ============================================================================
19
+ #
20
+ # findings = Clickwrap::Linter.review_policy(Clickwrap.policy!(:signup))
21
+ # findings.map(&:code) # => [:consent_statement_bundles_purposes]
22
+ # findings.first.explanation # => a plain-English sentence
23
+ #
24
+ # Every finding carries a stable symbol, so a test can assert on it without
25
+ # matching English, and an explanation, so a person reading the log knows what
26
+ # to do about it.
27
+ class Linter
28
+ Finding = Data.define(:code, :explanation, :context) do
29
+ def to_s = "#{code}: #{explanation}"
30
+ end
31
+
32
+ # Rendered-fragment scans, kept to string work so they are cheap enough to
33
+ # run on every development render.
34
+ ANSWER_FIELD_PATTERN = /name=["']clickwrap_submission\[answers\]\[([^\]"']+)\]["']/
35
+ TOKEN_FIELD_PATTERN = /name=["']clickwrap_submission\[presentation_token\]["']/
36
+ SUBMIT_CONTROL_PATTERN = /<(?:button|input)\b[^>]*type=["']submit["']|<button(?![^>]*type=)/i
37
+ CHECKED_ATTRIBUTE_PATTERN = /\bchecked\b/
38
+
39
+ # A rough test for a consent sentence that is carrying more than one
40
+ # purpose. It is deliberately generous: the cost of a false positive is one
41
+ # log line suggesting a split, and the cost of a false negative is a consent
42
+ # record whose meaning nobody can reconstruct.
43
+ BUNDLED_PURPOSE_PATTERN = %r{\b(and|and/or|as well as|plus)\b}i
44
+
45
+ class << self
46
+ # On in development and test, off everywhere else — a production request
47
+ # should not be scanning its own HTML — and `config.lint_presentations`
48
+ # answers for a host that disagrees with either half.
49
+ #
50
+ # Every finding is a log line, never an exception: a lint finding is a
51
+ # thing to look at, not a reason to stop a developer's page from
52
+ # rendering. Which is exactly why it needs a switch — a warning nobody
53
+ # can turn off is one people learn to scroll past.
54
+ def enabled?
55
+ configured = Clickwrap.config.lint_presentations
56
+ return configured unless configured.nil?
57
+
58
+ return false unless defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env
59
+
60
+ ::Rails.env.development? || ::Rails.env.test?
61
+ end
62
+
63
+ # --- Entry points -------------------------------------------------------
64
+
65
+ # What a compiled policy alone can be checked for, without rendering
66
+ # anything: blank copy, bundled consent purposes, and an optional consent
67
+ # that another statement has quietly made mandatory.
68
+ def review_policy(policy, locale: nil)
69
+ locale = (locale || (defined?(::I18n) ? ::I18n.locale : :en)).to_s
70
+
71
+ findings = []
72
+ findings.concat(blank_assertion_findings(policy))
73
+ findings.concat(bundled_consent_findings(policy, locale))
74
+ findings.concat(optional_consent_prerequisite_findings(policy))
75
+ findings
76
+ end
77
+
78
+ # What one presentation can be checked for: a statement the person is
79
+ # asked to accept with no way to read what they are accepting.
80
+ def review_presentation(presentation)
81
+ presentation.statements.filter_map do |statement|
82
+ next if statement.documents.any?
83
+ next unless %w[agreement acknowledgment consent].include?(statement.kind)
84
+
85
+ Finding.new(
86
+ code: :document_link_missing,
87
+ explanation: "The #{statement.kind} #{statement.key.inspect} presents no document, so " \
88
+ "the person is asked to accept something the page never shows them. Give " \
89
+ "the statement a `document:` and publish it.",
90
+ context: { statement: statement.key, policy: presentation.policy_key }
91
+ )
92
+ end
93
+ end
94
+
95
+ # Whether the manifest that came back still says what the policy says.
96
+ # A difference here means the evidence and the current server-owned offer
97
+ # have diverged — usually a deploy between render and submit, sometimes a
98
+ # hand-built form that stopped tracking the policy.
99
+ def review_manifest(manifest, policy:)
100
+ snapshot = manifest.respond_to?(:to_h) ? manifest.to_h : manifest
101
+ rendered = Array(snapshot["statements"] || snapshot[:statements])
102
+ differences = manifest_differences(rendered, policy: policy, locale: snapshot["locale"])
103
+ return [] if differences.empty?
104
+
105
+ [Finding.new(
106
+ code: :rendered_manifest_differs_from_policy,
107
+ explanation: "The submitted presentation manifest does not match the current policy " \
108
+ "#{policy.key}: #{differences.join("; ")}. Clickwrap verifies this at " \
109
+ "capture; the warning is here so the drift is visible while you can still " \
110
+ "explain it.",
111
+ context: { policy: policy.key, differences: differences }
112
+ )]
113
+ end
114
+
115
+ # What the rendered HTML can be checked for. Pass the whole page when you
116
+ # have it — the CTA-ordering check needs everything before the block, not
117
+ # just the block.
118
+ def review_rendered_html(html, presentation: nil)
119
+ text = html.to_s
120
+ findings = []
121
+ findings.concat(submit_ordering_findings(text))
122
+ findings.concat(submit_button_text_findings(text, presentation)) if presentation
123
+ findings.concat(preselected_control_findings(text))
124
+ if presentation
125
+ findings.concat(rendered_document_link_findings(text, presentation))
126
+ findings.concat(combined_control_findings(text, presentation))
127
+ end
128
+ findings
129
+ end
130
+
131
+ # Called by the form-builder helper on every development render. Scans
132
+ # only the fragment it produced, so it deliberately skips the CTA-ordering
133
+ # check (a submit button earlier in the host's page is invisible from
134
+ # here — that one belongs in a system test over the whole page).
135
+ def review_rendered_fields(html, presentation:)
136
+ findings = preselected_control_findings(html.to_s)
137
+ findings.concat(rendered_document_link_findings(html.to_s, presentation))
138
+ findings.concat(combined_control_findings(html.to_s, presentation))
139
+ findings.concat(review_presentation(presentation))
140
+ warn_about(findings, source: "policy #{presentation.policy_key}")
141
+ findings
142
+ end
143
+
144
+ # Warnings go to the Rails log, or to stderr when there is no log. Never
145
+ # an exception: a lint finding is a thing to look at, not a reason to stop
146
+ # a developer's page from rendering.
147
+ def warn_about(findings, source: nil)
148
+ Array(findings).each do |finding|
149
+ message = ["[clickwrap] lint", source, "#{finding.code}: #{finding.explanation}"]
150
+ .compact.join(" — ")
151
+
152
+ logger = Clickwrap.logger
153
+ logger ? logger.warn(message) : Kernel.warn(message)
154
+ end
155
+
156
+ findings
157
+ end
158
+
159
+ private
160
+
161
+ # --- Policy checks ------------------------------------------------------
162
+
163
+ def blank_assertion_findings(policy)
164
+ policy.statements.filter_map do |statement|
165
+ next if statement.assertion.present?
166
+
167
+ Finding.new(
168
+ code: :assertion_text_blank,
169
+ explanation: "Statement #{statement.key.inspect} has no assertion text. The sentence " \
170
+ "beside the control is what the receipt records, so a blank one records " \
171
+ "nothing meaningful.",
172
+ context: { policy: policy.key, statement: statement.key }
173
+ )
174
+ end
175
+ end
176
+
177
+ def bundled_consent_findings(policy, locale)
178
+ policy.consent_statements.filter_map do |statement|
179
+ reasons = []
180
+ reasons << "it presents #{statement.document_keys.size} documents" if statement.document_keys.size > 1
181
+ if BUNDLED_PURPOSE_PATTERN.match?(resolved_assertion(statement, locale))
182
+ reasons << "its sentence joins more than one thing with a conjunction"
183
+ end
184
+ next if reasons.empty?
185
+
186
+ Finding.new(
187
+ code: :consent_statement_bundles_purposes,
188
+ explanation: "Consent statement #{statement.key.inspect} may cover more than one " \
189
+ "purpose (#{reasons.join(" and ")}). One control per purpose keeps each " \
190
+ "grant separately withdrawable and keeps the receipt able to say which " \
191
+ "purpose was actually agreed to.",
192
+ context: { policy: policy.key, statement: statement.key }
193
+ )
194
+ end
195
+ end
196
+
197
+ # The words a person would actually read, which is what these heuristics
198
+ # are about. An assertion declared as an I18n key says nothing on its own;
199
+ # when it cannot be resolved (no translation yet), fall back to the
200
+ # declaration rather than turning a lint pass into a failure.
201
+ def resolved_assertion(statement, locale)
202
+ statement.resolve_copy(locale: locale)["assertion"].to_s
203
+ rescue StandardError
204
+ statement.assertion.to_snapshot.to_s
205
+ end
206
+
207
+ def optional_consent_prerequisite_findings(policy)
208
+ optional_consents = policy.consent_statements.select(&:optional?).map(&:key)
209
+ return [] if optional_consents.empty?
210
+
211
+ policy.statements.flat_map do |statement|
212
+ (statement.requires & optional_consents).map do |consent_key|
213
+ Finding.new(
214
+ code: :optional_consent_required_for_another_action,
215
+ explanation: "#{statement.key.inspect} requires the optional consent " \
216
+ "#{consent_key.inspect}, which makes an optional control mandatory for " \
217
+ "an action it is not part of. Either drop `optional: true` and say " \
218
+ "plainly that it is required, or stop requiring it here.",
219
+ context: { policy: policy.key, statement: statement.key, consent: consent_key }
220
+ )
221
+ end
222
+ end
223
+ end
224
+
225
+ # --- Manifest checks ----------------------------------------------------
226
+
227
+ def manifest_differences(rendered, policy:, locale:)
228
+ rendered_keys = rendered.map { |fragment| fragment["key"] || fragment[:key] }
229
+ policy_keys = policy.statements.map(&:key)
230
+
231
+ differences = (policy_keys - rendered_keys).map { |key| "#{key} is in the policy but absent from the manifest" }
232
+ (rendered_keys - policy_keys).each { |key| differences << "#{key} is in the manifest but not the policy" }
233
+
234
+ rendered.each do |fragment|
235
+ statement = policy.statement(fragment["key"] || fragment[:key])
236
+ next if statement.nil?
237
+
238
+ differences.concat(statement_differences(fragment, statement, locale))
239
+ end
240
+
241
+ differences
242
+ end
243
+
244
+ def statement_differences(fragment, statement, locale)
245
+ differences = []
246
+ differences << "#{statement.key} is recorded in the manifest as required=#{fragment["required"]}" if
247
+ fragment.key?("required") && fragment["required"] != statement.required?
248
+
249
+ resolved_locale = (locale || (defined?(::I18n) ? ::I18n.locale : :en)).to_s
250
+ resolved = statement.resolve_copy(locale: resolved_locale)["assertion"]
251
+ differences << "#{statement.key} has different wording in the manifest" if
252
+ fragment["assertion"].present? && resolved.present? && fragment["assertion"] != resolved
253
+
254
+ differences
255
+ end
256
+
257
+ # --- Rendered-HTML checks -----------------------------------------------
258
+
259
+ # Also unchanged by the composed line: whether the block is one control or
260
+ # five, the question is whether a person can reach the action without
261
+ # passing it, and the first answer field is still where the block starts.
262
+ def submit_ordering_findings(html)
263
+ first_control = html.index(ANSWER_FIELD_PATTERN) || html.index(TOKEN_FIELD_PATTERN)
264
+ return [] if first_control.nil?
265
+
266
+ first_submit = html.index(SUBMIT_CONTROL_PATTERN)
267
+ return [] if first_submit.nil? || first_submit > first_control
268
+
269
+ [Finding.new(
270
+ code: :submit_control_before_clickwrap_block,
271
+ explanation: "A submit control appears before the Clickwrap controls in this page. " \
272
+ "Someone can reach the action without having passed what they are being " \
273
+ "asked to accept; put the block immediately before the call to action it " \
274
+ "belongs to.",
275
+ context: { submit_at: first_submit, controls_at: first_control }
276
+ )]
277
+ end
278
+
279
+ # Unchanged by the composed line, and deliberately so: the composed
280
+ # control is submitted under a real statement's name, so a `checked` on
281
+ # one box covering three acts is caught by exactly the same scan that
282
+ # catches a `checked` on a box covering one.
283
+ def preselected_control_findings(html)
284
+ findings = []
285
+ position = 0
286
+
287
+ while (match = ANSWER_FIELD_PATTERN.match(html, position))
288
+ position = match.end(0)
289
+ statement_key = match[1]
290
+ next unless CHECKED_ATTRIBUTE_PATTERN.match?(surrounding_tag(html, match.begin(0)))
291
+
292
+ findings << Finding.new(
293
+ code: :consent_control_preselected,
294
+ explanation: "The control for #{statement_key.inspect} is rendered already selected. " \
295
+ "A pre-ticked box records the page's default rather than a person's " \
296
+ "action; controls must start empty and stay empty until someone acts.",
297
+ context: { statement: statement_key }
298
+ )
299
+ end
300
+
301
+ findings
302
+ end
303
+
304
+ # The composed line's own hazard, and the reason it needs a rule of its
305
+ # own: this presentation signed ONE control covering several statements,
306
+ # and the page rendered controls for the covered ones as well. Usually an
307
+ # ejected view that predates the composed default.
308
+ #
309
+ # It is not a security hole — the server fans the one signed control's
310
+ # answer out to every statement it covers and overwrites whatever else
311
+ # arrived — but it is a page offering choices that do not exist. Somebody
312
+ # unticks one box, submits, and gets a refusal about a statement they
313
+ # thought they had answered separately.
314
+ def combined_control_findings(html, presentation)
315
+ combined = presentation.try(:combined)
316
+ return [] if combined.nil?
317
+
318
+ extra = html.scan(ANSWER_FIELD_PATTERN).flatten.uniq &
319
+ (combined.statement_keys - [combined.answered_as])
320
+ return [] if extra.empty?
321
+
322
+ [Finding.new(
323
+ code: :combined_statement_rendered_as_its_own_control,
324
+ explanation: "This presentation offers one control for " \
325
+ "#{combined.statement_keys.join(", ")}, but the page also renders separate " \
326
+ "controls for #{extra.join(", ")}. The server answers every covered " \
327
+ "statement from the one signed control, so those extra boxes offer a " \
328
+ "choice nobody has — render the composed sentence, or present with " \
329
+ "`combined: false` and mean it.",
330
+ context: { policy: presentation.policy_key, covered: combined.statement_keys, extra: extra }
331
+ )]
332
+ end
333
+
334
+ def rendered_document_link_findings(html, presentation)
335
+ rendered_paths = Loofah.fragment(html).css("a[href]").map { |link| link["href"] }
336
+
337
+ presentation.statements.flat_map do |statement|
338
+ statement.documents.filter_map do |document|
339
+ next if document.path.present? && rendered_paths.include?(document.path)
340
+
341
+ Finding.new(
342
+ code: :document_link_missing,
343
+ explanation: "#{document.key.inspect} is part of #{statement.key.inspect}, but the " \
344
+ "exact immutable path signed into the presentation " \
345
+ "(#{document.path.inspect}) was not rendered as a link. A matching label " \
346
+ "that points somewhere else is not the offered document version.",
347
+ context: { statement: statement.key, document: document.key, expected_path: document.path }
348
+ )
349
+ end
350
+ end
351
+ end
352
+
353
+ def submit_button_text_findings(html, presentation)
354
+ expected = presentation.submit_button_text
355
+ return [] if expected.blank?
356
+
357
+ fragment = Loofah.fragment(html)
358
+ control = fragment.css("input[type='submit'], button[type='submit'], button:not([type])").first
359
+ return [] unless control
360
+
361
+ actual = control.name == "input" ? control["value"] : control.text
362
+ return [] if actual == expected
363
+
364
+ [Finding.new(
365
+ code: :submit_button_text_differs_from_manifest,
366
+ explanation: "The presentation records #{expected.inspect} as its call to action, but " \
367
+ "the rendered submit control says #{actual.inspect}. Render the action with " \
368
+ "form.clickwrap or form.clickwrap_submit so one string supplies both.",
369
+ context: { expected: expected, actual: actual }
370
+ )]
371
+ end
372
+
373
+ # The opening tag the match sits inside, so `checked` on a neighboring
374
+ # element is not mistaken for `checked` on this control.
375
+ def surrounding_tag(html, position)
376
+ start = html.rindex("<", position) || position
377
+ finish = html.index(">", position) || html.length
378
+ html[start..finish]
379
+ end
380
+ end
381
+ end
382
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # Every human-facing string in a policy — an assertion, a label, a link
5
+ # label, a call to action — can be written three ways:
6
+ #
7
+ # statement: "I agree to the Terms." # a literal
8
+ # statement: :"clickwrap.signup.terms" # an I18n key
9
+ # statement: { en: "I agree", es: "Acepto" } # a locale map
10
+ #
11
+ # Whichever form the policy uses, Clickwrap resolves it to actual text before
12
+ # the presentation is built, and stores the resolved text and locale in the
13
+ # evidence. Storing only an I18n key would be a false economy: the key's
14
+ # meaning can change in a later deploy, and then the receipt no longer says
15
+ # what the server bound to the presentation.
16
+ #
17
+ # A missing translation fails closed. Presenting a required legal statement
18
+ # as a raw key, a blank string, or an unexpected fallback language would put
19
+ # something in front of a person and then record it as though it had been
20
+ # legible to them.
21
+ class LocalizedText
22
+ attr_reader :declaration
23
+
24
+ def initialize(declaration)
25
+ @declaration = declaration
26
+ freeze
27
+ end
28
+
29
+ # Returns [text, locale_actually_used].
30
+ def resolve(locale:, interpolations: {})
31
+ case declaration
32
+ when String then [declaration, locale.to_s]
33
+ when Symbol then resolve_i18n_key(locale, interpolations)
34
+ when Hash then resolve_locale_map(locale)
35
+ when Proc then [declaration.call(locale).to_s, locale.to_s]
36
+ when nil then [nil, nil]
37
+ else
38
+ raise DefinitionError,
39
+ "A policy text must be a String, an I18n key Symbol, a locale Hash, or a " \
40
+ "callable, got #{declaration.class}"
41
+ end
42
+ end
43
+
44
+ def present? = !declaration.nil?
45
+
46
+ # How the declaration appears in the compiled policy snapshot. The snapshot
47
+ # records the shape, not the resolved text, because the same revision
48
+ # legitimately renders differently per locale.
49
+ def to_snapshot
50
+ case declaration
51
+ when String then { "kind" => "literal", "value" => declaration }
52
+ when Symbol then { "kind" => "i18n_key", "value" => declaration.to_s }
53
+ when Hash then { "kind" => "locale_map", "value" => declaration.transform_keys(&:to_s).transform_values(&:to_s) }
54
+ when Proc then { "kind" => "callable" }
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def resolve_i18n_key(locale, interpolations)
61
+ unless defined?(::I18n)
62
+ raise DefinitionError,
63
+ "#{declaration.inspect} looks like an I18n key but I18n is not loaded. " \
64
+ "Use a literal string or a locale map instead."
65
+ end
66
+
67
+ text = ::I18n.t(declaration, locale:, default: nil, **interpolations)
68
+
69
+ raise MissingTranslation.new(key: declaration, locale:) if text.nil? || text.to_s.strip.empty?
70
+
71
+ [text.to_s, locale.to_s]
72
+ end
73
+
74
+ def resolve_locale_map(locale)
75
+ normalized = declaration.transform_keys(&:to_s)
76
+ wanted = locale.to_s
77
+
78
+ text = normalized[wanted] || normalized[wanted.split("-").first]
79
+
80
+ raise MissingTranslation.new(key: declaration.keys, locale:) if text.nil?
81
+
82
+ [text.to_s, wanted]
83
+ end
84
+ end
85
+
86
+ # Raised when a required human-facing string has no text for the requested
87
+ # locale. Clickwrap will not present a legal statement in a language the
88
+ # policy did not declare, and will not record one it could not render.
89
+ class MissingTranslation < DefinitionError
90
+ attr_reader :key, :locale
91
+
92
+ def initialize(key:, locale:)
93
+ @key = key
94
+ @locale = locale
95
+ super(
96
+ "No text for #{key.inspect} in locale #{locale.inspect}. Add the translation, or " \
97
+ "restrict the policy to the locales it can actually present."
98
+ )
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # The host-facing macro. The engine extends `ActiveRecord::Base` with this
5
+ # module (via `ActiveSupport.on_load(:active_record)`), so the actor model can
6
+ # declare:
7
+ #
8
+ # class User < ApplicationRecord
9
+ # has_clickwraps
10
+ # end
11
+ #
12
+ # That gives the model its evidence proxy — `user.clickwraps.agreed_to?(:terms)`
13
+ # and friends — plus the associations, without Clickwrap reaching into the
14
+ # model for anything else. Same grammar as the rest of the ecosystem:
15
+ # `has_sessions`, `has_credits`, `has_api_keys`, `has_wallets`.
16
+ #
17
+ # Note what the macro deliberately does NOT add: a `dependent: :destroy` on
18
+ # the evidence association. Deleting an account must not silently erase the
19
+ # record of what that person agreed to; the associations nullify the actor
20
+ # link and leave a stable pseudonymous reference behind, and what happens next
21
+ # is a retention decision the host makes on purpose.
22
+ #
23
+ # The macro is a thin forwarder — all behavior lives in Clickwrap::HasClickwraps
24
+ # so it is discoverable, testable, and `include`-able directly when a host
25
+ # prefers that style.
26
+ #
27
+ # We don't `require_relative` the concern here even though this file is
28
+ # required by the spine at gem-load time. The concern lives under
29
+ # `lib/clickwrap/models/concerns/` and is autoloaded by Zeitwerk (the engine
30
+ # pushes that subtree under the `Clickwrap` namespace with `models` and
31
+ # `concerns` collapsed). Requiring it here too would double-manage the same
32
+ # constant and make Zeitwerk raise on its eager-load pass. This is safe
33
+ # because the macro body only REFERENCES the constant, and it runs when a host
34
+ # model calls `has_clickwraps` — long after boot, when the autoloader is
35
+ # fully wired.
36
+ module Macros
37
+ def has_clickwraps
38
+ include Clickwrap::HasClickwraps unless include?(Clickwrap::HasClickwraps)
39
+ self
40
+ end
41
+
42
+ # For a domain row whose existence was authorized by a clickwrap capture —
43
+ # a withdrawal, a signed declaration, a provisioned contract. Expects a
44
+ # `clickwrap_event_id` column (`bin/rails generate clickwrap:link
45
+ # your_table` writes the migration) and an explicit evidence contract:
46
+ #
47
+ # has_clickwrap_evidence policy: :withdrawal_authorization,
48
+ # statement: :withdrawal,
49
+ # actor: :user,
50
+ # subject: :self
51
+ #
52
+ # It then reads aloud from either end:
53
+ #
54
+ # capture_clickwrap_and!(:withdrawal_authorization) do |pending_receipt|
55
+ # withdrawal.clickwrap_event_id = pending_receipt.event_id
56
+ # withdrawal.save!
57
+ # end
58
+ #
59
+ # withdrawal.clickwrap_event # the evidence event behind this row
60
+ # withdrawal.clickwrap_receipt # its receipt: .verify, .to_canonical_json
61
+ #
62
+ # No database foreign key, deliberately: evidence and domain rows keep
63
+ # independent retention schedules. The model contract is still strict for
64
+ # every new link: it checks the policy, statement, human actor, subject,
65
+ # tenant, and represented party, and it never lets a link be replaced.
66
+ # Rows that predate the gem may remain nil; new rows require evidence by
67
+ # default.
68
+ def has_clickwrap_evidence(policy:, statement:, actor:, subject:, tenant: nil,
69
+ represented_party: nil, required_for_new_records: true)
70
+ belongs_to :clickwrap_event, class_name: "Clickwrap::Event", optional: true
71
+
72
+ class_attribute :clickwrap_evidence_contract, instance_writer: false
73
+ self.clickwrap_evidence_contract = {
74
+ policy: policy.to_s,
75
+ statement: statement.to_s,
76
+ actor: actor,
77
+ subject: subject,
78
+ tenant: tenant,
79
+ represented_party: represented_party,
80
+ required_for_new_records: required_for_new_records == true
81
+ }.freeze
82
+
83
+ validate :validate_clickwrap_evidence_is_present_for_new_record
84
+ validate :validate_clickwrap_evidence_link_cannot_be_replaced
85
+ validate :validate_clickwrap_evidence_matches_this_record
86
+
87
+ define_method(:clickwrap_receipt) do
88
+ self.class.column_names.include?("clickwrap_event_id") ? clickwrap_event&.receipt : nil
89
+ end
90
+
91
+ define_method(:validate_clickwrap_evidence_is_present_for_new_record) do
92
+ # A host may deploy the model macro before the link migration, and old
93
+ # data migrations may load today's model while replaying a schema from
94
+ # before Clickwrap existed. The contract becomes strict as soon as the
95
+ # column exists; before then there is no attribute that could carry the
96
+ # evidence, so the macro must stay inert instead of crashing deploys.
97
+ # Class-level `column_names`, never per-row `has_attribute?`: a partial
98
+ # SELECT must not read as "this row has no evidence contract".
99
+ return unless self.class.column_names.include?("clickwrap_event_id")
100
+
101
+ contract = self.class.clickwrap_evidence_contract
102
+ return unless new_record? && contract.fetch(:required_for_new_records)
103
+ return if clickwrap_event_id.present?
104
+
105
+ errors.add(
106
+ :clickwrap_event,
107
+ "must be linked inside the Clickwrap protected-action transaction"
108
+ )
109
+ end
110
+
111
+ define_method(:validate_clickwrap_evidence_link_cannot_be_replaced) do
112
+ return unless self.class.column_names.include?("clickwrap_event_id")
113
+ return unless persisted? && will_save_change_to_clickwrap_event_id?
114
+
115
+ previous_id, next_id = clickwrap_event_id_change_to_be_saved
116
+ return if previous_id.blank? || previous_id == next_id
117
+
118
+ errors.add(
119
+ :clickwrap_event,
120
+ "cannot be replaced or removed after it has been linked"
121
+ )
122
+ end
123
+
124
+ define_method(:validate_clickwrap_evidence_matches_this_record) do
125
+ return unless self.class.column_names.include?("clickwrap_event_id")
126
+
127
+ should_validate = new_record? || will_save_change_to_clickwrap_event_id?
128
+ return unless should_validate && clickwrap_event_id.present?
129
+
130
+ event = Clickwrap::Event.find_by(id: clickwrap_event_id)
131
+ unless event
132
+ errors.add(:clickwrap_event, "does not identify an existing Clickwrap event")
133
+ next
134
+ end
135
+
136
+ contract = self.class.clickwrap_evidence_contract
137
+ expected_policy = Clickwrap.policy!(contract.fetch(:policy))
138
+ expected_statement = expected_policy.statement!(contract.fetch(:statement))
139
+
140
+ mismatches = []
141
+ mismatches << "a capture event" unless event.event_type == "capture"
142
+ mismatches << "policy #{expected_policy.key.inspect}" unless event.policy_key == expected_policy.key
143
+
144
+ statement_event = event.statements.find do |candidate|
145
+ candidate.statement_key == expected_statement.key &&
146
+ candidate.action == expected_statement.initial_action
147
+ end
148
+ mismatches << "statement #{expected_statement.key.inspect}" unless statement_event
149
+
150
+ expected_actor = clickwrap_evidence_contract_value(contract.fetch(:actor), :actor)
151
+ mismatches << "this record's actor" unless
152
+ event.actor_reference == Clickwrap::Reference.actor(expected_actor)
153
+
154
+ expected_subject = clickwrap_evidence_contract_value(contract.fetch(:subject), :subject)
155
+ unless event.subject_key.to_s == Clickwrap::Reference.subject(expected_subject).to_s
156
+ mismatches << "this record's subject"
157
+ end
158
+
159
+ expected_tenant = clickwrap_evidence_contract_value(contract.fetch(:tenant), :tenant)
160
+ unless event.tenant_key.to_s == Clickwrap::Reference.tenant(expected_tenant).to_s
161
+ mismatches << "this record's tenant"
162
+ end
163
+
164
+ expected_party = clickwrap_evidence_contract_value(
165
+ contract.fetch(:represented_party),
166
+ :represented_party
167
+ )
168
+ unless event.represented_party_reference.to_s ==
169
+ Clickwrap::Reference.represented_party(expected_party).to_s
170
+ mismatches << "this record's represented party"
171
+ end
172
+
173
+ next unless mismatches.any?
174
+
175
+ errors.add(
176
+ :clickwrap_event,
177
+ "must be #{mismatches.to_sentence}; the supplied event belongs to a different act"
178
+ )
179
+ end
180
+
181
+ define_method(:clickwrap_evidence_contract_value) do |resolver, name|
182
+ case resolver
183
+ when :self
184
+ self
185
+ when Symbol, String
186
+ public_send(resolver)
187
+ else
188
+ resolver.respond_to?(:call) ? resolver.call(self) : resolver
189
+ end
190
+ rescue NoMethodError => error
191
+ raise DefinitionError,
192
+ "The `#{name}:` resolver for #{self.class.name}.has_clickwrap_evidence could not " \
193
+ "be called: #{error.message}"
194
+ end
195
+
196
+ private :validate_clickwrap_evidence_is_present_for_new_record,
197
+ :validate_clickwrap_evidence_link_cannot_be_replaced,
198
+ :validate_clickwrap_evidence_matches_this_record,
199
+ :clickwrap_evidence_contract_value
200
+ self
201
+ end
202
+ end
203
+ end