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,716 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # Builds a presentation: resolves the policy's documents and copy for one
5
+ # locale, records the exact call to action, and signs the result into a
6
+ # short-lived token.
7
+ #
8
+ # This is the one place that turns a policy into something a person can see,
9
+ # and everything that renders — the form-builder helper, the reference views,
10
+ # the engine's standalone screen, a JSON API response, a fully custom design
11
+ # system — goes through it. There is deliberately no second path: a shortcut
12
+ # that skipped the manifest would produce evidence that looks identical and
13
+ # proves considerably less.
14
+ class Presenter
15
+ # The only two kinds a composed sentence may absorb. A consent must stay
16
+ # unbundled to be separately withdrawable and separately provable, and a
17
+ # declaration, attestation, or authorization is a specific assertion someone
18
+ # should have to read on its own line rather than find folded into a
19
+ # sentence about something else.
20
+ COMPOSABLE_KINDS = %w[agreement acknowledgment].freeze
21
+
22
+ # Where a sentence fragment puts the documents it is about.
23
+ DOCUMENTS_PLACEHOLDER = "%{documents}"
24
+
25
+ Statement = Data.define(:key, :kind, :assertion, :label, :required, :optional, :choices,
26
+ :requires_an_explicit_choice, :documents, :control_name,
27
+ :control_id, :error_id, :purpose_key, :withdrawal_path) do
28
+ def required? = required
29
+ def optional? = optional
30
+ def requires_an_explicit_choice? = requires_an_explicit_choice
31
+ def checkbox? = choices.nil?
32
+ end
33
+
34
+ Document = Data.define(:key, :label, :version_label, :locale, :source_media_type,
35
+ :source_content_digest, :rendered_media_type,
36
+ :rendered_content_digest, :renderer_name, :renderer_version,
37
+ :sanitizer_name, :sanitizer_version, :version_id, :path)
38
+
39
+ # One statement's share of a composed sentence, already split around the
40
+ # place its documents go. Kept as parts rather than a template with a
41
+ # placeholder so a view can drop real links into the middle of a sentence
42
+ # without ever concatenating HTML into translated text.
43
+ Fragment = Data.define(:statement_key, :kind, :prefix, :suffix, :documents, :documents_joiner) do
44
+ def to_text = "#{prefix}#{documents.map(&:label).join(documents_joiner)}#{suffix}"
45
+ end
46
+
47
+ # The one control and the one sentence a composable policy renders. It is
48
+ # not a statement and never becomes one: the statements it covers keep their
49
+ # own kinds, documents, answers, and lifecycles in the evidence. What this
50
+ # object describes is the offer a person saw.
51
+ #
52
+ # It answers everything a Statement answers about a CONTROL — the name, the
53
+ # id, the error id, whether it is required, whether it has choices — so the
54
+ # reference views render one control the same way whether it stands for one
55
+ # act or three, and a host's ejected statement partial styles both. What it
56
+ # does not answer is anything about an act, because it is not one.
57
+ Combined = Data.define(:sentence, :fragments, :joiner, :terminator, :statement_keys,
58
+ :control_name, :control_id, :error_id) do
59
+ def covers?(statement_key) = statement_keys.include?(statement_key.to_s)
60
+
61
+ # Always. Only required statements compose, so the single control is
62
+ # required too — which also lets a custom surface pass this object to
63
+ # `clickwrap_statement_check_box` and get the same markup contract a
64
+ # statement gets.
65
+ def required? = true
66
+ def optional? = false
67
+ def checkbox? = true
68
+ def choices = nil
69
+ def requires_an_explicit_choice? = false
70
+
71
+ # Composed, so there is no single kind — and "combined" is what the
72
+ # rendered class name should say, because that is what a person is
73
+ # looking at.
74
+ def kind = "combined"
75
+
76
+ # The words, without their links. The views render the linked form
77
+ # through `clickwrap_statement_label`; this is for anything reading the
78
+ # offer as text.
79
+ def assertion = sentence
80
+
81
+ # The links live inside the sentence, so there is no list of documents to
82
+ # render beneath it.
83
+ def documents = [].freeze
84
+ def withdrawal_path = nil
85
+
86
+ # The statement whose name the single control carries. Every covered
87
+ # statement is answered by it; this is the one whose key it is submitted
88
+ # under, so a browser sends one value and the server fans it out.
89
+ def answered_as = statement_keys.first
90
+ end
91
+
92
+ Result = Data.define(:policy, :manifest, :token, :statements, :combined, :submit_button_text,
93
+ :locale, :actor, :subject, :represented_party, :tenant_key) do
94
+ def statement(key) = statements.find { |candidate| candidate.key == key.to_s }
95
+ def combined? = !combined.nil?
96
+ def policy_key = policy.key
97
+ def revision = manifest.revision_digest
98
+ def to_h = manifest.to_h
99
+ def as_json(*) = manifest.to_h
100
+
101
+ # The statements this presentation renders as controls of their own:
102
+ # everything the composed line could not honestly absorb.
103
+ def itemized_statements
104
+ return statements if combined.nil?
105
+
106
+ statements.reject { |statement| combined.covers?(statement.key) }
107
+ end
108
+ end
109
+
110
+ def initialize(policy:, actor: nil, subject: nil, tenant: nil, locale: nil,
111
+ submit_button_text: nil, capture_channel: :web_browser,
112
+ registration_flow_id: nil, prospective_actor: nil, acting_for: nil,
113
+ represented_party_creation_flow_id: nil,
114
+ authentication_context: nil,
115
+ document_version_path_with: nil,
116
+ default_document_version_path_with: nil,
117
+ combined: true)
118
+ @policy = policy
119
+ @actor = actor
120
+ @prospective_actor = prospective_actor
121
+ @subject = subject
122
+ @tenant = tenant
123
+ @locale = (locale || default_locale).to_s
124
+ @submit_button_text = submit_button_text
125
+ @capture_channel = capture_channel.to_s
126
+ @registration_flow_id = registration_flow_id
127
+ @acting_for = acting_for
128
+ @represented_party_creation_flow_id = represented_party_creation_flow_id
129
+ @authentication_context = (authentication_context || {}).to_h.symbolize_keys
130
+ @document_version_path_with = document_version_path_with
131
+ # Wired by `form.clickwrap` and `present_clickwrap` from the render
132
+ # context, never by a host: it is the engine fallback with the render's
133
+ # own Hotwire Native treatment attached, and it is asked LAST, after a
134
+ # document's declared `link:` has had its say.
135
+ @default_document_version_path_with = default_document_version_path_with
136
+ @combined = combined != false
137
+
138
+ return unless @document_version_path_with && !@document_version_path_with.respond_to?(:call)
139
+
140
+ raise ArgumentError,
141
+ "document_version_path_with must respond to call(version), so Clickwrap can bind " \
142
+ "the exact immutable link target into the presentation."
143
+ end
144
+
145
+ attr_reader :policy, :actor, :subject, :tenant, :locale, :capture_channel
146
+
147
+ def present
148
+ policy.validate_tenant!(tenant)
149
+ validate_actor_binding!
150
+ validate_subject_binding!
151
+ validate_represented_party_binding!
152
+ validate_channel!
153
+ validate_locale!
154
+ authority_at_presentation = authority_at_presentation_snapshot
155
+
156
+ revision = PolicyRevision.freeze_for(policy)
157
+ resolved = policy.statements.map { |statement| resolve_statement(statement) }
158
+ combined = compose(resolved)
159
+
160
+ manifest = PresentationManifest.build(
161
+ policy: policy,
162
+ revision_digest: revision.revision_digest,
163
+ statements: resolved.map { |statement| manifest_fragment(statement) },
164
+ submit_button_text: @submit_button_text,
165
+ locale: locale,
166
+ actor_reference: actor_reference,
167
+ actor_type: actor&.class&.name,
168
+ tenant_key: tenant_key,
169
+ subject_key: subject_key,
170
+ subject_fingerprint: subject_fingerprint,
171
+ registration_flow_id: @registration_flow_id,
172
+ prospective_actor_type: @prospective_actor&.class&.name,
173
+ represented_party_reference: represented_party_reference,
174
+ represented_party_type: @acting_for&.class&.name,
175
+ authority_rule: policy.authority_rule&.to_snapshot,
176
+ represented_party_creation_flow_id: @represented_party_creation_flow_id,
177
+ represented_party_will_be_created_by_protected_action:
178
+ prospective_represented_party?,
179
+ authority_at_presentation: authority_at_presentation,
180
+ combined_control: combined_control_fragment(combined),
181
+ capture_channel: capture_channel
182
+ )
183
+
184
+ persist_presentation(manifest, revision) if policy.persist_presentations?
185
+
186
+ Result.new(
187
+ policy: policy,
188
+ manifest: manifest,
189
+ token: manifest.to_token,
190
+ statements: resolved,
191
+ combined: combined,
192
+ submit_button_text: @submit_button_text,
193
+ locale: locale,
194
+ actor: actor,
195
+ subject: subject,
196
+ represented_party: @acting_for,
197
+ tenant_key: tenant_key
198
+ )
199
+ end
200
+
201
+ # The fingerprint of whatever the policy says identifies this subject. It is
202
+ # how an authorization for one withdrawal stops being usable for a different
203
+ # one, and how a declaration about one set of orders stops covering a
204
+ # changed set.
205
+ def subject_fingerprint
206
+ SubjectFingerprint.for(policy, subject)
207
+ end
208
+
209
+ def subject_key = StatementState.subject_key_for(subject)
210
+
211
+ def tenant_key = Reference.tenant(tenant)
212
+
213
+ def actor_reference
214
+ return nil if actor.nil?
215
+
216
+ Reference.actor(actor)
217
+ end
218
+
219
+ def represented_party_reference
220
+ return nil if @acting_for.nil?
221
+ return "represented_party_creation/#{@represented_party_creation_flow_id}" if prospective_represented_party?
222
+
223
+ Reference.represented_party(@acting_for)
224
+ end
225
+
226
+ private
227
+
228
+ def validate_subject_binding!
229
+ return unless policy.subject_bound? && subject.nil?
230
+
231
+ raise DefinitionError,
232
+ "Policy #{policy.key} binds evidence to a subject fingerprint, so presenting it " \
233
+ "requires `subject:`. A subject-bound policy cannot be completed from a generic " \
234
+ "URL with no server-owned resource context."
235
+ end
236
+
237
+ def validate_actor_binding!
238
+ return if actor
239
+
240
+ unless @prospective_actor
241
+ raise DefinitionError,
242
+ "Presenting policy #{policy.key} without an actor requires `prospective_actor:`. " \
243
+ "Use it only for a new account registration flow; anonymous users need an " \
244
+ "explicit `Clickwrap.anonymous_actor(...)` reference."
245
+ end
246
+
247
+ return if @registration_flow_id.present?
248
+
249
+ raise DefinitionError,
250
+ "A prospective-actor presentation needs `registration_flow_id:` from server-owned " \
251
+ "session state, so a token from another signup flow cannot create evidence for this account."
252
+ end
253
+
254
+ def validate_represented_party_binding!
255
+ if @acting_for.nil?
256
+ if @represented_party_creation_flow_id.present?
257
+ raise DefinitionError,
258
+ "A represented-party creation flow needs `acting_for:` to name the exact new record."
259
+ end
260
+
261
+ # A policy may support represented-party actions and ordinary personal
262
+ # actions. No represented party on this particular presentation is a
263
+ # valid, explicitly bound state.
264
+ return
265
+ end
266
+
267
+ unless policy.permits_acting_for_party?(@acting_for)
268
+ allowed = policy.authority_rule&.represented_party_types
269
+ raise DefinitionError,
270
+ "Policy #{policy.key} does not permit acting for #{@acting_for.class.name}. " \
271
+ "Allowed represented-party types: #{allowed&.join(", ").presence || "(none)"}."
272
+ end
273
+
274
+ if prospective_represented_party?
275
+ unless policy.authority_rule.allows_represented_party_creation?
276
+ raise DefinitionError,
277
+ "Policy #{policy.key} does not permit creating the represented party inside " \
278
+ "the protected action. Opt in explicitly in the represented-party authority rule."
279
+ end
280
+ if @represented_party_creation_flow_id.blank?
281
+ raise DefinitionError,
282
+ "Presenting a new represented party needs " \
283
+ "`represented_party_creation_flow_id:` from server-owned session state."
284
+ end
285
+ elsif @represented_party_creation_flow_id.present?
286
+ raise DefinitionError,
287
+ "`represented_party_creation_flow_id:` is only valid while `acting_for:` is a new record."
288
+ end
289
+ end
290
+
291
+ def prospective_represented_party?
292
+ @acting_for.respond_to?(:new_record?) && @acting_for.new_record?
293
+ end
294
+
295
+ def authority_at_presentation_snapshot
296
+ return nil if @acting_for.nil?
297
+
298
+ if prospective_represented_party?
299
+ return {
300
+ "state" => "not_yet_verifiable",
301
+ "reason" => "represented_party_will_be_created_by_protected_action"
302
+ }
303
+ end
304
+
305
+ AuthorityVerifier.verify!(
306
+ policy: policy,
307
+ actor: actor,
308
+ represented_party: @acting_for,
309
+ tenant: tenant,
310
+ authentication_context: @authentication_context
311
+ ).to_snapshot
312
+ end
313
+
314
+ def default_locale
315
+ defined?(::I18n) ? ::I18n.locale : :en
316
+ end
317
+
318
+ def validate_channel!
319
+ return if policy.permits_capture_channel?(capture_channel)
320
+
321
+ raise DefinitionError,
322
+ "Policy #{policy.key} does not accept captures from #{capture_channel}. It allows: " \
323
+ "#{policy.capture_channels.join(", ")}."
324
+ end
325
+
326
+ def validate_locale!
327
+ return if policy.permits_locale?(locale)
328
+
329
+ raise MissingTranslation.new(key: policy.key, locale: locale)
330
+ end
331
+
332
+ def resolve_statement(statement)
333
+ copy = statement.resolve_copy(locale: locale)
334
+ documents = resolve_documents(statement)
335
+
336
+ Statement.new(
337
+ key: statement.key,
338
+ kind: statement.kind,
339
+ assertion: copy["assertion"],
340
+ label: copy["label"],
341
+ required: statement.required?,
342
+ optional: statement.optional?,
343
+ choices: statement.choices,
344
+ requires_an_explicit_choice: statement.requires_an_explicit_choice?,
345
+ documents: documents,
346
+ control_name: control_name(statement),
347
+ control_id: "clickwrap_#{policy.key}_#{statement.key}",
348
+ error_id: "clickwrap_#{policy.key}_#{statement.key}_error",
349
+ purpose_key: statement.purpose_key,
350
+ withdrawal_path: statement.withdrawal_path
351
+ )
352
+ end
353
+
354
+ # --- Composing the one-line offer ----------------------------------------
355
+ #
356
+ # A signup screen asks for two ordinary things — agree to the Terms,
357
+ # acknowledge the Privacy Notice — and rendering them as two stacked
358
+ # checkboxes with a "Required" flag, a version label, and an "(opens in a
359
+ # new tab)" hint apiece is four times the interface the moment deserves.
360
+ # When every statement is one of those ordinary things, they compose into
361
+ # ONE control carrying ONE sentence with the documents linked inside it.
362
+ #
363
+ # Composing is a decision about PRESENTATION only. The evidence keeps every
364
+ # statement separate — its own kind, its own documents, its own answer, its
365
+ # own lifecycle — because "agreed to the Terms" and "acknowledged the
366
+ # Privacy Notice" remain different facts no matter how few boxes it took to
367
+ # say both.
368
+
369
+ def compose(resolved)
370
+ return nil unless @combined
371
+
372
+ composable = resolved.select { |statement| composable?(statement) }
373
+ return nil if composable.empty?
374
+
375
+ connectives = sentence_connectives
376
+ return nil if connectives.nil?
377
+
378
+ fragments = composable.map { |statement| sentence_fragment(statement, connectives) }
379
+ return nil if fragments.any?(&:nil?)
380
+
381
+ build_combined(composable, fragments, connectives)
382
+ end
383
+
384
+ def build_combined(composable, fragments, connectives)
385
+ control = composable.first
386
+
387
+ Combined.new(
388
+ sentence: fragments.map(&:to_text).join(connectives[:joiner]) + connectives[:terminator],
389
+ fragments: fragments.freeze,
390
+ joiner: connectives[:joiner],
391
+ terminator: connectives[:terminator],
392
+ statement_keys: composable.map(&:key).freeze,
393
+ control_name: control.control_name,
394
+ control_id: control.control_id,
395
+ error_id: control.error_id
396
+ )
397
+ end
398
+
399
+ # Every clause here is a way a statement can be more than the sentence can
400
+ # honestly say. An optional consent bundled into a required line would make
401
+ # it required; a choice folded into a checkbox would turn a recorded "no"
402
+ # into an unrecorded silence; a statement with a withdrawal route needs that
403
+ # route beside the control it belongs to; and copy the application wrote
404
+ # itself is copy it wrote for a reason.
405
+ def composable?(statement)
406
+ return false unless COMPOSABLE_KINDS.include?(statement.kind)
407
+ return false unless statement.required? && statement.checkbox?
408
+ return false if statement.withdrawal_path.present?
409
+ return false if statement.documents.empty?
410
+
411
+ default_worded?(policy.statement!(statement.key))
412
+ end
413
+
414
+ # The assertion is still the conventional key the DSL fills in, so nobody
415
+ # has chosen these words for this policy. A policy that did — a literal, a
416
+ # locale map, its own I18n key — gets its own control and its own sentence,
417
+ # unedited.
418
+ def default_worded?(declared)
419
+ declared.assertion.declaration == :"clickwrap.statements.#{declared.kind}.#{declared.key}"
420
+ end
421
+
422
+ # The words that hold a composed sentence together, in the locale being
423
+ # presented. A locale that has not translated them composes nothing and
424
+ # renders itemized instead — half a sentence in the wrong language is worse
425
+ # than two tidy lines in the right one.
426
+ def sentence_connectives
427
+ joiner = sentence_text("joiner")
428
+ documents_joiner = sentence_text("documents_joiner")
429
+ terminator = sentence_text("terminator")
430
+ return nil if joiner.nil? || documents_joiner.nil? || terminator.nil?
431
+
432
+ { joiner: joiner, documents_joiner: documents_joiner, terminator: terminator }
433
+ end
434
+
435
+ def sentence_fragment(statement, connectives)
436
+ template = sentence_text(statement.kind)
437
+ return nil if template.nil?
438
+
439
+ prefix, suffix = template.split(DOCUMENTS_PLACEHOLDER, 2)
440
+ # A translation with nowhere to put the documents would render a sentence
441
+ # about documents nobody can open. Itemize instead.
442
+ return nil if suffix.nil?
443
+
444
+ Fragment.new(
445
+ statement_key: statement.key,
446
+ kind: statement.kind,
447
+ prefix: prefix,
448
+ suffix: suffix,
449
+ documents: statement.documents,
450
+ documents_joiner: connectives[:documents_joiner]
451
+ )
452
+ end
453
+
454
+ # What the manifest signs about a composed offer. The per-statement
455
+ # fragments already carry the acts, the documents, and their digests; this
456
+ # carries the thing only the composed shape has — the exact sentence a
457
+ # person read, and which statement keys the one answer they gave covers.
458
+ def combined_control_fragment(combined)
459
+ return nil if combined.nil?
460
+
461
+ {
462
+ "sentence" => combined.sentence,
463
+ "covers" => combined.statement_keys,
464
+ "answered_as" => combined.answered_as,
465
+ "control_name" => combined.control_name
466
+ }
467
+ end
468
+
469
+ def sentence_text(key)
470
+ return nil unless defined?(::I18n)
471
+
472
+ text = ::I18n.t("clickwrap.sentence.#{key}", locale: locale, default: nil)
473
+ text&.to_s
474
+ end
475
+
476
+ # The form field name. Answers arrive nested under one key so a host's
477
+ # strong parameters can permit the whole envelope in one line, and so an
478
+ # answer can never be mistaken for one of the host's own attributes.
479
+ def control_name(statement)
480
+ "clickwrap_submission[answers][#{statement.key}]"
481
+ end
482
+
483
+ def resolve_documents(statement)
484
+ statement.document_keys.filter_map do |document_key|
485
+ version = current_document_version(document_key)
486
+
487
+ unless version
488
+ raise DocumentNotPublishedError,
489
+ "Policy #{policy.key} presents #{document_key} but no published version of it " \
490
+ "is effective for locale #{locale}. Declare it with `Clickwrap.document " \
491
+ "#{document_key.to_sym.inspect}, version: \"...\", from: ...` and run " \
492
+ "`bin/rails clickwrap:publish`."
493
+ end
494
+
495
+ label, = statement.link_labels[document_key]&.resolve(locale: locale)
496
+
497
+ Document.new(
498
+ key: document_key,
499
+ label: label || document_key.humanize,
500
+ version_label: version.version_label,
501
+ locale: version.locale,
502
+ source_media_type: version.media_type,
503
+ source_content_digest: version.content_digest,
504
+ rendered_media_type: version.rendered_media_type.presence || version.media_type,
505
+ rendered_content_digest: version.rendered_content_digest.presence || version.content_digest,
506
+ renderer_name: version.renderer_name,
507
+ renderer_version: version.renderer_version,
508
+ sanitizer_name: version.sanitizer_name,
509
+ sanitizer_version: version.sanitizer_version,
510
+ version_id: version.id,
511
+ path: document_path(version, declared_link_for(document_key, version))
512
+ )
513
+ end
514
+ end
515
+
516
+ # The `link:` the application declared for exactly these bytes: same tenant,
517
+ # same key, same version label, same locale. Read from the registry rather
518
+ # than the database because it is a presentation decision, not evidence —
519
+ # the evidence is the digest of the bytes, which the row already holds.
520
+ def declared_link_for(document_key, version)
521
+ document = documents_for_this_policy[document_key.to_s]
522
+ return nil unless document
523
+
524
+ Clickwrap.documents[[document.tenant_key, document.document_key,
525
+ version.version_label, version.locale]]&.link
526
+ end
527
+
528
+ # Documents are immutable and published: within one presentation build, the
529
+ # answer for a key cannot change, and two statements naming the same
530
+ # document must get the same answer anyway. So every key this policy
531
+ # references is resolved once, in two queries, rather than one lookup (or
532
+ # two) plus a version query per statement-document pair.
533
+ def current_document_version(document_key)
534
+ current_document_versions[document_key.to_s]
535
+ end
536
+
537
+ def current_document_versions
538
+ @current_document_versions ||= begin
539
+ documents = documents_for_this_policy
540
+ versions = newest_effective_versions_by_document_id(documents.values.map(&:id))
541
+
542
+ documents.transform_values { |document| versions[document.id] }
543
+ end
544
+ end
545
+
546
+ # The order here is the whole contract of a document link.
547
+ #
548
+ # 1. A resolver the CALLER passed wins outright: it is the host saying, at
549
+ # this call site, exactly where this document lives.
550
+ # 2. The document's declared `link:` — the host's own reader-facing page.
551
+ # 3. The render context's default resolver: the mounted engine route, with
552
+ # whatever Hotwire Native treatment this request calls for. It is given
553
+ # the declared link too, so a native render absolutizes a host page the
554
+ # same way it absolutizes an engine path.
555
+ # 4. Nothing but the engine's own routes, which refuse rather than sign a
556
+ # path that resolves to nothing.
557
+ def document_path(version, link)
558
+ path =
559
+ if @document_version_path_with
560
+ @document_version_path_with.call(version)
561
+ elsif @default_document_version_path_with
562
+ @default_document_version_path_with.call(version, link)
563
+ elsif link.present?
564
+ link
565
+ elsif defined?(Clickwrap::Engine)
566
+ engine_document_version_path(version)
567
+ end
568
+
569
+ return path.to_s if path.present?
570
+
571
+ raise ConfigurationError,
572
+ "Clickwrap could not build the immutable URL for document version #{version.id}. " \
573
+ "Present through form.clickwrap or present_clickwrap so the mounted route is known, " \
574
+ "or pass document_version_path_with: ->(version) { ... }."
575
+ rescue Clickwrap::Error
576
+ raise
577
+ rescue StandardError => error
578
+ raise ConfigurationError,
579
+ "Clickwrap could not build the immutable URL for document version #{version.id}: " \
580
+ "#{error.class}: #{error.message}"
581
+ end
582
+
583
+ # The tenant's own document wins over the shared one, exactly as the
584
+ # per-key lookup did. When there is no tenant the two scopes are the same
585
+ # query, so only one is issued.
586
+ def documents_for_this_policy
587
+ @documents_for_this_policy ||= begin
588
+ keys = policy.statements.flat_map(&:document_keys).uniq
589
+
590
+ if keys.empty?
591
+ {}
592
+ else
593
+ shared = ::Clickwrap::Document.where(document_key: keys, tenant_key: nil).index_by(&:document_key)
594
+
595
+ if tenant_key.blank?
596
+ shared
597
+ else
598
+ shared.merge(::Clickwrap::Document.for_tenant(tenant_key)
599
+ .where(document_key: keys).index_by(&:document_key))
600
+ end
601
+ end
602
+ end
603
+ end
604
+
605
+ # One query for every version this presentation could offer, ordered the
606
+ # same way Document#current_version orders one document's versions, so the
607
+ # first row per document is the one that method would have returned.
608
+ def newest_effective_versions_by_document_id(document_ids)
609
+ return {} if document_ids.empty?
610
+
611
+ at = Clickwrap.now
612
+
613
+ ::Clickwrap::DocumentVersion
614
+ .published
615
+ .effective_at_or_before(at)
616
+ .not_retired_at(at)
617
+ .for_locale(locale)
618
+ .where(document_id: document_ids)
619
+ .order(effective_at: :desc, published_at: :desc, created_at: :desc)
620
+ .group_by(&:document_id)
621
+ .transform_values(&:first)
622
+ end
623
+
624
+ # The engine's own URL helpers carry no mount prefix, so on an application
625
+ # that never mounted the engine they answer with a path that resolves to
626
+ # nothing. That answer would be *signed*: it goes into the manifest, into
627
+ # the digest, and into the evidence as the exact document the person was
628
+ # offered. A signed dead link is the worst failure this gem has — it looks
629
+ # like evidence and cites a 404 — so it is refused at build time instead.
630
+ def engine_document_version_path(version)
631
+ ControllerHelpers.assert_engine_can_resolve_document_links!(version)
632
+
633
+ Clickwrap::Engine.routes.url_helpers.document_version_path(version.id)
634
+ end
635
+
636
+ def manifest_fragment(statement)
637
+ declared = policy.statement!(statement.key)
638
+
639
+ {
640
+ "key" => statement.key,
641
+ "kind" => statement.kind,
642
+ "assertion" => statement.assertion,
643
+ "label" => statement.label,
644
+ "required" => statement.required?,
645
+ "optional" => statement.optional?,
646
+ "choices" => statement.choices,
647
+ "requires_an_explicit_choice" => statement.requires_an_explicit_choice?,
648
+ "purpose" => statement.purpose_key,
649
+ "withdrawal_path" => statement.withdrawal_path,
650
+ "valid_for_seconds" => declared.valid_for&.to_i,
651
+ "one_time" => declared.one_time?,
652
+ "requires" => declared.requires,
653
+ "requires_current_version" => declared.requires_current_version?,
654
+ "subject_fingerprint" => subject_fingerprint_for(declared),
655
+ "subject_fingerprint_version" => declared.subject_fingerprint_version,
656
+ "protected_outcome_version" => declared.protected_outcome_version,
657
+ "control_name" => statement.control_name,
658
+ "documents" => statement.documents.map do |document|
659
+ {
660
+ "key" => document.key,
661
+ "label" => document.label,
662
+ "version" => document.version_label,
663
+ "locale" => document.locale,
664
+ "source_media_type" => document.source_media_type,
665
+ "source_digest" => document.source_content_digest,
666
+ "rendered_media_type" => document.rendered_media_type,
667
+ "rendered_digest" => document.rendered_content_digest,
668
+ "renderer" => {
669
+ "name" => document.renderer_name,
670
+ "version" => document.renderer_version,
671
+ "sanitizer_name" => document.sanitizer_name,
672
+ "sanitizer_version" => document.sanitizer_version
673
+ }.compact.presence,
674
+ "version_id" => document.version_id.to_s,
675
+ "path" => document.path
676
+ }
677
+ end
678
+ }.compact
679
+ end
680
+
681
+ def subject_fingerprint_for(statement)
682
+ return nil unless statement.subject_bound?
683
+
684
+ SubjectFingerprint.for_statement(statement, subject)
685
+ end
686
+
687
+ # Only written when a policy asked for it. The state is `presented_by_server`
688
+ # and nothing stronger: the server generated and offered this. Whether a
689
+ # human ever saw it is not something a web server can know.
690
+ def persist_presentation(manifest, revision)
691
+ Presentation.create!(
692
+ policy_key: policy.key,
693
+ policy_revision: revision,
694
+ nonce: manifest.nonce,
695
+ manifest: manifest.to_h,
696
+ manifest_digest: manifest.digest,
697
+ actor: actor.is_a?(::ActiveRecord::Base) ? actor : nil,
698
+ actor_reference: actor_reference,
699
+ registration_flow_id: @registration_flow_id,
700
+ represented_party_type: @acting_for.is_a?(::ActiveRecord::Base) ? @acting_for.class.name : nil,
701
+ represented_party_id: @acting_for.is_a?(::ActiveRecord::Base) ? @acting_for.id : nil,
702
+ represented_party_reference: represented_party_reference.presence,
703
+ tenant_key: tenant_key,
704
+ subject: subject.is_a?(::ActiveRecord::Base) ? subject : nil,
705
+ subject_fingerprint: subject_fingerprint,
706
+ locale: locale,
707
+ capture_channel: capture_channel,
708
+ state: "presented_by_server",
709
+ issued_at: manifest.issued_at,
710
+ expires_at: manifest.expires_at,
711
+ retain_until: Clickwrap.now + policy.persist_presentations_for,
712
+ created_at: Clickwrap.now
713
+ )
714
+ end
715
+ end
716
+ end