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,418 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # `bin/rails clickwrap:doctor` — a read-only diagnosis you can act on at 03:00.
5
+ #
6
+ # ✓ 6 policies compiled
7
+ # ✓ all referenced documents are published and digest-verified
8
+ # ✓ every required gate has a remediation route
9
+ # ✓ request-derived personal data is off by default
10
+ # ! withdrawal_authorization records IP geolocation city without a review date
11
+ # ! request-source trust is unverified
12
+ # ✓ no overdue disposition
13
+ # ✓ all checked event digests verify
14
+ #
15
+ # ============================================================================
16
+ # EVERY FINDING HERE IS AN OBJECTIVE FACT ABOUT CONFIGURATION OR DATA. This
17
+ # class must never print, return, or imply "compliant", "court-proof", "audit
18
+ # guaranteed", "approved", or "certified", and there is no overall verdict
19
+ # line: a run with no warnings means the specific things listed below were
20
+ # checked and nothing objectionable was found in them. It does not mean the
21
+ # application is lawful, that its retention periods are right, or that its
22
+ # evidence would persuade anyone. Those are not questions a library can
23
+ # answer, and a green tick that implied otherwise would be worse than no tool
24
+ # at all. PHRASES_THIS_REPORT_NEVER_PRINTS exists so a test can assert it.
25
+ # ============================================================================
26
+ class Doctor
27
+ Finding = Data.define(:status, :message) do
28
+ def ok? = status == :ok
29
+ def warning? = status == :warning
30
+ def problem? = status == :problem
31
+
32
+ def to_s = "#{SYMBOLS.fetch(status, "?")} #{message}"
33
+ end
34
+
35
+ SYMBOLS = { ok: "✓", warning: "!", problem: "✗" }.freeze
36
+
37
+ # Greped by the release test against every message this class produces.
38
+ PHRASES_THIS_REPORT_NEVER_PRINTS = (
39
+ Vocabulary::PROHIBITED_CLAIM_PHRASES + [
40
+ "compliant",
41
+ "compliance",
42
+ "court proof",
43
+ "audit guaranteed",
44
+ "audit approved",
45
+ "certified",
46
+ "approved",
47
+ "enforceable",
48
+ "legally valid"
49
+ ]
50
+ ).freeze
51
+
52
+ # How many recent events get their digests recomputed. A doctor run is
53
+ # something an operator does interactively, so it samples rather than
54
+ # verifying the whole table; `clickwrap:verify` is the exhaustive one.
55
+ DIGEST_SAMPLE_SIZE = 100
56
+
57
+ def report
58
+ findings = []
59
+ findings.concat(policy_findings)
60
+ findings.concat(document_findings)
61
+ findings.concat(optional_table_findings)
62
+ findings.concat(engine_mount_findings)
63
+ findings.concat(gate_findings)
64
+ findings.concat(request_evidence_findings)
65
+ findings.concat(review_date_findings)
66
+ findings.concat(resolver_findings)
67
+ findings.concat(source_trust_findings)
68
+ findings.concat(with_database("disposition") { disposition_findings })
69
+ findings.concat(with_database("legal holds") { legal_hold_findings })
70
+ findings.concat(with_database("event digests") { digest_findings })
71
+ findings.concat(with_database("integrity attestations") { integrity_attestation_findings })
72
+ findings.concat(with_database("external actions") { external_action_findings })
73
+ findings
74
+ end
75
+
76
+ # The README's rendering: one line per finding, in the order they were
77
+ # produced, with nothing summarizing them into a verdict.
78
+ def to_s = report.join("\n")
79
+
80
+ private
81
+
82
+ # --- Configuration --------------------------------------------------------
83
+
84
+ def policy_findings
85
+ count = Clickwrap.policies.size
86
+
87
+ if count.zero?
88
+ return [warning("no policies are compiled. Declare them with `Clickwrap.policy :key do ... end`, " \
89
+ "conventionally in config/clickwrap.rb.")]
90
+ end
91
+
92
+ [ok("#{count} #{pluralize(count, "policy", "policies")} compiled")]
93
+ end
94
+
95
+ # Every document a policy references has to exist, be published, and still
96
+ # hash to what was recorded when it was published. A policy that presents a
97
+ # document nobody published is a runtime failure waiting for the first
98
+ # person who tries to sign up.
99
+ def document_findings
100
+ referenced = Clickwrap.policies.values.flat_map { |policy| policy.statements.flat_map(&:document_keys) }.uniq
101
+ return [ok("no policy references a document")] if referenced.empty?
102
+
103
+ problems = with_database("documents") { document_problems(referenced) }
104
+ return problems if problems.any?
105
+
106
+ [ok("all referenced documents are published and digest-verified " \
107
+ "(#{referenced.length} #{pluralize(referenced.length, "document", "documents")})")]
108
+ end
109
+
110
+ def document_problems(referenced)
111
+ referenced.flat_map do |key|
112
+ definitions = Clickwrap.documents.values.select { |definition| definition.key == key }
113
+
114
+ if definitions.empty?
115
+ next [problem("document :#{key} is referenced by a policy but never declared with " \
116
+ "`Clickwrap.document :#{key}, version: \"...\", from: ...`")]
117
+ end
118
+
119
+ definitions.filter_map { |definition| document_problem(definition) }
120
+ end
121
+ end
122
+
123
+ def document_problem(definition)
124
+ document = Document.find_by(document_key: definition.key, tenant_key: definition.tenant_key)
125
+ version = document&.versions&.find_by(version_label: definition.version_label, locale: definition.locale)
126
+
127
+ if version.nil? || !version.published?
128
+ return problem("document #{definition} is declared but not published. Run " \
129
+ "`bin/rails clickwrap:publish`.")
130
+ end
131
+
132
+ return nil if version.verify_content_digest
133
+
134
+ problem("the stored bytes for document #{definition} no longer match the digest recorded " \
135
+ "when it was published, so evidence citing it cannot be reproduced")
136
+ end
137
+
138
+ # A default install emits only the tables it can put a row in; each further
139
+ # capability brings its own migration. So this reports two different facts,
140
+ # and never conflates them: a capability that is ON with its tables missing
141
+ # is a problem with a command attached, and a capability that is simply not
142
+ # installed is a fact about this installation, stated once so an operator
143
+ # reading at 03:00 does not go looking for a table that was never meant to
144
+ # be there.
145
+ def optional_table_findings
146
+ SchemaRequirements.reset!
147
+ missing = SchemaRequirements.missing_for_configuration
148
+
149
+ SchemaRequirements::FEATURES.map do |feature|
150
+ installed = SchemaRequirements.installed?(feature)
151
+
152
+ if installed.nil?
153
+ warning("could not check whether the #{feature.flag} tables exist (no database)")
154
+ elsif installed
155
+ ok("the #{feature.flag} tables are installed")
156
+ elsif missing.include?(feature)
157
+ problem(feature.explanation)
158
+ else
159
+ ok("the #{feature.flag} tables are not installed, and nothing configured needs them")
160
+ end
161
+ end
162
+ end
163
+
164
+ # The mount is not only about gates. Every presentation links each document
165
+ # at its immutable engine route, and that link is signed into the manifest
166
+ # and kept as the exact document the person was offered. Unmounted, there
167
+ # is no such route: presenting refuses rather than signing a dead link, so
168
+ # a compiled policy on an unmounted application is a broken signup screen,
169
+ # not a stylistic choice.
170
+ def engine_mount_findings
171
+ return [] if Clickwrap.policies.empty?
172
+ return [ok("Clickwrap::Engine is mounted, so every document link a presentation signs resolves")] \
173
+ if engine_mounted?
174
+
175
+ [problem("Clickwrap::Engine is not mounted, so presenting any of the " \
176
+ "#{Clickwrap.policies.size} compiled #{pluralize(Clickwrap.policies.size, "policy",
177
+ "policies")} refuses rather than " \
178
+ "sign a document link that resolves to nothing. Mount it with " \
179
+ "`mount Clickwrap::Engine => \"/agreements\"`, or bind your own document route into " \
180
+ "every presentation with `document_version_path_with:`.")]
181
+ end
182
+
183
+ # A `requires_clickwrap` gate refuses to compile without somewhere to send
184
+ # the person it stops, so the objective question left for the doctor is
185
+ # whether the engine — the default destination — is actually mounted.
186
+ def gate_findings
187
+ return [ok("every required gate has a remediation route (Clickwrap::Engine is mounted)")] if engine_mounted?
188
+
189
+ [warning("Clickwrap::Engine is not mounted, so every `requires_clickwrap` gate needs its own " \
190
+ "`remediation_path:`. Mount it with `mount Clickwrap::Engine => \"/agreements\"` or " \
191
+ "point each gate at a page you own.")]
192
+ end
193
+
194
+ def engine_mounted?
195
+ ControllerHelpers.engine_is_mounted?
196
+ rescue StandardError
197
+ false
198
+ end
199
+
200
+ def request_evidence_findings
201
+ config = Clickwrap.config
202
+
203
+ if config.records_any_request_evidence_by_default?
204
+ return [warning("request-derived personal data is recorded by default for every policy " \
205
+ "(#{default_categories.join(", ")}). That is a decision worth re-reading: " \
206
+ "a policy that does not need an IP address still gets one.")]
207
+ end
208
+
209
+ [ok("request-derived personal data is off by default")]
210
+ end
211
+
212
+ def default_categories
213
+ config = Clickwrap.config
214
+ categories = []
215
+ categories << "ip_address" if config.record_ip_address_by_default
216
+ categories << "browser_user_agent" if config.record_browser_user_agent_by_default
217
+ geolocation = config.enabled_default_ip_geolocation_fields
218
+ categories << "ip_geolocation #{geolocation.join("/")}" if geolocation.any?
219
+ categories
220
+ end
221
+
222
+ # A policy that keeps a person's IP address or estimated city with no date
223
+ # on which somebody looks at that decision again is how a temporary
224
+ # measure becomes permanent. The date is the host's; the doctor only
225
+ # notices its absence — for EVERY collected category, not only
226
+ # geolocation: an IP address past its review date is exactly as overdue
227
+ # as a city would be.
228
+ def review_date_findings
229
+ Clickwrap.policies.values.filter_map do |policy|
230
+ request_evidence = policy.request_evidence
231
+ collected = collected_request_evidence_categories(request_evidence)
232
+ next if collected.empty?
233
+
234
+ review_on = request_evidence.review_configuration_on
235
+
236
+ if review_on.nil?
237
+ warning("#{policy.key} records #{collected.join(", ")} without a review date. Add " \
238
+ "`review_request_evidence_configuration_on` so this decision gets looked at again.")
239
+ elsif past?(review_on)
240
+ warning("#{policy.key} was due to have its request-evidence configuration " \
241
+ "(#{collected.join(", ")}) reviewed on #{review_on}, and that date has passed")
242
+ end
243
+ end + default_review_date_findings
244
+ end
245
+
246
+ def collected_request_evidence_categories(request_evidence)
247
+ categories = []
248
+ categories << "ip_address" if request_evidence.records_ip_address?
249
+ categories << "browser_user_agent" if request_evidence.records_browser_user_agent?
250
+ if request_evidence.records_ip_geolocation?
251
+ categories << "ip_geolocation #{request_evidence.enabled_ip_geolocation_fields.join("/")}"
252
+ end
253
+ categories
254
+ end
255
+
256
+ def default_review_date_findings
257
+ config = Clickwrap.config
258
+ return [] unless config.records_any_request_evidence_by_default?
259
+
260
+ review_on = config.review_default_request_evidence_configuration_on
261
+
262
+ if review_on.nil?
263
+ [warning("request evidence is recorded by default with no " \
264
+ "`review_default_request_evidence_configuration_on` date")]
265
+ elsif past?(review_on)
266
+ [warning("the default request-evidence configuration was due for review on #{review_on}, " \
267
+ "and that date has passed")]
268
+ else
269
+ []
270
+ end
271
+ end
272
+
273
+ def resolver_findings
274
+ configured = Clickwrap.config.ip_geolocation_resolver
275
+ wanted = Clickwrap.policies.values.select { |policy| policy.request_evidence.records_ip_geolocation? }
276
+
277
+ if configured.nil?
278
+ return [ok("no IP-geolocation resolver is configured, and no policy asks for one")] if wanted.empty?
279
+
280
+ # A warning rather than a problem: capture still succeeds and the field
281
+ # is recorded as unavailable with a reason, which is the honest outcome.
282
+ # What it is not is what the policy asked for, so somebody should know.
283
+ return [warning("#{wanted.map(&:key).join(", ")} records IP geolocation but no " \
284
+ "`ip_geolocation_resolver` is configured, so every capture will record it " \
285
+ "as unavailable")]
286
+ end
287
+
288
+ [ok("an IP-geolocation resolver is configured (#{configured.class.name})")]
289
+ end
290
+
291
+ # An IP address read from a forwarded header is only as good as the proxy
292
+ # configuration in front of it. Clickwrap cannot check a host's Cloudflare,
293
+ # load balancer, or `config.hosts` setup; what it can check is whether the
294
+ # host ever recorded that they verified one.
295
+ def source_trust_findings
296
+ records_ip_address = Clickwrap.config.record_ip_address_by_default ||
297
+ Clickwrap.policies.values.any? { |policy| policy.request_evidence.records_ip_address? }
298
+ return [] unless records_ip_address
299
+
300
+ if Clickwrap.config.trusted_proxy_configuration_digest.blank?
301
+ return [warning("request-source trust is unverified: IP addresses are recorded, but no " \
302
+ "`trusted_proxy_configuration_digest` is set, so nothing records which " \
303
+ "proxy configuration (Cloudflare, a load balancer, a CDN) the addresses " \
304
+ "were read through")]
305
+ end
306
+
307
+ [ok("a reviewed trusted-proxy configuration digest is recorded with each recorded IP address")]
308
+ end
309
+
310
+ # --- Data -----------------------------------------------------------------
311
+
312
+ def disposition_findings
313
+ now = Clickwrap.now
314
+ overdue = Event.due_for_core_disposition(now).count +
315
+ RequestEvidence.with_ip_address_due(now).count +
316
+ RequestEvidence.with_browser_user_agent_due(now).count +
317
+ RequestEvidence.with_ip_geolocation_due(now).count
318
+
319
+ return [ok("no overdue disposition")] if overdue.zero?
320
+
321
+ [warning("#{overdue} #{pluralize(overdue, "record is", "records are")} past a retention rule " \
322
+ "and still here. Run `bin/rails clickwrap:retention:plan`, review it, then " \
323
+ "`bin/rails clickwrap:retention:apply PLAN=...`.")]
324
+ end
325
+
326
+ def legal_hold_findings
327
+ in_effect = LegalHold.in_effect.count
328
+ due = LegalHold.due_for_review(Clickwrap.now).count
329
+
330
+ return [ok("no legal holds are in effect")] if in_effect.zero?
331
+
332
+ if due.zero?
333
+ return [ok("#{in_effect} legal #{pluralize(in_effect, "hold", "holds")} in effect, none past review")]
334
+ end
335
+
336
+ [warning("#{due} legal #{pluralize(due, "hold is", "holds are")} past the review date they " \
337
+ "were placed with. A hold nobody revisits is how everything gets kept forever.")]
338
+ end
339
+
340
+ def digest_findings
341
+ events = Event.order(id: :desc).limit(DIGEST_SAMPLE_SIZE).to_a
342
+ return [ok("no events recorded yet")] if events.empty?
343
+
344
+ by_status = events.group_by(&:digest_integrity_status)
345
+ failed = by_status.fetch(:unaccounted_mismatch, [])
346
+
347
+ if failed.empty?
348
+ dispositions = by_status.fetch(:documented_core_disposition, []).length
349
+ verifying = by_status.fetch(:verified, []).length
350
+
351
+ return [ok("all #{events.length} checked event digests verify")] if dispositions.zero?
352
+
353
+ return [ok("#{verifying} checked event digests verify and #{dispositions} " \
354
+ "#{pluralize(dispositions, "core disposition is", "core dispositions are")} " \
355
+ "documented by valid linked events; no mismatch is unexplained")]
356
+ end
357
+
358
+ [problem("#{failed.length} of #{events.length} checked event digests do not match the bytes " \
359
+ "they cover and have no valid linked disposition event (first: #{failed.first.id}). " \
360
+ "That means those rows changed after they were written or carry an unexplained " \
361
+ "disposition marker; it does not on its own say who changed them.")]
362
+ end
363
+
364
+ def external_action_findings
365
+ unresolved = ExternalAction.unresolved.count
366
+ return [ok("no unresolved external actions")] if unresolved.zero?
367
+
368
+ stale = ExternalAction.needing_reconciliation.count
369
+
370
+ [warning("#{unresolved} external #{pluralize(unresolved, "action is", "actions are")} still " \
371
+ "pending or unknown (#{stale} older than 15 minutes). Run " \
372
+ "`bin/rails clickwrap:reconcile_external_actions` to list them.")]
373
+ end
374
+
375
+ def integrity_attestation_findings
376
+ counts = Integrity::AttestationReconciler.missing_counts
377
+ return [ok("no external integrity attestation adapters are configured")] if counts.empty?
378
+
379
+ missing = counts.sum { |_, count| count }
380
+ return [ok("every eligible event has an attestation attempt from each configured adapter")] if missing.zero?
381
+
382
+ details = counts.filter_map do |kind, count|
383
+ "#{count} #{kind.tr("_", " ")}" unless count.zero?
384
+ end.join(", ")
385
+
386
+ [warning("#{missing} configured integrity #{pluralize(missing, "attestation attempt is",
387
+ "attestation attempts are")} " \
388
+ "missing (#{details}). Run `bin/rails clickwrap:integrity:attest_missing`; this can " \
389
+ "call external providers and does not change the committed evidence event.")]
390
+ end
391
+
392
+ # --- Plumbing -------------------------------------------------------------
393
+
394
+ # Half of what the doctor checks needs the database, and the reason someone
395
+ # is running it at 03:00 may well be that the database is unhappy. A failed
396
+ # check reports what it could not read instead of taking the whole report
397
+ # down with it.
398
+ def with_database(what)
399
+ yield
400
+ rescue StandardError => error
401
+ [warning("could not check #{what}: #{error.class}. #{error.message}")]
402
+ end
403
+
404
+ def past?(date)
405
+ return false if date.nil?
406
+
407
+ date.to_time <= Clickwrap.now
408
+ rescue StandardError
409
+ false
410
+ end
411
+
412
+ def pluralize(count, singular, plural) = count == 1 ? singular : plural
413
+
414
+ def ok(message) = Finding.new(status: :ok, message: message)
415
+ def warning(message) = Finding.new(status: :warning, message: message)
416
+ def problem(message) = Finding.new(status: :problem, message: message)
417
+ end
418
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # A declared document version, before it is published.
5
+ #
6
+ # Clickwrap.document :terms,
7
+ # from: Rails.root.join("app/content/legal/terms.md")
8
+ #
9
+ # Clickwrap.document :terms,
10
+ # version: "2026-08-15",
11
+ # locale: :en,
12
+ # effective_at: Time.utc(2026, 8, 15),
13
+ # from: Rails.root.join("app/content/legal/terms.en.md")
14
+ #
15
+ # Clickwrap.document :terms,
16
+ # from: Rails.root.join("app/content/legal/terms.md"),
17
+ # link: "/legal/terms"
18
+ #
19
+ # `link:` is where a PERSON reads this document — the host's own formatted
20
+ # page, with its typography, its navigation, and its language switcher —
21
+ # rather than the engine's plain rendering of the published bytes. It is the
22
+ # path Clickwrap presents beside the control AND the path it signs into the
23
+ # presentation manifest, so the evidence never cites a different target from
24
+ # the link somebody could actually press.
25
+ #
26
+ # The trade is explicit and belongs to the host: a host page shows whatever
27
+ # is current, so the signed path is a stable address rather than an immutable
28
+ # snapshot. The bytes are still frozen, digested, and recorded — what changes
29
+ # is which URL the receipt says was offered. A host that wants the immutable
30
+ # rendering in the evidence simply leaves `link:` off and gets the engine's
31
+ # per-version route, as before.
32
+ #
33
+ # The declaration says which bytes to publish. `bin/rails clickwrap:publish`
34
+ # reads them once, digests them, and freezes a database snapshot. From then
35
+ # on the snapshot is the evidence; the file on disk is only where it came
36
+ # from. Changing the file does not change published evidence, and reusing a
37
+ # version label for different bytes is refused rather than silently accepted.
38
+ #
39
+ # `version:` is optional exactly when the source can name its own: a file or
40
+ # inline content whose leading YAML front matter carries `clickwrap_version:`
41
+ # (or `last_updated:`) IS the single source of truth for its label, so
42
+ # bumping a legal text is one edit in one file. A source with no front-matter
43
+ # version and no explicit `version:` is refused at boot with a sentence —
44
+ # Clickwrap never invents a label, because a policy that requires a current
45
+ # version cannot be satisfied by a guess.
46
+ class DocumentDefinition
47
+ SOURCE_KINDS = %i[file inline resolver].freeze
48
+
49
+ MEDIA_TYPES_BY_EXTENSION = {
50
+ ".md" => "text/markdown",
51
+ ".markdown" => "text/markdown",
52
+ ".html" => "text/html",
53
+ ".htm" => "text/html",
54
+ ".txt" => "text/plain",
55
+ ".json" => "application/json",
56
+ ".pdf" => "application/pdf"
57
+ }.freeze
58
+
59
+ REFUSED_VERSION_LABELS = %w[unversioned current latest head none default].freeze
60
+
61
+ # A `link:` is rendered as an href and signed into evidence, so it has to be
62
+ # somewhere a browser can navigate. Anything else — `javascript:`, `data:`,
63
+ # a bare word — would be a scheme the gem itself painted into a page.
64
+ LINK_SCHEMES = %r{\A(?:/[^/]|/\z|https://|http://)}
65
+
66
+ attr_reader :key, :version_label, :locale, :media_type, :effective_at,
67
+ :tenant_key, :source_kind, :source_reference, :inline_content,
68
+ :resolver, :renderer, :link
69
+
70
+ def initialize(key:, version: nil, locale: :en, media_type: nil, effective_at: nil,
71
+ tenant: nil, from: nil, content: nil, resolver: nil, renderer: nil,
72
+ link: nil)
73
+ @key = normalize_key(key)
74
+ @locale = locale.to_s
75
+ @effective_at = effective_at
76
+ @tenant_key = tenant&.to_s
77
+ @renderer = renderer
78
+ @link = normalize_link(link)
79
+
80
+ assign_source(from:, content:, resolver:)
81
+ @version_label = normalize_version(version || version_label_from_front_matter)
82
+ @media_type = (media_type || infer_media_type).to_s
83
+
84
+ validate!
85
+ freeze
86
+ end
87
+
88
+ # Reads the exact bytes this definition points at. Called at publish time,
89
+ # never at export time: an export that re-read a mutable source and called
90
+ # the result historical evidence would be a lie.
91
+ def read_bytes
92
+ case source_kind
93
+ when :inline then inline_content.to_s.dup
94
+ when :file then read_file_bytes
95
+ when :resolver then read_resolver_bytes
96
+ end
97
+ end
98
+
99
+ def identity = [tenant_key, key, version_label, locale]
100
+
101
+ def to_s
102
+ "#{key} #{version_label} (#{locale})"
103
+ end
104
+
105
+ private
106
+
107
+ def assign_source(from:, content:, resolver:)
108
+ provided = { from:, content:, resolver: }.compact
109
+
110
+ if provided.length > 1
111
+ raise DefinitionError,
112
+ "Document #{key} #{version_label} declares #{provided.keys.join(" and ")}. " \
113
+ "Give it exactly one source."
114
+ end
115
+
116
+ if from
117
+ @source_kind = :file
118
+ @source_reference = from.to_s
119
+ elsif content
120
+ @source_kind = :inline
121
+ @inline_content = content.to_s
122
+ @source_reference = "inline"
123
+ elsif resolver
124
+ @source_kind = :resolver
125
+ @resolver = resolver
126
+ @source_reference = "resolver"
127
+ else
128
+ raise DefinitionError,
129
+ "Document #{key} #{version_label} has no source. Use `from:` with a path, " \
130
+ "`content:` with a string, or `resolver:` with something that returns bytes."
131
+ end
132
+ end
133
+
134
+ def read_file_bytes
135
+ unless File.exist?(source_reference)
136
+ raise DocumentNotPublishedError,
137
+ "Document #{self} points at #{source_reference}, which does not exist."
138
+ end
139
+
140
+ File.binread(source_reference)
141
+ end
142
+
143
+ # No explicit `version:` was given, so the source itself must name one
144
+ # through its leading YAML front matter. Every refusal here is a boot
145
+ # failure with the fix in the sentence, because a silently invented label
146
+ # would let "unversioned" evidence masquerade as versioned.
147
+ def version_label_from_front_matter
148
+ case source_kind
149
+ when :file then file_front_matter_version_label
150
+ when :inline then inline_front_matter_version_label
151
+ else
152
+ raise DefinitionError,
153
+ "Document #{key} has no `version:`, and a `resolver:` source cannot name its own " \
154
+ "version at boot because its bytes are only read at publish time. Pass `version:` " \
155
+ "explicitly, for example version: \"2026-08-15\"."
156
+ end
157
+ end
158
+
159
+ def file_front_matter_version_label
160
+ unless File.exist?(source_reference)
161
+ raise DefinitionError,
162
+ "Document #{key} has no `version:` and its file #{source_reference} does not " \
163
+ "exist yet, so there is no front matter to read one from. Create the file with a " \
164
+ "`clickwrap_version:` or `last_updated:` front-matter key, or pass `version:` " \
165
+ "explicitly."
166
+ end
167
+
168
+ FrontMatter.version_label_in(File.read(source_reference)) ||
169
+ raise(DefinitionError,
170
+ "Document #{key} has no `version:` and #{source_reference} has no " \
171
+ "`clickwrap_version:` or `last_updated:` front-matter key. The version label has " \
172
+ "no other source of truth — add one of those keys to the file's front matter " \
173
+ "(`clickwrap_version:` wins when both are present), or pass `version:` explicitly.")
174
+ end
175
+
176
+ def inline_front_matter_version_label
177
+ FrontMatter.version_label_in(inline_content) ||
178
+ raise(DefinitionError,
179
+ "Document #{key} has no `version:` and its inline `content:` has no " \
180
+ "`clickwrap_version:` or `last_updated:` front-matter key. Add one to the " \
181
+ "content's leading front matter, or pass `version:` explicitly.")
182
+ end
183
+
184
+ def read_resolver_bytes
185
+ bytes = resolver.respond_to?(:call) ? resolver.call(self) : resolver.read(self)
186
+
187
+ if bytes.nil? || bytes.to_s.empty?
188
+ raise DocumentNotPublishedError,
189
+ "The resolver for document #{self} returned no bytes."
190
+ end
191
+
192
+ bytes.to_s
193
+ end
194
+
195
+ def infer_media_type
196
+ return "text/plain" unless source_kind == :file
197
+
198
+ MEDIA_TYPES_BY_EXTENSION.fetch(File.extname(source_reference).downcase, "text/plain")
199
+ end
200
+
201
+ def normalize_key(value)
202
+ normalized = value.to_s
203
+ raise DefinitionError, "A document needs a key, for example :terms" if normalized.empty?
204
+
205
+ normalized
206
+ end
207
+
208
+ # A version label is the application's own name for a frozen set of bytes.
209
+ # Clickwrap does not interpret it — a date, a semantic version, and a git
210
+ # SHA are all fine — but it refuses the placeholder labels applications
211
+ # reach for when they have not really versioned anything, because a policy
212
+ # that requires a current version cannot be satisfied by "unversioned".
213
+ def normalize_version(value)
214
+ normalized = value.to_s.strip
215
+
216
+ if normalized.empty?
217
+ raise DefinitionError,
218
+ "Document #{key} needs a `version:` label, for example \"2026-08-15\"."
219
+ end
220
+
221
+ if REFUSED_VERSION_LABELS.include?(normalized.downcase)
222
+ raise DefinitionError,
223
+ "Document #{key} cannot use the version label #{normalized.inspect}: it names a " \
224
+ "moving target rather than a frozen set of bytes. Use a date, a release tag, or " \
225
+ "any label you will never reuse for different content."
226
+ end
227
+
228
+ normalized
229
+ end
230
+
231
+ def normalize_link(value)
232
+ return nil if value.nil?
233
+
234
+ normalized = value.to_s.strip
235
+ return nil if normalized.empty?
236
+
237
+ unless normalized.match?(LINK_SCHEMES)
238
+ raise DefinitionError,
239
+ "Document #{key} has a `link:` of #{value.inspect}. A link is the page a person " \
240
+ "reads this document on, so it must be a root-relative path (\"/legal/terms\") or " \
241
+ "an absolute http(s) URL. Clickwrap renders it as an href and signs it into the " \
242
+ "presentation manifest, and it will not sign a scheme it cannot navigate to."
243
+ end
244
+
245
+ normalized.freeze
246
+ end
247
+
248
+ def validate!
249
+ return unless effective_at && !effective_at.respond_to?(:to_time)
250
+
251
+ raise DefinitionError,
252
+ "Document #{self} has an `effective_at:` that is not a time."
253
+ end
254
+ end
255
+ end