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,1129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Clickwrap
6
+ # The single configuration object the host populates in
7
+ # `config/initializers/clickwrap.rb` via `Clickwrap.configure do |config| ... end`.
8
+ #
9
+ # Design rules:
10
+ # - Runtime adapters and class names are read at the point of use. Policy
11
+ # semantics — including merged request-evidence defaults — are copied into
12
+ # the immutable compiled policy revision at boot, so changing this object
13
+ # cannot mutate a form that was already rendered.
14
+ # - Class names are stored as strings and constantized lazily, so the
15
+ # initializer works no matter when the app loads (the User model may not
16
+ # exist yet at boot).
17
+ # - Validating setters normalize their input and raise a plain-English
18
+ # ConfigurationError on a bad value — failing at the assignment line
19
+ # rather than at 3am with a NoMethodError. `validate!` runs once more at
20
+ # the end of `configure` for the cross-field checks.
21
+ # - Every hook defaults to a no-op, so the gem works untouched and a host
22
+ # wires hooks only as needed.
23
+ # - Nothing here collects personal data by default. Every `record_*` flag
24
+ # starts false, and turning one on without a purpose and a retention
25
+ # decision is a configuration error, not a warning.
26
+ #
27
+ # One setting deserves its own note: there is deliberately no
28
+ # `gdpr_compliant_mode`, `maximum_evidence`, `full_evidence`, or
29
+ # `legal_proof`. An option that silently enables a category of personal data
30
+ # is exactly the thing this gem exists not to do, and no runtime flag can
31
+ # make a legal determination on your behalf.
32
+ class Configuration
33
+ DOCUMENT_STORES = %i[database active_storage resolver].freeze
34
+ DIGEST_ALGORITHMS = %i[sha256 sha384 sha512].freeze
35
+
36
+ # The readers are grouped by section on purpose, and kept that way even
37
+ # though rubocop would happily collapse them into one line. The initializer
38
+ # this class backs is the main thing a host reads about Clickwrap, and the
39
+ # shape of these groups is the shape of that file.
40
+ #
41
+ # --- Identity -------------------------------------------------------------
42
+ attr_reader :actor_class_name, :current_actor_method_name, :parent_controller_class_name
43
+ attr_reader :find_current_tenant_with, :identify_actor_with, :snapshot_actor_with
44
+ attr_reader :describe_authentication_with
45
+
46
+ # --- Documents and policies -----------------------------------------------
47
+ attr_reader :store_document_contents_in, :document_renderer, :document_resolver
48
+ attr_reader :document_link_html_options_with
49
+ attr_reader :hotwire_native_document_links
50
+ attr_reader :publish_documents_after_database_preparation
51
+ attr_accessor :policy_paths
52
+
53
+ attr_reader :raise_on_missing_translation
54
+
55
+ # --- Development aids -----------------------------------------------------
56
+ attr_accessor :lint_presentations
57
+
58
+ # --- Presentation ---------------------------------------------------------
59
+ attr_reader :presentation_valid_for
60
+ attr_reader :remediation_token_valid_for
61
+
62
+ # --- Integrity ------------------------------------------------------------
63
+ attr_reader :digest_canonical_receipts_with, :chain_event_history_with
64
+ attr_reader :anchor_event_history_with, :timestamp_receipts_with
65
+ attr_reader :application_version, :template_version
66
+
67
+ # --- Authorization --------------------------------------------------------
68
+ attr_reader :authorize_receipt_access_with, :authorize_unredacted_request_evidence_access_with
69
+ attr_reader :verify_actor_can_act_for_represented_party_with
70
+ attr_reader :authorize_clickwrap_remediation_subject_with
71
+ attr_reader :authorize_clickwrap_remediation_represented_party_with
72
+
73
+ # --- Request evidence: what is recorded by default ------------------------
74
+ #
75
+ # One reader per IP-geolocation field, spelled out rather than generated,
76
+ # because each is a separate decision about what to keep about someone's
77
+ # network context and each should be greppable by its own name.
78
+ attr_reader :record_ip_address_by_default, :record_browser_user_agent_by_default
79
+ attr_reader :record_ip_geolocation_country_by_default,
80
+ :record_ip_geolocation_region_by_default,
81
+ :record_ip_geolocation_city_by_default,
82
+ :record_ip_geolocation_postal_code_by_default,
83
+ :record_ip_geolocation_latitude_and_longitude_by_default,
84
+ :record_ip_geolocation_timezone_by_default,
85
+ :record_ip_geolocation_continent_by_default,
86
+ :record_ip_geolocation_metro_code_by_default,
87
+ :record_ip_geolocation_accuracy_radius_in_kilometers_by_default
88
+
89
+ # --- Request evidence: why, how long, and how it is protected -------------
90
+ attr_accessor :reason_for_recording_ip_addresses_by_default,
91
+ :reason_for_recording_browser_user_agents_by_default,
92
+ :reason_for_recording_ip_geolocation_by_default,
93
+ :legal_basis_reference_for_recording_ip_addresses_by_default,
94
+ :legal_basis_reference_for_recording_browser_user_agents_by_default,
95
+ :legal_basis_reference_for_recording_ip_geolocation_by_default,
96
+ :review_default_request_evidence_configuration_on
97
+
98
+ attr_reader :encrypt_recorded_ip_addresses, :encrypt_recorded_browser_user_agents,
99
+ :encrypt_recorded_ip_geolocation
100
+ attr_reader :delete_recorded_ip_addresses_after, :delete_recorded_browser_user_agents_after,
101
+ :delete_recorded_ip_geolocation_after
102
+ attr_reader :read_ip_address_from_http_request_with, :read_browser_user_agent_from_http_request_with
103
+ attr_reader :ip_geolocation_resolver, :fail_capture_when_ip_geolocation_is_unavailable
104
+ attr_reader :trusted_proxy_configuration_digest, :reason_for_storing_request_evidence_unencrypted
105
+ attr_reader :find_request_evidence_binding_key_with
106
+
107
+ # --- Hooks ----------------------------------------------------------------
108
+ attr_reader :after_event_is_committed, :report_after_commit_failure_with
109
+
110
+ def initialize
111
+ # Identity. "User" is the overwhelmingly common case; the host overrides
112
+ # it if their actor model is "Account", "Member", or something else.
113
+ @actor_class_name = "User"
114
+ @current_actor_method_name = :current_user
115
+ @parent_controller_class_name = "ApplicationController"
116
+ @find_current_tenant_with = ->(_controller) {}
117
+
118
+ # How an actor is referenced in evidence. Prefer a host override, then a
119
+ # GlobalID when available, then a stable class/id string in minimal Rails.
120
+ # The resulting string survives row deletion instead of becoming a
121
+ # cascading foreign-key loss.
122
+ @identify_actor_with = ->(actor) { default_actor_reference(actor) }
123
+
124
+ # Actor snapshots contain only what the host names. Clickwrap never
125
+ # serializes a whole user into evidence: a receipt should carry the
126
+ # fields someone reviewed and chose, not every column that happened to
127
+ # exist on the day it was written.
128
+ @snapshot_actor_with = ->(_actor) { {} }
129
+
130
+ # An honest default: when the controller can name a current actor, the
131
+ # request ran under an application-authenticated session; when it cannot
132
+ # — a signup form, a public capture screen, a controller with no
133
+ # authentication at all — nothing is claimed. The temptation this guards
134
+ # against is describing every request as authenticated merely because it
135
+ # passed through ApplicationController. Deliberately gentler than the
136
+ # capture path's actor resolution: describing authentication is context,
137
+ # not identity, so a missing actor method here means "nothing to
138
+ # describe", never an error.
139
+ configuration = self
140
+ @describe_authentication_with = lambda do |controller|
141
+ method_name = configuration.current_actor_method_name
142
+ actor = controller.respond_to?(method_name, true) ? controller.send(method_name) : nil
143
+ actor ? { method: :authenticated_session } : {}
144
+ end
145
+
146
+ # Documents and policies.
147
+ @store_document_contents_in = :database
148
+ @document_renderer = DocumentRenderer.new
149
+ @document_resolver = nil
150
+ @document_link_html_options_with = ->(_document) { { target: "_blank", rel: "noopener" } }
151
+ @hotwire_native_document_links = nil
152
+ @policy_paths = ["config/clickwrap.rb", "config/clickwrap/*.rb"]
153
+
154
+ # Publishing rides `db:prepare`, so the deploy step everyone forgets
155
+ # does not exist: by the time the server takes traffic, every declared
156
+ # document version has an immutable snapshot. Idempotent — an
157
+ # already-published version is left untouched — and a publish refusal
158
+ # (a reused label over changed bytes) fails the deploy loudly, which is
159
+ # strictly better than signups failing quietly later.
160
+ @publish_documents_after_database_preparation = true
161
+
162
+ # A required legal statement with no translation is not presentable. Fail
163
+ # rather than show a raw I18n key, a blank, or an unexpected language.
164
+ @raise_on_missing_translation = true
165
+
166
+ # nil means "decide from the environment": on in development and test,
167
+ # off everywhere else. `true` and `false` answer for a host that
168
+ # disagrees with either half — a linter nobody can turn off is a warning
169
+ # people learn to scroll past.
170
+ @lint_presentations = nil
171
+
172
+ # Presentation manifests are short-lived by design: they bind a render to
173
+ # a submit, and a token that stayed valid for days would weaken exactly
174
+ # the substitution check it exists to make.
175
+ @presentation_valid_for = 2.hours
176
+ @remediation_token_valid_for = 2.hours
177
+
178
+ # Integrity. The baseline detects accidental or ordinary mutation of the
179
+ # verified bytes. Chains, anchors, and third-party timestamps are separate,
180
+ # explicitly enabled tiers, and each one claims only what it supplies.
181
+ @digest_canonical_receipts_with = :sha256
182
+ @chain_event_history_with = nil
183
+ @anchor_event_history_with = nil
184
+ @timestamp_receipts_with = nil
185
+ @application_version = -> {}
186
+ @template_version = -> {}
187
+
188
+ # Authorization. Actors can read their own receipts; anything wider is
189
+ # the host's decision, and unredacted request evidence needs a reason.
190
+ @authorize_receipt_access_with = ->(_controller, _receipt) { false }
191
+ @authorize_unredacted_request_evidence_access_with = ->(_controller, _receipt, _because) { false }
192
+ @verify_actor_can_act_for_represented_party_with = lambda do |actor:, represented_party:, policy:,
193
+ authentication_context:, tenant:|
194
+ AuthorityDecision.new(authorized: false)
195
+ end
196
+ @authorize_clickwrap_remediation_subject_with = lambda do |actor:, subject:, policy:, controller:|
197
+ subject.nil?
198
+ end
199
+ @authorize_clickwrap_remediation_represented_party_with =
200
+ lambda do |actor:, represented_party:, policy:, controller:|
201
+ represented_party.nil?
202
+ end
203
+ @remediation_subject_authorization_configured = false
204
+ @remediation_represented_party_authorization_configured = false
205
+ @represented_party_authority_adapters = {
206
+ "organizations_membership" => Integrations::OrganizationsAuthority.new
207
+ }
208
+
209
+ # Request evidence. Every one of these is false, and that is the whole
210
+ # point. Recording an IP address is a decision with consequences; the
211
+ # library will not make it silently on a host's behalf.
212
+ @record_ip_address_by_default = false
213
+ @record_browser_user_agent_by_default = false
214
+ Vocabulary::IP_GEOLOCATION_DATA_FIELDS.each do |field|
215
+ instance_variable_set(:"@record_ip_geolocation_#{field}_by_default", false)
216
+ end
217
+
218
+ @reason_for_recording_ip_addresses_by_default = nil
219
+ @reason_for_recording_browser_user_agents_by_default = nil
220
+ @reason_for_recording_ip_geolocation_by_default = nil
221
+ @legal_basis_reference_for_recording_ip_addresses_by_default = nil
222
+ @legal_basis_reference_for_recording_browser_user_agents_by_default = nil
223
+ @legal_basis_reference_for_recording_ip_geolocation_by_default = nil
224
+ @review_default_request_evidence_configuration_on = nil
225
+
226
+ @encrypt_recorded_ip_addresses = true
227
+ @encrypt_recorded_browser_user_agents = true
228
+ @encrypt_recorded_ip_geolocation = true
229
+
230
+ # nil means "every policy that enables the field must supply its own
231
+ # rule". There is no keep-forever default anywhere in this gem.
232
+ @delete_recorded_ip_addresses_after = nil
233
+ @delete_recorded_browser_user_agents_after = nil
234
+ @delete_recorded_ip_geolocation_after = nil
235
+
236
+ # Rails' request.remote_ip is the conventional reader. The host remains
237
+ # responsible for configuring and testing trusted proxies correctly:
238
+ # https://api.rubyonrails.org/classes/ActionDispatch/RemoteIp.html
239
+ @read_ip_address_from_http_request_with = ->(http_request) { http_request.remote_ip }
240
+ @read_browser_user_agent_from_http_request_with = ->(http_request) { http_request.user_agent }
241
+ @trusted_proxy_configuration_digest = nil
242
+
243
+ @ip_geolocation_resolver = nil
244
+ @ip_geolocation_resolvers = {}
245
+ @fail_capture_when_ip_geolocation_is_unavailable = false
246
+
247
+ # The keyed annex digest carries a key ID so a host can rotate keys
248
+ # without making old annexes unverifiable. The default derives one key
249
+ # from Rails' key generator and names it by a non-secret fingerprint.
250
+ # Production hosts with explicit key rotation can replace both settings
251
+ # with a credentials-backed keyring.
252
+ @current_request_evidence_binding_key_id = nil
253
+ @find_request_evidence_binding_key_with = lambda do |requested_key_id|
254
+ key = default_request_evidence_binding_key
255
+ expected_id = default_request_evidence_binding_key_id(key)
256
+ key if key && Digest.secure_compare?(requested_key_id.to_s, expected_id.to_s)
257
+ end
258
+
259
+ # Hooks. These run only after required evidence and domain state have
260
+ # committed, and a failure here is reported but can never undo the
261
+ # committed action.
262
+ @after_event_is_committed = ->(_event) {}
263
+ @report_after_commit_failure_with = ->(_error, _event) {}
264
+
265
+ # Host-registered retention calculations, keyed by the name a retention
266
+ # class refers to.
267
+ @retention_time_calculators = {}
268
+ end
269
+
270
+ # --- Identity setters -----------------------------------------------------
271
+
272
+ def actor_class_name=(value)
273
+ @actor_class_name = ensure_class_name(value, "actor_class_name")
274
+ end
275
+
276
+ def current_actor_method_name=(value)
277
+ @current_actor_method_name = ensure_present_symbol(value, "current_actor_method_name")
278
+ end
279
+
280
+ def parent_controller_class_name=(value)
281
+ @parent_controller_class_name = ensure_class_name(value, "parent_controller_class_name")
282
+ end
283
+
284
+ def find_current_tenant_with=(value)
285
+ @find_current_tenant_with = ensure_callable(value, "find_current_tenant_with")
286
+ end
287
+
288
+ def identify_actor_with=(value)
289
+ @identify_actor_with = ensure_callable(value, "identify_actor_with")
290
+ end
291
+
292
+ def snapshot_actor_with=(value)
293
+ @snapshot_actor_with = ensure_callable(value, "snapshot_actor_with")
294
+ end
295
+
296
+ def describe_authentication_with=(value)
297
+ @describe_authentication_with = ensure_callable(value, "describe_authentication_with")
298
+ end
299
+
300
+ # The constantized actor class, resolved lazily on first use. Lazy on
301
+ # purpose: the initializer that sets `config.actor_class_name = "User"` runs
302
+ # before the User model is necessarily loaded.
303
+ def actor_class
304
+ name = actor_class_name
305
+ if @actor_class.nil? || @actor_class_name_at_resolution != name
306
+ @actor_class = name.constantize
307
+ @actor_class_name_at_resolution = name
308
+ end
309
+ @actor_class
310
+ end
311
+
312
+ def parent_controller_class = parent_controller_class_name.constantize
313
+
314
+ # --- Document and policy setters -----------------------------------------
315
+
316
+ def store_document_contents_in=(value)
317
+ normalized = value.to_s.to_sym
318
+ unless DOCUMENT_STORES.include?(normalized)
319
+ raise ConfigurationError,
320
+ "store_document_contents_in must be one of #{DOCUMENT_STORES.inspect}, " \
321
+ "got #{value.inspect}. Whichever you choose, the adapter has to return immutable " \
322
+ "bytes plus a verifiable digest — a URL alone is never a document version."
323
+ end
324
+
325
+ @store_document_contents_in = normalized
326
+ end
327
+
328
+ # Chooses how document bytes become the representation people are offered.
329
+ # `:safe_text` (the default) escapes everything into a faithful <pre>
330
+ # block; `:markdown` renders real HTML through whichever Markdown library
331
+ # the application already bundles (commonmarker, redcarpet, or kramdown —
332
+ # no new dependency); `:markdown_rails` renders through the application's
333
+ # OWN registered markdown-rails renderer — the exact pipeline its public
334
+ # `.md` pages already go through — so the stored snapshot is byte-identical
335
+ # to the page readers see, by construction. A custom renderer object must
336
+ # return the exact rendered bytes it offered, because Clickwrap stores
337
+ # their digest alongside the original source digest. That is what preserves
338
+ # the difference between "this Markdown file existed" and "this rendered
339
+ # representation was offered".
340
+ def document_renderer=(value)
341
+ @document_renderer =
342
+ case value
343
+ when nil then nil
344
+ when :markdown then DocumentRenderers::Markdown.new
345
+ when :markdown_rails then DocumentRenderers::MarkdownRails.new
346
+ when :safe_text then DocumentRenderer.new
347
+ when Symbol
348
+ raise ConfigurationError,
349
+ "document_renderer accepts :safe_text, :markdown, :markdown_rails, or an " \
350
+ "object responding to call(bytes, definition) — not #{value.inspect}."
351
+ else ensure_callable(value, "document_renderer")
352
+ end
353
+ end
354
+
355
+ def document_resolver=(value)
356
+ @document_resolver = value.nil? ? nil : ensure_callable(value, "document_resolver")
357
+ end
358
+
359
+ # Navigation attributes only; the immutable href is signed into the
360
+ # presentation and cannot be replaced through this styling/client hook.
361
+ def document_link_html_options_with=(value)
362
+ @document_link_html_options_with = ensure_callable(value, "document_link_html_options_with")
363
+ end
364
+
365
+ HOTWIRE_NATIVE_DOCUMENT_LINK_MODES = %i[external_browser same_screen].freeze
366
+
367
+ # One declarative answer for how document links behave inside a Hotwire
368
+ # Native app, doing both halves coherently — the signed href AND the
369
+ # navigation attributes:
370
+ #
371
+ # config.hotwire_native_document_links = {
372
+ # open_in: :external_browser,
373
+ # canonical_host: "https://www.example.com"
374
+ # }
375
+ #
376
+ # `:external_browser` absolutizes every document link against your
377
+ # canonical host and opens it outside the WebView — the right answer when
378
+ # clickwraps render on native AUTH SHEETS, where a same-host navigation
379
+ # pops the sheet and loses the half-filled form behind it. `:same_screen`
380
+ # leaves plain same-host links for your native path configuration to route
381
+ # (a modal document sheet inside a signed-in funnel, for example).
382
+ #
383
+ # When set, this answers native renders entirely; your
384
+ # `document_link_html_options_with` hook continues to answer everything
385
+ # else.
386
+ #
387
+ # `open_in:` also takes a callable, for an app whose native screens need
388
+ # different answers in different places — the auth sheet that must escape
389
+ # the WebView, the signed-in funnel that routes a document sheet itself:
390
+ #
391
+ # config.hotwire_native_document_links = {
392
+ # open_in: ->(controller) { controller.signing_up? ? :external_browser : :same_screen },
393
+ # canonical_host: "https://www.example.com"
394
+ # }
395
+ #
396
+ # It is asked twice per document link — once when the href is signed into
397
+ # the presentation, once when the link's attributes are rendered — and is
398
+ # handed the same controller both times, so it must answer from the
399
+ # request rather than from anything that changes between those moments.
400
+ # A callable `open_in:` needs `canonical_host:`, because nothing at boot
401
+ # can rule out its answering `:external_browser`.
402
+ def hotwire_native_document_links=(value)
403
+ if value.nil?
404
+ @hotwire_native_document_links = nil
405
+ return
406
+ end
407
+
408
+ unless value.is_a?(Hash)
409
+ raise ConfigurationError,
410
+ "hotwire_native_document_links takes nil or a Hash like " \
411
+ "{ open_in: :external_browser, canonical_host: \"https://www.example.com\" }."
412
+ end
413
+
414
+ options = value.symbolize_keys
415
+ open_in = options[:open_in]
416
+ unless HOTWIRE_NATIVE_DOCUMENT_LINK_MODES.include?(open_in) || open_in.respond_to?(:call)
417
+ raise ConfigurationError,
418
+ "hotwire_native_document_links needs `open_in:` as one of " \
419
+ "#{HOTWIRE_NATIVE_DOCUMENT_LINK_MODES.map(&:inspect).join(" or ")}, or a callable " \
420
+ "answering one of them per request — got #{open_in.inspect}."
421
+ end
422
+
423
+ unknown = options.keys - %i[open_in canonical_host]
424
+ unless unknown.empty?
425
+ raise ConfigurationError,
426
+ "hotwire_native_document_links has unknown option#{"s" if unknown.many?} " \
427
+ "#{unknown.map(&:inspect).join(", ")}. Supported options are :open_in and " \
428
+ ":canonical_host."
429
+ end
430
+
431
+ canonical_host = options[:canonical_host]
432
+ if open_in == :external_browser || open_in.respond_to?(:call)
433
+ if canonical_host.nil?
434
+ raise ConfigurationError,
435
+ "hotwire_native_document_links with open_in: " \
436
+ "#{open_in.respond_to?(:call) ? "a callable" : ":external_browser"} needs a " \
437
+ "`canonical_host:` — the absolute https host the external browser opens, for " \
438
+ "example \"https://www.example.com\". A relative link would land back inside " \
439
+ "the WebView this setting exists to escape#{if open_in.respond_to?(:call)
440
+ ", and nothing at boot can rule " \
441
+ "out a callable answering :external_browser"
442
+ end}."
443
+ end
444
+ validate_hotwire_native_canonical_host!(canonical_host) unless canonical_host.respond_to?(:call)
445
+ elsif canonical_host
446
+ raise ConfigurationError,
447
+ "hotwire_native_document_links with open_in: :same_screen keeps ordinary " \
448
+ "same-host links, so `canonical_host:` has no meaning there. Remove it, or use " \
449
+ "open_in: :external_browser."
450
+ end
451
+
452
+ @hotwire_native_document_links = { open_in: open_in, canonical_host: canonical_host }.freeze
453
+ end
454
+
455
+ # The mode this render should use, resolved at use time so a callable can
456
+ # answer per request or per screen. `context` is the controller handling
457
+ # the request; both the href and the link attributes are resolved from the
458
+ # same one, so the two halves of a document link cannot disagree.
459
+ def hotwire_native_document_link_mode(context = nil)
460
+ configured = hotwire_native_document_links&.fetch(:open_in, nil)
461
+ return configured unless configured.respond_to?(:call)
462
+
463
+ resolved = (configured.arity.zero? ? configured.call : configured.call(context))&.to_sym
464
+
465
+ unless HOTWIRE_NATIVE_DOCUMENT_LINK_MODES.include?(resolved)
466
+ raise ConfigurationError,
467
+ "hotwire_native_document_links `open_in:` answered #{resolved.inspect}. A callable " \
468
+ "there must answer #{HOTWIRE_NATIVE_DOCUMENT_LINK_MODES.map(&:inspect).join(" or ")} " \
469
+ "on every request — there is no third way for a document link to open."
470
+ end
471
+
472
+ resolved
473
+ end
474
+
475
+ # The canonical host, resolved and validated at use time so a host that is
476
+ # only knowable after boot (application config, credentials) can be a
477
+ # callable. Trailing slashes are trimmed because the engine path this
478
+ # prefixes always begins with one.
479
+ def hotwire_native_canonical_host
480
+ configured = hotwire_native_document_links&.fetch(:canonical_host, nil)
481
+ return nil if configured.nil?
482
+
483
+ resolved = configured.respond_to?(:call) ? configured.call.to_s : configured.to_s
484
+ validate_hotwire_native_canonical_host!(resolved)
485
+ resolved.chomp("/")
486
+ end
487
+
488
+ def validate_hotwire_native_canonical_host!(value)
489
+ return if value.to_s.start_with?("https://")
490
+
491
+ raise ConfigurationError,
492
+ "hotwire_native_document_links canonical_host must be an absolute https:// URL " \
493
+ "(got #{value.inspect}). It is the address an external browser opens on a user's " \
494
+ "phone; anything else either stays inside the WebView or downgrades the transport."
495
+ end
496
+ private :validate_hotwire_native_canonical_host!
497
+
498
+ def raise_on_missing_translation=(value)
499
+ @raise_on_missing_translation = ensure_boolean(value, "raise_on_missing_translation")
500
+ end
501
+
502
+ def publish_documents_after_database_preparation=(value)
503
+ @publish_documents_after_database_preparation =
504
+ ensure_boolean(value, "publish_documents_after_database_preparation")
505
+ end
506
+
507
+ def presentation_valid_for=(value)
508
+ @presentation_valid_for = ensure_duration(value, "presentation_valid_for")
509
+ end
510
+
511
+ def remediation_token_valid_for=(value)
512
+ @remediation_token_valid_for = ensure_duration(value, "remediation_token_valid_for")
513
+ end
514
+
515
+ # --- Integrity setters ----------------------------------------------------
516
+
517
+ def digest_canonical_receipts_with=(value)
518
+ @digest_canonical_receipts_with = ensure_digest_algorithm(value, "digest_canonical_receipts_with")
519
+ end
520
+
521
+ def chain_event_history_with=(value)
522
+ @chain_event_history_with =
523
+ value.nil? ? nil : ensure_digest_algorithm(value, "chain_event_history_with")
524
+ end
525
+
526
+ def anchor_event_history_with=(value)
527
+ @anchor_event_history_with =
528
+ ensure_adapter(value, "anchor_event_history_with", :anchor, :verify, :capabilities)
529
+ end
530
+
531
+ def timestamp_receipts_with=(value)
532
+ @timestamp_receipts_with =
533
+ ensure_adapter(value, "timestamp_receipts_with", :timestamp, :verify, :capabilities)
534
+ end
535
+
536
+ def application_version=(value)
537
+ @application_version = value.respond_to?(:call) ? value : -> { value }
538
+ end
539
+
540
+ def template_version=(value)
541
+ @template_version = value.respond_to?(:call) ? value : -> { value }
542
+ end
543
+
544
+ def resolved_application_version = application_version.call
545
+ def resolved_template_version = template_version.call
546
+
547
+ # --- Authorization setters ------------------------------------------------
548
+
549
+ def authorize_receipt_access_with=(value)
550
+ @authorize_receipt_access_with = ensure_callable(value, "authorize_receipt_access_with")
551
+ end
552
+
553
+ def authorize_unredacted_request_evidence_access_with=(value)
554
+ @authorize_unredacted_request_evidence_access_with =
555
+ ensure_callable(value, "authorize_unredacted_request_evidence_access_with")
556
+ end
557
+
558
+ def verify_actor_can_act_for_represented_party_with=(value)
559
+ @verify_actor_can_act_for_represented_party_with =
560
+ ensure_callable(value, "verify_actor_can_act_for_represented_party_with")
561
+ end
562
+
563
+ def authorize_clickwrap_remediation_subject_with=(value)
564
+ @authorize_clickwrap_remediation_subject_with =
565
+ ensure_callable(value, "authorize_clickwrap_remediation_subject_with")
566
+ @remediation_subject_authorization_configured = true
567
+ end
568
+
569
+ def authorize_clickwrap_remediation_represented_party_with=(value)
570
+ @authorize_clickwrap_remediation_represented_party_with =
571
+ ensure_callable(value, "authorize_clickwrap_remediation_represented_party_with")
572
+ @remediation_represented_party_authorization_configured = true
573
+ end
574
+
575
+ def remediation_subject_authorization_configured? = @remediation_subject_authorization_configured
576
+
577
+ def remediation_represented_party_authorization_configured?
578
+ @remediation_represented_party_authorization_configured
579
+ end
580
+
581
+ # Register a named, server-side authority adapter. Policies refer to the
582
+ # name from their compiled revision; the browser never submits it.
583
+ #
584
+ # config.register_represented_party_authority :company_directory, MyAdapter.new
585
+ #
586
+ # The adapter receives actor:, represented_party:, authority_rule:, tenant:,
587
+ # and authentication_context:, and returns Clickwrap::AuthorityDecision.
588
+ def register_represented_party_authority(name, adapter)
589
+ key = ensure_present_symbol(name, "represented-party authority name").to_s
590
+ if @represented_party_authority_adapters.key?(key)
591
+ raise ConfigurationError,
592
+ "A represented-party authority adapter named #{key.inspect} is already registered. " \
593
+ "Use one stable name per adapter; Clickwrap will not silently replace an " \
594
+ "authorization decision because initializer order changed."
595
+ end
596
+
597
+ @represented_party_authority_adapters[key] =
598
+ ensure_adapter(adapter, "represented-party authority #{key}", :verify)
599
+ end
600
+
601
+ def represented_party_authority_adapter(name)
602
+ @represented_party_authority_adapters[name.to_s]
603
+ end
604
+
605
+ def represented_party_authority_adapter_names
606
+ @represented_party_authority_adapters.keys.sort.freeze
607
+ end
608
+
609
+ # --- Request-evidence setters --------------------------------------------
610
+
611
+ def record_ip_address_by_default=(value)
612
+ @record_ip_address_by_default = ensure_boolean(value, "record_ip_address_by_default")
613
+ end
614
+
615
+ def record_browser_user_agent_by_default=(value)
616
+ @record_browser_user_agent_by_default = ensure_boolean(value, "record_browser_user_agent_by_default")
617
+ end
618
+
619
+ # One setter per IP-geolocation data field, defined rather than written out
620
+ # nine times. Each one is a separate decision about what to keep about
621
+ # someone's network context, so each one gets its own name in the
622
+ # initializer, its own line in the privacy inventory, and its own entry in
623
+ # the receipt.
624
+ Vocabulary::IP_GEOLOCATION_DATA_FIELDS.each do |field|
625
+ setting = :"record_ip_geolocation_#{field}_by_default"
626
+
627
+ define_method(:"#{setting}=") do |value|
628
+ instance_variable_set(:"@#{setting}", ensure_boolean(value, setting.to_s))
629
+ end
630
+ end
631
+
632
+ def encrypt_recorded_ip_addresses=(value)
633
+ @encrypt_recorded_ip_addresses = ensure_encryption_choice(value, "encrypt_recorded_ip_addresses")
634
+ end
635
+
636
+ def encrypt_recorded_browser_user_agents=(value)
637
+ @encrypt_recorded_browser_user_agents =
638
+ ensure_encryption_choice(value, "encrypt_recorded_browser_user_agents")
639
+ end
640
+
641
+ def encrypt_recorded_ip_geolocation=(value)
642
+ @encrypt_recorded_ip_geolocation = ensure_encryption_choice(value, "encrypt_recorded_ip_geolocation")
643
+ end
644
+
645
+ def delete_recorded_ip_addresses_after=(value)
646
+ @delete_recorded_ip_addresses_after =
647
+ ensure_positive_duration_or_nil(value, "delete_recorded_ip_addresses_after")
648
+ end
649
+
650
+ def delete_recorded_browser_user_agents_after=(value)
651
+ @delete_recorded_browser_user_agents_after =
652
+ ensure_positive_duration_or_nil(value, "delete_recorded_browser_user_agents_after")
653
+ end
654
+
655
+ def delete_recorded_ip_geolocation_after=(value)
656
+ @delete_recorded_ip_geolocation_after =
657
+ ensure_positive_duration_or_nil(value, "delete_recorded_ip_geolocation_after")
658
+ end
659
+
660
+ def read_ip_address_from_http_request_with=(value)
661
+ @read_ip_address_from_http_request_with =
662
+ ensure_callable(value, "read_ip_address_from_http_request_with")
663
+ end
664
+
665
+ def read_browser_user_agent_from_http_request_with=(value)
666
+ @read_browser_user_agent_from_http_request_with =
667
+ ensure_callable(value, "read_browser_user_agent_from_http_request_with")
668
+ end
669
+
670
+ # A digest of the host's reviewed trusted-proxy configuration, stored beside
671
+ # any recorded IP address. It does not make the configuration correct; it
672
+ # records which configuration was in force when the address was observed, so
673
+ # a later reader can tell whether the value came through a path the host had
674
+ # actually verified.
675
+ def trusted_proxy_configuration_digest=(value)
676
+ if value.nil?
677
+ @trusted_proxy_configuration_digest = nil
678
+ return
679
+ end
680
+
681
+ normalized = value.to_s.strip
682
+ unless Digest.well_formed?(normalized)
683
+ raise ConfigurationError,
684
+ "trusted_proxy_configuration_digest must be a complete prefixed SHA-2 digest, " \
685
+ "such as `sha256:` followed by 64 lowercase hexadecimal characters. It records " \
686
+ "which reviewed proxy configuration was in force; a label or TODO is not a digest."
687
+ end
688
+
689
+ @trusted_proxy_configuration_digest = normalized
690
+ end
691
+
692
+ def ip_geolocation_resolver=(value)
693
+ @ip_geolocation_resolver = ensure_ip_geolocation_resolver(value, "ip_geolocation_resolver")
694
+ end
695
+
696
+ # Register more than one resolver and let each server-owned policy select
697
+ # one by name with `record_ip_geolocation ..., using: :maxmind`.
698
+ def register_ip_geolocation_resolver(name, resolver)
699
+ key = ensure_present_symbol(name, "IP-geolocation resolver name").to_s
700
+ if key == "application_default" || @ip_geolocation_resolvers.key?(key)
701
+ raise ConfigurationError,
702
+ "An IP-geolocation resolver named #{key.inspect} is already reserved or registered. " \
703
+ "Choose one stable, unique name; Clickwrap will not silently replace a resolver " \
704
+ "because initializer order changed."
705
+ end
706
+
707
+ @ip_geolocation_resolvers[key] = ensure_ip_geolocation_resolver(
708
+ resolver,
709
+ "IP-geolocation resolver #{key}"
710
+ )
711
+ end
712
+
713
+ def ip_geolocation_resolver_for(name = nil)
714
+ return ip_geolocation_resolver if name.blank? || name.to_s == "application_default"
715
+
716
+ @ip_geolocation_resolvers[name.to_s]
717
+ end
718
+
719
+ def ip_geolocation_resolver_names = @ip_geolocation_resolvers.keys.sort.freeze
720
+
721
+ # Plain-English key-rotation API. The ID is evidence and must stay stable;
722
+ # the callback returns key bytes for current OR historical IDs.
723
+ #
724
+ # config.current_request_evidence_binding_key_id = "request-evidence-2026-01"
725
+ # config.find_request_evidence_binding_key_with = ->(key_id) { keyring[key_id] }
726
+ def current_request_evidence_binding_key_id=(value)
727
+ @current_request_evidence_binding_key_id = ensure_present_string(
728
+ value, "current_request_evidence_binding_key_id"
729
+ )
730
+ end
731
+
732
+ def current_request_evidence_binding_key_id
733
+ @current_request_evidence_binding_key_id ||
734
+ default_request_evidence_binding_key_id(default_request_evidence_binding_key)
735
+ end
736
+
737
+ def find_request_evidence_binding_key_with=(value)
738
+ @find_request_evidence_binding_key_with =
739
+ ensure_callable(value, "find_request_evidence_binding_key_with")
740
+ end
741
+
742
+ def request_evidence_binding_key_for(key_id)
743
+ key = find_request_evidence_binding_key_with.call(key_id)
744
+ return nil if key.nil?
745
+
746
+ bytes = key.to_s.b
747
+ if bytes.bytesize < 32
748
+ raise ConfigurationError,
749
+ "find_request_evidence_binding_key_with returned only #{bytes.bytesize} bytes for " \
750
+ "#{key_id.inspect}. Request-evidence binding keys must be at least 32 bytes."
751
+ end
752
+
753
+ bytes
754
+ end
755
+
756
+ def fail_capture_when_ip_geolocation_is_unavailable=(value)
757
+ @fail_capture_when_ip_geolocation_is_unavailable =
758
+ ensure_boolean(value, "fail_capture_when_ip_geolocation_is_unavailable")
759
+ end
760
+
761
+ # --- Hook setters ---------------------------------------------------------
762
+
763
+ def after_event_is_committed=(value)
764
+ @after_event_is_committed = ensure_callable(value, "after_event_is_committed")
765
+ end
766
+
767
+ def report_after_commit_failure_with=(value)
768
+ @report_after_commit_failure_with = ensure_callable(value, "report_after_commit_failure_with")
769
+ end
770
+
771
+ # --- Retention calculations -----------------------------------------------
772
+
773
+ # Registers a host calculation for an event-based retention rule.
774
+ #
775
+ # config.calculate_retention_time_for :regulated_evidence_retention_ends do |event|
776
+ # [event.recorded_at_by_server + 5.years,
777
+ # event.subject_liquidated_at&.+(3.years)].compact.max
778
+ # end
779
+ #
780
+ # Returning nil is a legitimate answer: it means the triggering host event
781
+ # has not happened yet, so the record is not due for disposition and
782
+ # Clickwrap reports it as unresolved rather than inventing a date.
783
+ def calculate_retention_time_for(name, &block)
784
+ raise ConfigurationError, "calculate_retention_time_for needs a block" unless block
785
+
786
+ key = ensure_present_symbol(name, "retention calculation name")
787
+ if @retention_time_calculators.key?(key)
788
+ raise ConfigurationError,
789
+ "A retention calculation named #{key.inspect} is already registered. Use one " \
790
+ "stable name per calculation; Clickwrap will not silently replace a deletion " \
791
+ "deadline because initializer order changed."
792
+ end
793
+
794
+ @retention_time_calculators[key] = block
795
+ end
796
+
797
+ # Deliberate replacement for tests, staged migrations, or a host that is
798
+ # intentionally changing an existing calculation. The separate verb and
799
+ # required reason keep this from becoming last-initializer-wins behavior.
800
+ def replace_retention_time_calculation_for(name, because:, &block)
801
+ key = ensure_present_symbol(name, "retention calculation name")
802
+ unless @retention_time_calculators.key?(key)
803
+ raise ConfigurationError,
804
+ "No retention calculation named #{key.inspect} exists to replace. Register it " \
805
+ "first with `calculate_retention_time_for`."
806
+ end
807
+ unless block
808
+ raise ConfigurationError,
809
+ "replace_retention_time_calculation_for needs a block with the new calculation."
810
+ end
811
+ if because.to_s.strip.empty?
812
+ raise ConfigurationError,
813
+ "Replacing retention calculation #{key.inspect} needs a `because:` explaining " \
814
+ "the reviewed change."
815
+ end
816
+
817
+ @retention_time_calculators[key] = block
818
+ end
819
+
820
+ def retention_time_calculator_names = @retention_time_calculators.keys
821
+
822
+ def resolve_retention_time(name, event)
823
+ calculator = @retention_time_calculators[name.to_sym]
824
+
825
+ unless calculator
826
+ raise ConfigurationError,
827
+ "No retention calculation is registered for #{name.inspect}. A retention class " \
828
+ "asked for it with `retain_..._until #{name.inspect}`. Register it with " \
829
+ "`config.calculate_retention_time_for #{name.inspect} do |event| ... end`."
830
+ end
831
+
832
+ calculator.call(event)
833
+ end
834
+
835
+ # --- Cross-field validation -----------------------------------------------
836
+
837
+ # Run at the end of `Clickwrap.configure`. The per-setter checks already
838
+ # caught the typos; these are the things that need the whole block resolved.
839
+ def validate!
840
+ validate_request_evidence_defaults!
841
+ validate_trusted_proxy_configuration!
842
+ validate_ip_geolocation_resolver!
843
+ true
844
+ end
845
+
846
+ # A convenience the initializer template and `clickwrap:doctor` both use.
847
+ def records_any_request_evidence_by_default?
848
+ record_ip_address_by_default ||
849
+ record_browser_user_agent_by_default ||
850
+ enabled_default_ip_geolocation_fields.any?
851
+ end
852
+
853
+ def enabled_default_ip_geolocation_fields
854
+ Vocabulary::IP_GEOLOCATION_DATA_FIELDS.select do |field|
855
+ public_send(:"record_ip_geolocation_#{field}_by_default")
856
+ end
857
+ end
858
+
859
+ private
860
+
861
+ def default_actor_reference(actor)
862
+ return nil if actor.nil?
863
+ return actor.to_s if actor.is_a?(String) || actor.is_a?(Symbol)
864
+ return actor.clickwrap_actor_reference if actor.respond_to?(:clickwrap_actor_reference)
865
+
866
+ Reference.record(actor)
867
+ end
868
+
869
+ def validate_request_evidence_defaults!
870
+ {
871
+ ip_address: [record_ip_address_by_default,
872
+ reason_for_recording_ip_addresses_by_default,
873
+ delete_recorded_ip_addresses_after],
874
+ browser_user_agent: [record_browser_user_agent_by_default,
875
+ reason_for_recording_browser_user_agents_by_default,
876
+ delete_recorded_browser_user_agents_after],
877
+ ip_geolocation: [enabled_default_ip_geolocation_fields.any?,
878
+ reason_for_recording_ip_geolocation_by_default,
879
+ delete_recorded_ip_geolocation_after]
880
+ }.each do |category, (enabled, reason, delete_after)|
881
+ next unless enabled
882
+
883
+ if reason.to_s.strip.empty?
884
+ raise ConfigurationError,
885
+ "Clickwrap is set to record #{category} for every policy by default, but " \
886
+ "`reason_for_recording_#{plural_for(category)}_by_default` is blank. Say in one " \
887
+ "plain sentence why the application needs it. If only some policies need it, " \
888
+ "turn the default off and enable it in those policies instead."
889
+ end
890
+
891
+ if ReviewedText.placeholder?(reason)
892
+ raise ConfigurationError,
893
+ "Clickwrap is set to record #{category} for every policy by default, but its " \
894
+ "reason is still scaffolding text (#{reason.inspect}). Replace it with the " \
895
+ "application's reviewed, present-tense reason, or turn that default off."
896
+ end
897
+
898
+ next unless delete_after.nil?
899
+
900
+ raise ConfigurationError,
901
+ "Clickwrap is set to record #{category} for every policy by default, but " \
902
+ "`delete_recorded_#{plural_for(category)}_after` is nil, so nothing would ever " \
903
+ "delete it. Set a reviewed period, or turn the default off and let each policy " \
904
+ "choose its own retention rule."
905
+ end
906
+ end
907
+
908
+ def plural_for(category)
909
+ case category
910
+ when :ip_address then "ip_addresses"
911
+ when :browser_user_agent then "browser_user_agents"
912
+ else "ip_geolocation"
913
+ end
914
+ end
915
+
916
+ def validate_trusted_proxy_configuration!
917
+ records_ip_derived_evidence =
918
+ record_ip_address_by_default || enabled_default_ip_geolocation_fields.any?
919
+ return unless records_ip_derived_evidence
920
+ return if trusted_proxy_configuration_digest.present?
921
+
922
+ raise ConfigurationError,
923
+ "Clickwrap is set to record an IP address or derive IP geolocation for every " \
924
+ "policy, but `trusted_proxy_configuration_digest` is blank. Review and test the " \
925
+ "deployment's trusted-proxy topology, digest that exact configuration, and set " \
926
+ "the complete prefixed SHA-2 digest (for example `sha256:...`). This records which " \
927
+ "proxy decision produced the address; it does not claim that decision was correct."
928
+ end
929
+
930
+ def validate_ip_geolocation_resolver!
931
+ return if ip_geolocation_resolver
932
+ return if enabled_default_ip_geolocation_fields.empty? && !fail_capture_when_ip_geolocation_is_unavailable
933
+
934
+ if enabled_default_ip_geolocation_fields.any?
935
+ raise ConfigurationError,
936
+ "Clickwrap is set to record the IP-geolocation fields " \
937
+ "#{enabled_default_ip_geolocation_fields.join(", ")} but no " \
938
+ "`ip_geolocation_resolver` is configured, so there is nothing to resolve them. " \
939
+ "Set one (for example Clickwrap::IpGeolocation::TrackdownResolver.new) or turn " \
940
+ "the fields off."
941
+ end
942
+
943
+ raise ConfigurationError,
944
+ "`fail_capture_when_ip_geolocation_is_unavailable` is true but no " \
945
+ "`ip_geolocation_resolver` is configured, so every capture would fail."
946
+ end
947
+
948
+ # --- Setter helpers -------------------------------------------------------
949
+
950
+ def ensure_callable(value, name)
951
+ unless value.respond_to?(:call)
952
+ raise ConfigurationError,
953
+ "#{name} must respond to #call (a proc or lambda), got #{value.inspect}"
954
+ end
955
+
956
+ value
957
+ end
958
+
959
+ def ensure_adapter(value, name, *required_methods)
960
+ return nil if value.nil?
961
+
962
+ missing = required_methods.reject { |required_method| value.respond_to?(required_method) }
963
+ unless missing.empty?
964
+ raise ConfigurationError,
965
+ "#{name} must respond to #{missing.map { |method| "##{method}" }.join(", ")}, " \
966
+ "but #{value.inspect} does not. See the matching adapter section in README.md."
967
+ end
968
+
969
+ value
970
+ end
971
+
972
+ def ensure_ip_geolocation_resolver(value, name)
973
+ adapter = ensure_adapter(value, name, :resolve, :capabilities)
974
+ return nil if adapter.nil?
975
+
976
+ parameters = adapter.method(:resolve).parameters
977
+ accepts_http_request = parameters.any? do |kind, parameter_name|
978
+ kind == :keyrest || (%i[key keyreq].include?(kind) && parameter_name == :http_request)
979
+ end
980
+ return adapter if accepts_http_request
981
+
982
+ raise ConfigurationError,
983
+ "#{name} must implement `#resolve(ip_address, http_request: nil)`. The request is " \
984
+ "explicit because request-backed providers such as Cloudflare need it to read " \
985
+ "their location headers and record whether the host verified that CDN path. " \
986
+ "Update #{adapter.class} to accept the `http_request:` keyword, even if that " \
987
+ "resolver does not use it."
988
+ rescue NameError
989
+ raise ConfigurationError,
990
+ "#{name} exposes #resolve but Clickwrap could not inspect its parameters. Define " \
991
+ "`#resolve(ip_address, http_request: nil)` explicitly so request provenance is " \
992
+ "never dropped by an opaque adapter."
993
+ end
994
+
995
+ def ensure_class_name(value, name)
996
+ class_name = value.is_a?(Class) ? value.name : value.to_s
997
+
998
+ raise ConfigurationError, "#{name} can't be blank" if class_name.strip.empty?
999
+
1000
+ class_name
1001
+ end
1002
+
1003
+ def ensure_present_symbol(value, name)
1004
+ symbol = value.to_s.strip
1005
+
1006
+ raise ConfigurationError, "#{name} can't be blank" if symbol.empty?
1007
+
1008
+ symbol.to_sym
1009
+ end
1010
+
1011
+ def ensure_present_string(value, name)
1012
+ string = value.to_s.strip
1013
+ raise ConfigurationError, "#{name} can't be blank" if string.empty?
1014
+
1015
+ string
1016
+ end
1017
+
1018
+ def default_request_evidence_binding_key
1019
+ if defined?(::Rails) && ::Rails.application&.key_generator
1020
+ ::Rails.application.key_generator.generate_key("clickwrap/request-evidence-binding", 32)
1021
+ else
1022
+ ENV.fetch("CLICKWRAP_REQUEST_EVIDENCE_BINDING_KEY", nil)
1023
+ end
1024
+ end
1025
+
1026
+ def default_request_evidence_binding_key_id(key)
1027
+ return nil if key.nil?
1028
+
1029
+ "rails_key_generator_#{Digest.hex(key)[0, 16]}"
1030
+ end
1031
+
1032
+ def ensure_boolean(value, name)
1033
+ unless [true, false].include?(value)
1034
+ raise ConfigurationError, "#{name} must be true or false, got #{value.inspect}"
1035
+ end
1036
+
1037
+ value
1038
+ end
1039
+
1040
+ # Storing raw IP addresses or browser user-agent strings unencrypted is
1041
+ # allowed, because some hosts have a reviewed reason for it and pretending
1042
+ # otherwise would just push them to store the values somewhere worse. But it
1043
+ # is never the default, and it is never a quiet one-character change.
1044
+ def ensure_encryption_choice(value, name)
1045
+ return value if value == true
1046
+
1047
+ if value == false
1048
+ return false if @deliberately_storing_request_evidence_unencrypted
1049
+
1050
+ raise ConfigurationError,
1051
+ "#{name} = false stores this personal data in plain text in your database, where " \
1052
+ "it will also appear in ordinary backups and database dumps. If that is a " \
1053
+ "reviewed decision, say so explicitly first:\n\n " \
1054
+ "config.deliberately_store_request_evidence_unencrypted!(\n " \
1055
+ "because: \"...your reviewed reason...\"\n " \
1056
+ ")\n"
1057
+ end
1058
+
1059
+ raise ConfigurationError, "#{name} must be true or false, got #{value.inspect}"
1060
+ end
1061
+
1062
+ def ensure_digest_algorithm(value, name)
1063
+ normalized = value.to_s.to_sym
1064
+
1065
+ unless DIGEST_ALGORITHMS.include?(normalized)
1066
+ raise ConfigurationError,
1067
+ "#{name} must be one of #{DIGEST_ALGORITHMS.inspect}, got #{value.inspect}"
1068
+ end
1069
+
1070
+ normalized
1071
+ end
1072
+
1073
+ def ensure_duration(value, name)
1074
+ unless value.respond_to?(:from_now) && value.respond_to?(:ago)
1075
+ raise ConfigurationError,
1076
+ "#{name} must be a duration (like 2.hours), got #{value.inspect}"
1077
+ end
1078
+
1079
+ value
1080
+ end
1081
+
1082
+ def ensure_positive_duration_or_nil(value, name)
1083
+ return nil if value.nil?
1084
+
1085
+ duration = ensure_duration(value, name)
1086
+
1087
+ unless duration.to_i.positive?
1088
+ raise ConfigurationError,
1089
+ "#{name} must be a period in the future, got #{value.inspect}. Use nil if each " \
1090
+ "policy should choose its own retention rule."
1091
+ end
1092
+
1093
+ duration
1094
+ end
1095
+
1096
+ public
1097
+
1098
+ # The deliberate, named escape hatch referenced by `ensure_encryption_choice`.
1099
+ # It exists so that turning encryption off is a sentence a reviewer can find
1100
+ # in a diff, with the host's own reason attached, rather than a `false`.
1101
+ def deliberately_store_request_evidence_unencrypted!(because:)
1102
+ if because.to_s.strip.empty?
1103
+ raise ConfigurationError,
1104
+ "deliberately_store_request_evidence_unencrypted! needs a `because:` explaining " \
1105
+ "the reviewed decision."
1106
+ end
1107
+
1108
+ @deliberately_storing_request_evidence_unencrypted = true
1109
+ @reason_for_storing_request_evidence_unencrypted = because
1110
+ end
1111
+
1112
+ def storing_request_evidence_unencrypted? = @deliberately_storing_request_evidence_unencrypted == true
1113
+
1114
+ def method_missing(name, *arguments, **options, &)
1115
+ if name.to_s.end_with?("=")
1116
+ setting = name.to_s.delete_suffix("=")
1117
+ raise ConfigurationError,
1118
+ "Clickwrap has no initializer setting named `config.#{setting}`. Check the " \
1119
+ "spelling; unknown settings are refused so a typo can never look configured."
1120
+ end
1121
+
1122
+ super
1123
+ end
1124
+
1125
+ def respond_to_missing?(name, include_private = false)
1126
+ super
1127
+ end
1128
+ end
1129
+ end