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.
- checksums.yaml +4 -4
- data/.rubocop.yml +90 -0
- data/CHANGELOG.md +612 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +241 -0
- data/app/controllers/clickwrap/application_controller.rb +79 -0
- data/app/controllers/clickwrap/captures_controller.rb +145 -0
- data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
- data/app/controllers/clickwrap/receipts_controller.rb +115 -0
- data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
- data/app/helpers/clickwrap/engine_helper.rb +97 -0
- data/app/views/clickwrap/captures/show.html.erb +34 -0
- data/app/views/clickwrap/receipts/index.html.erb +38 -0
- data/app/views/clickwrap/receipts/show.html.erb +91 -0
- data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
- data/app/views/clickwrap/shared/_fields.html.erb +100 -0
- data/app/views/clickwrap/shared/_statement.html.erb +105 -0
- data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
- data/config/locales/en.yml +160 -0
- data/config/locales/es.yml +138 -0
- data/config/routes.rb +41 -0
- data/exe/clickwrap +374 -0
- data/guides/README.md +30 -0
- data/guides/accessibility.md +249 -0
- data/guides/consent-and-lifecycle.md +299 -0
- data/guides/integrating.md +610 -0
- data/guides/integrity.md +212 -0
- data/guides/migrating.md +335 -0
- data/guides/naming.md +320 -0
- data/guides/organizations.md +320 -0
- data/guides/receipts-and-verification.md +415 -0
- data/guides/request-evidence.md +512 -0
- data/guides/retention-and-legal-holds.md +438 -0
- data/lib/clickwrap/actor_proxy.rb +147 -0
- data/lib/clickwrap/anonymous_actor.rb +47 -0
- data/lib/clickwrap/authority.rb +174 -0
- data/lib/clickwrap/canonical_json.rb +216 -0
- data/lib/clickwrap/capture/event_builder.rb +220 -0
- data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
- data/lib/clickwrap/capture.rb +650 -0
- data/lib/clickwrap/configuration.rb +1129 -0
- data/lib/clickwrap/controller_helpers.rb +758 -0
- data/lib/clickwrap/current_state.rb +282 -0
- data/lib/clickwrap/digest.rb +125 -0
- data/lib/clickwrap/doctor.rb +418 -0
- data/lib/clickwrap/document_definition.rb +255 -0
- data/lib/clickwrap/document_renderer.rb +83 -0
- data/lib/clickwrap/document_renderers/markdown.rb +175 -0
- data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
- data/lib/clickwrap/dsl/policy_builder.rb +462 -0
- data/lib/clickwrap/dsl/retention_builder.rb +89 -0
- data/lib/clickwrap/durable_commit_callback.rb +37 -0
- data/lib/clickwrap/engine.rb +184 -0
- data/lib/clickwrap/errors.rb +181 -0
- data/lib/clickwrap/form_builder_extensions.rb +341 -0
- data/lib/clickwrap/front_matter.rb +67 -0
- data/lib/clickwrap/identifier.rb +112 -0
- data/lib/clickwrap/import/external_receipt.rb +241 -0
- data/lib/clickwrap/import/fine_print.rb +290 -0
- data/lib/clickwrap/import/legacy.rb +450 -0
- data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
- data/lib/clickwrap/integrity/anchor.rb +130 -0
- data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
- data/lib/clickwrap/integrity/attestor.rb +221 -0
- data/lib/clickwrap/integrity/chain.rb +313 -0
- data/lib/clickwrap/integrity/timestamp.rb +143 -0
- data/lib/clickwrap/ip_geolocation/location.rb +112 -0
- data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
- data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
- data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
- data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
- data/lib/clickwrap/ip_geolocation.rb +16 -0
- data/lib/clickwrap/lifecycle.rb +534 -0
- data/lib/clickwrap/linter.rb +382 -0
- data/lib/clickwrap/localized_text.rb +101 -0
- data/lib/clickwrap/macros.rb +203 -0
- data/lib/clickwrap/models/application_record.rb +20 -0
- data/lib/clickwrap/models/chain_head.rb +79 -0
- data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
- data/lib/clickwrap/models/disposition_plan.rb +208 -0
- data/lib/clickwrap/models/document.rb +46 -0
- data/lib/clickwrap/models/document_version.rb +163 -0
- data/lib/clickwrap/models/event.rb +743 -0
- data/lib/clickwrap/models/event_document.rb +79 -0
- data/lib/clickwrap/models/event_statement.rb +92 -0
- data/lib/clickwrap/models/external_action.rb +150 -0
- data/lib/clickwrap/models/integrity_attestation.rb +90 -0
- data/lib/clickwrap/models/legal_hold.rb +81 -0
- data/lib/clickwrap/models/policy_revision.rb +115 -0
- data/lib/clickwrap/models/presentation.rb +59 -0
- data/lib/clickwrap/models/receipt_access.rb +53 -0
- data/lib/clickwrap/models/recording_sequence.rb +21 -0
- data/lib/clickwrap/models/request_evidence.rb +378 -0
- data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
- data/lib/clickwrap/models/statement_state.rb +130 -0
- data/lib/clickwrap/pending_receipt.rb +177 -0
- data/lib/clickwrap/policy.rb +283 -0
- data/lib/clickwrap/presentation_manifest.rb +210 -0
- data/lib/clickwrap/presenter.rb +716 -0
- data/lib/clickwrap/privacy.rb +419 -0
- data/lib/clickwrap/protected_outcome.rb +120 -0
- data/lib/clickwrap/receipt.rb +606 -0
- data/lib/clickwrap/receipt_html.rb +235 -0
- data/lib/clickwrap/receipt_verifier.rb +978 -0
- data/lib/clickwrap/reference.rb +44 -0
- data/lib/clickwrap/registration.rb +236 -0
- data/lib/clickwrap/registry.rb +54 -0
- data/lib/clickwrap/remediation_token.rb +155 -0
- data/lib/clickwrap/request_evidence_extractor.rb +590 -0
- data/lib/clickwrap/request_evidence_policy.rb +261 -0
- data/lib/clickwrap/retention/applier.rb +231 -0
- data/lib/clickwrap/retention/disposition.rb +221 -0
- data/lib/clickwrap/retention/planner.rb +502 -0
- data/lib/clickwrap/retention_class.rb +97 -0
- data/lib/clickwrap/reviewed_text.rb +28 -0
- data/lib/clickwrap/schema_requirements.rb +196 -0
- data/lib/clickwrap/services/authorize_external_action.rb +149 -0
- data/lib/clickwrap/services/load_policies.rb +69 -0
- data/lib/clickwrap/services/publish_documents.rb +251 -0
- data/lib/clickwrap/services/validate_policy_references.rb +166 -0
- data/lib/clickwrap/statement.rb +248 -0
- data/lib/clickwrap/subject_fingerprint.rb +28 -0
- data/lib/clickwrap/submission.rb +169 -0
- data/lib/clickwrap/system_actor.rb +31 -0
- data/lib/clickwrap/test_helpers.rb +676 -0
- data/lib/clickwrap/testing.rb +211 -0
- data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
- data/lib/clickwrap/verification.rb +504 -0
- data/lib/clickwrap/version.rb +12 -1
- data/lib/clickwrap/view_helpers.rb +190 -0
- data/lib/clickwrap/vocabulary.rb +294 -0
- data/lib/clickwrap.rb +497 -7
- data/lib/generators/clickwrap/document_generator.rb +164 -0
- data/lib/generators/clickwrap/hardening_generator.rb +177 -0
- data/lib/generators/clickwrap/install_generator.rb +1287 -0
- data/lib/generators/clickwrap/link_generator.rb +56 -0
- data/lib/generators/clickwrap/policy_generator.rb +118 -0
- data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
- data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
- data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
- data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
- data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
- data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
- data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
- data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
- data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
- data/lib/generators/clickwrap/views_generator.rb +101 -0
- data/lib/tasks/clickwrap.rake +569 -0
- 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
|