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
data/exe/clickwrap ADDED
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # The standalone verifier.
5
+ #
6
+ # clickwrap verify receipt.json --documents ./receipt-documents
7
+ #
8
+ # ==============================================================================
9
+ # THIS COMMAND MUST WORK WITH NOTHING. No Rails, no host application, no
10
+ # database, no configuration, no network, and no access to the code that wrote
11
+ # the receipt. That is the entire point of it: a receipt whose only verifier is
12
+ # the application that produced it is a receipt whose verification nobody
13
+ # independent can repeat. Someone should be able to install this gem on a laptop
14
+ # years from now, point it at a JSON file and a folder of documents, and get an
15
+ # answer.
16
+ #
17
+ # So this file requires four things and nothing else: canonical JSON, the digest
18
+ # helpers, the receipt verifier, and the standard library. Adding an
19
+ # ActiveSupport call, a Rails constant, or a gem dependency here would quietly
20
+ # break the promise the receipt itself makes in its `verifier_instructions`.
21
+ # ==============================================================================
22
+ #
23
+ # What a successful run establishes is bounded, and the output says so: the
24
+ # bytes in this file still hash to the digest recorded inside it, and the
25
+ # document files supplied still hash to the digests the receipt cites. It does
26
+ # not establish who wrote the receipt, when, or that whoever controlled the
27
+ # application and its database could not have written both the record and its
28
+ # digest.
29
+
30
+ lib = File.expand_path("../lib", __dir__)
31
+ $LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib)
32
+
33
+ require "json"
34
+
35
+ require "clickwrap/version"
36
+ require "clickwrap/errors"
37
+ require "clickwrap/canonical_json"
38
+ require "clickwrap/digest"
39
+
40
+ # The command-line front end. Argument parsing by hand rather than through
41
+ # OptionParser: there is one command with one option, and a hand-written parser
42
+ # is easier to read than the configuration of a general one.
43
+ module ClickwrapCommandLine
44
+ SUCCESS = 0
45
+ FAILURE = 1
46
+ INCOMPLETE = 2
47
+
48
+ # Three states, not two. A check that could not run — a document whose bytes
49
+ # nobody supplied — is neither a pass nor a failure, and collapsing it into
50
+ # either one turns "we did not look" into "we looked and it was fine".
51
+ SYMBOLS = { true => "✓", false => "✗", nil => "–" }.freeze
52
+
53
+ USAGE = <<~TEXT.freeze
54
+ clickwrap #{Clickwrap::VERSION} — verify a Clickwrap receipt without the application that wrote it.
55
+
56
+ Usage:
57
+ clickwrap verify RECEIPT.json [--documents DIR] [--json]
58
+ clickwrap --help
59
+ clickwrap --version
60
+
61
+ Options:
62
+ --documents DIR Folder holding BOTH artifacts for every cited document. Name them
63
+ KEY-VERSION-LOCALE.source.EXT and
64
+ KEY-VERSION-LOCALE.rendered.EXT. The source and the exact rendered
65
+ representation are checked against their separate recorded digests.
66
+ --json Print the result as JSON instead of lines of text.
67
+
68
+ Exit status:
69
+ 0 every required check passed
70
+ 1 a check failed or the receipt could not be read
71
+ 2 no check failed, but at least one required artifact was not supplied
72
+
73
+ What a pass means: the canonical bytes still hash to the digest recorded inside the
74
+ receipt, and the documents supplied still hash to the digests it cites. It does not
75
+ establish who produced the receipt, when, or that a party controlling the original
76
+ application and its database could not have written both the record and its digest.
77
+ TEXT
78
+
79
+ module_function
80
+
81
+ def run(argv)
82
+ return help if argv.empty? || argv.first == "--help" || argv.first == "-h"
83
+ return version if ["--version", "-v"].include?(argv.first)
84
+
85
+ command = argv.shift
86
+
87
+ case command
88
+ when "verify" then verify(argv)
89
+ else
90
+ warn("Unknown command #{command.inspect}.\n\n#{USAGE}")
91
+ FAILURE
92
+ end
93
+ end
94
+
95
+ def help
96
+ say(USAGE)
97
+ SUCCESS
98
+ end
99
+
100
+ def version
101
+ say("clickwrap #{Clickwrap::VERSION} (receipt schema #{Clickwrap::CANONICAL_SCHEMA_VERSION})")
102
+ SUCCESS
103
+ end
104
+
105
+ def verify(argv)
106
+ options = parse_verify_options(argv)
107
+ return FAILURE if options.nil?
108
+
109
+ receipt_json = read_receipt(options[:receipt_path])
110
+ return FAILURE if receipt_json.nil?
111
+
112
+ parsed = parse_json(receipt_json, options[:receipt_path])
113
+ return FAILURE if parsed.nil?
114
+
115
+ documents, sources = load_documents(parsed, options[:documents_directory])
116
+ return FAILURE if documents.nil?
117
+
118
+ result = verifier.verify(receipt_json, documents: documents)
119
+ options[:json] ? report_as_json(parsed, result, sources) : report(parsed, result, sources)
120
+ return FAILURE if result.failed?
121
+ return INCOMPLETE if result.incomplete?
122
+
123
+ SUCCESS
124
+ rescue Clickwrap::Error => error
125
+ warn("#{error.class.name.split("::").last}: #{error.message}")
126
+ FAILURE
127
+ end
128
+
129
+ # --- Arguments ------------------------------------------------------------
130
+
131
+ def parse_verify_options(argv)
132
+ options = { receipt_path: nil, documents_directory: nil, json: false }
133
+
134
+ until argv.empty?
135
+ argument = argv.shift
136
+
137
+ case argument
138
+ when "--documents"
139
+ options[:documents_directory] = argv.shift
140
+ when /\A--documents=(.+)\z/
141
+ options[:documents_directory] = Regexp.last_match(1)
142
+ when "--json"
143
+ options[:json] = true
144
+ else
145
+ options[:receipt_path] = argument
146
+ end
147
+ end
148
+
149
+ return options if options[:receipt_path]
150
+
151
+ warn("clickwrap verify needs the path to a receipt file.\n\n#{USAGE}")
152
+ nil
153
+ end
154
+
155
+ # --- Reading --------------------------------------------------------------
156
+
157
+ def read_receipt(path)
158
+ unless File.file?(path)
159
+ warn("There is no file at #{path}.")
160
+ return nil
161
+ end
162
+
163
+ File.read(path, encoding: Encoding::UTF_8)
164
+ end
165
+
166
+ def parse_json(text, path)
167
+ JSON.parse(text)
168
+ rescue JSON::ParserError => error
169
+ warn("#{path} is not valid JSON: #{error.message}")
170
+ nil
171
+ end
172
+
173
+ # Matches each document the receipt cites to a file in the bundle.
174
+ #
175
+ # The receipt names a key, a version label, and a locale, so those are tried
176
+ # first, most specific first, and a file whose name merely starts with the key
177
+ # is the last resort. A document with no matching file is reported as not
178
+ # checked — never as verified, and never as failed, because "we did not look"
179
+ # and "we looked and it was wrong" are different answers.
180
+ def load_documents(receipt, directory)
181
+ entries = Array(receipt["documents"])
182
+ return [{}, {}] if directory.nil? || entries.empty?
183
+
184
+ unless File.directory?(directory)
185
+ warn("There is no directory at #{directory}.")
186
+ return [nil, nil]
187
+ end
188
+
189
+ files = Dir.children(directory).select { |name| File.file?(File.join(directory, name)) }
190
+ documents = {}
191
+ sources = {}
192
+ @ambiguous_documents = {}
193
+
194
+ entries.each do |entry|
195
+ key = entry["key"].to_s
196
+ version = entry["version"].to_s
197
+ locale = entry["locale"].to_s
198
+ identity = [key, version, locale].reject(&:empty?).join("@")
199
+ next if key.empty? || documents.key?(identity)
200
+
201
+ source_name = match_artifact_file(files, key, version, locale, "source")
202
+ rendered_name = match_artifact_file(files, key, version, locale, "rendered")
203
+
204
+ if source_name || rendered_name
205
+ documents[identity] = {}
206
+ if source_name
207
+ documents[identity]["source"] = File.binread(File.join(directory, source_name))
208
+ sources["#{identity}:source"] = source_name
209
+ end
210
+ if rendered_name
211
+ documents[identity]["rendered"] = File.binread(File.join(directory, rendered_name))
212
+ sources["#{identity}:rendered"] = rendered_name
213
+ end
214
+ next
215
+ end
216
+
217
+ # A single generic file can only mean the representation the receipt
218
+ # records as offered. When source and rendered digests are identical the
219
+ # verifier can safely use those same bytes for both; otherwise callers
220
+ # must use the explicit .source/.rendered names above.
221
+ name = match_file(files, key, version, locale)
222
+ next if name.nil?
223
+
224
+ documents[identity] = File.binread(File.join(directory, name))
225
+ sources["#{identity}:rendered"] = name
226
+ end
227
+
228
+ [documents, sources]
229
+ end
230
+
231
+ # Finds the one file that holds a document's bytes, or nothing.
232
+ #
233
+ # "Or nothing" is deliberate, and so is the ambiguity check. Two files can
234
+ # easily reduce to the same name — `terms.md` beside a stray `terms.bak`, or
235
+ # two locales dropped in the same folder — and a verifier that quietly picked
236
+ # one would report that a document verified while having read a file the
237
+ # operator never meant to offer. That is the one failure mode a verification
238
+ # tool must not have, so an ambiguous match is reported as ambiguous and the
239
+ # document is left unchecked rather than checked against a guess.
240
+ def match_file(files, key, version, locale)
241
+ candidates = ["#{key}-#{version}-#{locale}", "#{key}-#{version}", "#{key}.#{locale}", key]
242
+
243
+ candidates.each do |candidate|
244
+ matches = files.select { |name| basename(name).casecmp?(candidate) }
245
+ return matches.first if matches.length == 1
246
+ return ambiguous(key, matches) if matches.length > 1
247
+ end
248
+
249
+ prefixed = files.select { |name| basename(name).downcase.start_with?("#{key.downcase}-") }
250
+ return prefixed.first if prefixed.length == 1
251
+ return ambiguous(key, prefixed) if prefixed.length > 1
252
+
253
+ nil
254
+ end
255
+
256
+ def match_artifact_file(files, key, version, locale, artifact)
257
+ candidates = ["#{key}-#{version}-#{locale}", "#{key}-#{version}", "#{key}.#{locale}", key]
258
+
259
+ candidates.each do |candidate|
260
+ pattern = /\A#{Regexp.escape(candidate)}[.-]#{Regexp.escape(artifact)}(?:\..+)?\z/i
261
+ matches = files.grep(pattern)
262
+ return matches.first if matches.length == 1
263
+ return ambiguous("#{key}:#{artifact}", matches) if matches.length > 1
264
+ end
265
+
266
+ nil
267
+ end
268
+
269
+ def ambiguous(key, matches)
270
+ @ambiguous_documents[key] = matches.sort
271
+ nil
272
+ end
273
+
274
+ def basename(name) = File.basename(name, File.extname(name))
275
+
276
+ def ambiguous_documents = @ambiguous_documents ||= {}
277
+
278
+ # --- Reporting ------------------------------------------------------------
279
+
280
+ def report(receipt, result, sources)
281
+ say("Receipt #{receipt["event_id"]} (schema #{result.schema || receipt["schema"]})")
282
+ say
283
+
284
+ Array(result.checks).each { |check| say(" #{SYMBOLS.fetch(status(check), "?")} #{describe(check)}") }
285
+
286
+ say
287
+ sources.each { |key, name| say(" document #{key} read from #{name}") }
288
+ say(" no document files were supplied, so no document digest was checked") if sources.empty?
289
+
290
+ ambiguous_documents.each do |key, matches|
291
+ say(" ! document #{key} was NOT checked: #{matches.join(" and ")} both match that key.")
292
+ say(" Leave exactly one file per document so the check reads what you meant.")
293
+ end
294
+
295
+ say
296
+ Array(result.failures).each { |failure| say(" #{failure}") } if result.failed?
297
+ say case result.status
298
+ when "verified" then "VERIFIED — #{summary(result)}."
299
+ when "incomplete" then "INCOMPLETE — #{summary(result)}; supply the missing artifacts and run again."
300
+ else "FAILED — #{summary(result)}; see the failing checks above."
301
+ end
302
+ say
303
+ say("A passing digest detects modification of the bytes it covers. It does not establish who")
304
+ say("produced them, when, or that whoever controlled the original application and its database")
305
+ say("could not have written both the record and the digest.")
306
+ end
307
+
308
+ def report_as_json(receipt, result, sources)
309
+ say(
310
+ JSON.pretty_generate(
311
+ "event_id" => receipt["event_id"],
312
+ "schema" => result.schema || receipt["schema"],
313
+ "success" => result.success?,
314
+ "status" => result.status,
315
+ "checks" => Array(result.checks).map do |check|
316
+ { "name" => value(check, :name), "passed" => status(check), "detail" => value(check, :detail) }
317
+ end,
318
+ "failures" => Array(result.failures).map(&:to_s),
319
+ "documents_read" => sources,
320
+ "verifier_version" => Clickwrap::VERIFIER_VERSION
321
+ )
322
+ )
323
+ end
324
+
325
+ def summary(result)
326
+ checks = Array(result.checks)
327
+ passed = checks.count { |check| status(check) == true }
328
+ skipped = checks.count { |check| status(check).nil? }
329
+ sentence = "#{passed} of #{checks.length} checks passed"
330
+
331
+ skipped.zero? ? sentence : "#{sentence}, #{skipped} could not be run"
332
+ end
333
+
334
+ def describe(check)
335
+ detail = value(check, :detail)
336
+ name = value(check, :name)
337
+
338
+ detail.to_s.empty? ? name.to_s : "#{name}: #{detail}"
339
+ end
340
+
341
+ # true (checked and passed), false (checked and failed), nil (not checked).
342
+ def status(check)
343
+ passed = value(check, :passed)
344
+ return nil if passed.nil?
345
+
346
+ passed == true
347
+ end
348
+
349
+ # Check entries may arrive with symbol or string keys, or as objects that
350
+ # answer the same names. Reading all three costs four lines and saves the
351
+ # command from breaking on a detail nobody should have to think about.
352
+ def value(check, name)
353
+ return check.public_send(name) if check.respond_to?(name) && !check.is_a?(Hash)
354
+ return check[name] if check.respond_to?(:key?) && check.key?(name)
355
+ return check[name.to_s] if check.respond_to?(:key?) && check.key?(name.to_s)
356
+
357
+ nil
358
+ end
359
+
360
+ def verifier
361
+ require "clickwrap/receipt_verifier"
362
+ Clickwrap::ReceiptVerifier
363
+ rescue LoadError => error
364
+ raise Clickwrap::Error, "This build of clickwrap has no receipt verifier available (#{error.message})."
365
+ end
366
+
367
+ def say(text = "") = $stdout.puts(text)
368
+
369
+ # Anything that is not the report itself goes to standard error, so
370
+ # `clickwrap verify ... > result.txt` captures the result and nothing else.
371
+ def warn(text) = Kernel.warn(text)
372
+ end
373
+
374
+ exit(ClickwrapCommandLine.run(ARGV))
data/guides/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # Clickwrap guides
2
+
3
+ The [README](../README.md) is the tour: it gets you from install to a verified receipt and
4
+ shows each capability briefly. These guides are the depth behind the parts that are easy to
5
+ get subtly wrong, and they assume you have already read the README section they expand on.
6
+
7
+ | Guide | Read it when |
8
+ |---|---|
9
+ | [Integrating](integrating.md) | When you are wiring the gem into a real application — or pointing an AI agent at the job. The battle-tested playbook from a full production migration: install order, real legal content, test setup, Devise bridges, custom surfaces, protecting a money path, importing history, and the dual-write rollout doctrine. |
10
+ | [Request evidence](request-evidence.md) | Before you enable IP address, browser user-agent, or any IP-geolocation field. It is the data dictionary: one row per field, where it comes from, what it does not establish, who can read it, and what happens to it when it is deleted. |
11
+ | [Receipts and verification](receipts-and-verification.md) | When you need to hand a receipt to somebody outside your application, or explain exactly what a green verification result covers. Also the canonicalization profile, if you are writing a verifier of your own. |
12
+ | [Retention and legal holds](retention-and-legal-holds.md) | When your retention periods come from a real obligation rather than a round number, when a duration cannot express the schedule, or before the first time you run a disposition against production data. |
13
+ | [Integrity](integrity.md) | When someone asks how strong the audit trail is, or which of the five tiers you are actually on. Includes the threat model as a list of "what happens if" scenarios. |
14
+ | [Consent and lifecycle](consent-and-lifecycle.md) | When you are choosing between `agree_to`, `acknowledge`, and `consent_to` for a specific screen, or when you need the full state and action table for a kind. |
15
+ | [Migrating](migrating.md) | When you have an `accepted_terms_at` column or a FinePrint installation and you want the history without inventing the parts of it nobody recorded. |
16
+ | [Accessibility](accessibility.md) | Before your accessibility review, so you know exactly which line is the reference views' responsibility and which is your page's. |
17
+ | [Naming](naming.md) | Before you propose a public method, option, configuration setting, or receipt field — or before you review a pull request that adds one. |
18
+ | [Organizations](organizations.md) | When a human user accepts or authorizes something on behalf of an organization. Separates actor, represented party, tenant, subject, membership evidence, and the legal-authority boundary. |
19
+
20
+ Two things hold across all of them.
21
+
22
+ **Clickwrap is evidence mechanics.** It records what was offered, what was answered, and what
23
+ committed alongside it. Your application and its counsel own the words, the lawful basis, the
24
+ retention periods, the identity questions, and every legal conclusion. Nothing in these guides
25
+ is advice about any of that.
26
+
27
+ **Every external claim here carries an exact URL and a source class.** Law, court decisions,
28
+ regulator guidance, technical standards, vendor documentation, and this project's own design
29
+ inferences are labeled separately, because they carry very different weight. Source-code
30
+ citations are pinned to commit `a1ffe9b` of this repository rather than to a moving branch.
@@ -0,0 +1,249 @@
1
+ # Accessibility: what the reference views do, and what stays yours
2
+
3
+ Clickwrap ships tested reference views. They are a good starting point and they are one
4
+ fragment of one page. **Nothing in this gem certifies your application under
5
+ [WCAG 2.2](https://www.w3.org/TR/WCAG22/) *(technical standard)* or any other accessibility
6
+ standard**, and no library that sees a single partial could. Accessibility applies to the whole
7
+ experience: placement, contrast, clutter, reading order, focus management across the page,
8
+ error recovery, and whether the surrounding design lets somebody find the control at all.
9
+
10
+ What follows is the exact division of responsibility, so your review can spend its time on the
11
+ part that is actually yours.
12
+
13
+ ---
14
+
15
+ ## What the reference views do
16
+
17
+ All of this is in `app/views/clickwrap/shared/_fields.html.erb`,
18
+ `_statement.html.erb`, and `_error_summary.html.erb`, and is exercised by
19
+ the gem's own suite.
20
+
21
+ ### Labels and programmatic names
22
+
23
+ Every control has one label, tied together by ID, and the label carries the words:
24
+
25
+ ```erb
26
+ <%= check_box_tag combined.control_name, "1", false, id: combined.control_id, ... %>
27
+ <%= label_tag combined.control_id, clickwrap_combined_sentence(combined) %>
28
+ ```
29
+
30
+ The label **is** the sentence — including the document links, which sit inside it. Pressing the
31
+ words toggles the control, and assistive technology announces the sentence and the box together.
32
+ There is no `aria-label` standing in for a visible label, and no placeholder doing a label's job.
33
+
34
+ For an explicit yes/no decision, the group is a real `<fieldset>` with a `<legend>` carrying the
35
+ assertion, and each radio has its own `<label>`.
36
+
37
+ ### The default is one line
38
+
39
+ An ordinary signup renders one checkbox carrying one sentence:
40
+
41
+ > ☐ I agree to the [Terms of Service](#) and I acknowledge the [Privacy Policy](#).
42
+
43
+ That is a deliberate accessibility decision as much as a visual one. Two boxes, two "Required"
44
+ flags, two version labels, and four stacked links are four times the interface for the same
45
+ decision — and everything a screen-reader user has to hear before reaching the button is
46
+ something a sighted user gets to skip.
47
+
48
+ ### One control never covers two *different kinds of* answer
49
+
50
+ The composed line is one control answering several statements, and it is allowed to be, because
51
+ the server signs which statements it covered and answers all of them from the one box. What it
52
+ may never absorb is an answer somebody could reasonably want to give differently: an optional
53
+ consent (which the line would silently make required), a recorded yes/no, a purpose with a
54
+ withdrawal route, or copy the application wrote itself. Each of those keeps its own control below
55
+ the line — and the development linter flags
56
+ `combined_statement_rendered_as_its_own_control` if a page offers a *second* control for a
57
+ statement the signed line already covers, because that box offers a choice nobody has.
58
+
59
+ ### Controls start unselected
60
+
61
+ There is no `checked` attribute anywhere in the statement partial, and there never will be. A
62
+ pre-ticked box records the page's default rather than a person's action. The development linter
63
+ flags `consent_control_preselected` if it finds one in rendered output.
64
+
65
+ ### Focus is visible and keyboard operation works
66
+
67
+ The shipped stylesheet defines a visible focus indicator rather than removing the browser's.
68
+ Every control is a native input; nothing is a `div` with a click handler. Tab order is document
69
+ order because the markup is in reading order.
70
+
71
+ ### Error relationships are programmatic
72
+
73
+ When a statement fails validation, its control gets `aria-invalid="true"` and
74
+ `aria-describedby` pointing at the paragraph carrying the message. The paragraph has the
75
+ matching `id`. The `fieldset` gets the same treatment for choice groups.
76
+
77
+ ### There is an error summary, and it takes focus
78
+
79
+ ```erb
80
+ <div class="clickwrap-error-summary" role="alert" tabindex="-1" autofocus>
81
+ ```
82
+
83
+ `role="alert"` so assistive technology announces it when the failed submission re-renders,
84
+ `tabindex="-1"` so it can hold focus, and `autofocus` so the browser moves focus there on load —
85
+ **without a line of JavaScript**. Each entry is a link to the control it is about, so the fix is
86
+ one press away rather than a scroll and a hunt. One control gets one entry however many acts it
87
+ answered: three lines pointing at the same checkbox is a list of the page's internals, not of a
88
+ person's problems.
89
+
90
+ ### Meaning is never carried by color alone
91
+
92
+ Error messages carry a text prefix (`clickwrap.ui.error_prefix`) before the message. The styling
93
+ underlines and colors these; the meaning survives without either.
94
+
95
+ Nothing prints the word "Required" beside a control. The `required` attribute is there as
96
+ progressive enhancement, and **the server decides** either way — but a required control on a
97
+ signup form is not information anybody is missing, and a page that has to say it is a page
98
+ expecting to be argued with. An optional consent stays unlabelled for the same reason and
99
+ because it is unticked, unrequired, and separate: three signals that say it already.
100
+
101
+ ### It works with no JavaScript
102
+
103
+ No Stimulus controller is required for correctness. Validation, error rendering, focus on the
104
+ summary, and evidence capture all work in a browser that never runs a script. HTML `required`
105
+ is progressive enhancement only — **the server decides**, and a client that ignores the
106
+ attribute, never sends the field, or posts by hand meets the same server-side check.
107
+
108
+ ### Document links come before the action
109
+
110
+ Links to each document render above the submit control — inside the sentence on the composed
111
+ line, under the statement on an itemized one — carry the document's own name rather than "click
112
+ here", and open in a new tab with `rel="noopener"` so a half-filled form is not lost. A link
113
+ that only appears after the call to action has been pressed is not a link to anything.
114
+
115
+ The "opens in a new tab" hint is rendered as an `sr-only` span rather than visible text: a
116
+ sighted person gets their browser's own new-tab behavior and does not need it spelled out beside
117
+ every link, and a screen-reader user gets the words. It is still rendered **only** when the link
118
+ really does open a new tab, so hosts that change how links open —
119
+ `config.document_link_html_options_with`, or `config.hotwire_native_document_links` for a native
120
+ app — change the hint with them, and a `:same_screen` native link announces nothing it does not
121
+ do.
122
+
123
+ There is no version label beside a control. Versions are on the receipt, where somebody is
124
+ reading the record and can act on them.
125
+
126
+ ### Locale-aware selection, with no silent fallback
127
+
128
+ Human-facing text resolves to the requested locale before presentation, and a missing required
129
+ translation fails closed rather than rendering a raw I18n key, a blank, or an unexpected
130
+ language.
131
+
132
+ ---
133
+
134
+ ## What the host still owns
135
+
136
+ Everything below is outside what the gem can see, and all of it can defeat a correct partial.
137
+
138
+ | Yours | Why it matters |
139
+ |---|---|
140
+ | **Placement on the page** | A conspicuous control in a cluttered layout is not conspicuous. Where the block sits relative to the rest of the form is a design decision Clickwrap cannot make |
141
+ | **Contrast and type size** | The reference stylesheet is a starting point in your color system, not a contrast audit of it. Check the rendered values against your own palette |
142
+ | **The call-to-action wording** | You pass `submit:`. Whether that text tells the person what pressing it does is your judgment |
143
+ | **Reading order of the whole page** | The partial is in order internally. Whether the surrounding markup keeps it that way is not something a partial can control |
144
+ | **Page-level focus management** | The error summary takes focus on re-render. If your framework, modal, or Turbo Frame moves focus afterwards, that is yours to reconcile |
145
+ | **Zoom, reflow, and small viewports** | Test at 200% and 400%, and at 320 CSS pixels wide |
146
+ | **Motion, timeouts, and interruptions** | Presentation tokens are short-lived by default (`config.presentation_valid_for`, two hours). If a person needs longer than your timeout allows, that is your flow to fix |
147
+ | **The document itself** | Whether the Terms, notice, or declaration is readable — plain language, headings, structure — is content, and content is yours |
148
+ | **Native and API surfaces** | Hotwire Native web screens use the same component; a fully custom native or JSON presentation renders your own controls against the presenter's primitives, and inherits none of the above |
149
+ | **Ejected views** | `bin/rails generate clickwrap:views` copies these partials into your app, where they shadow the gem's. From that moment every property above is yours to keep |
150
+ | **Assistive-technology testing with real users** | No automated check substitutes for it |
151
+
152
+ ---
153
+
154
+ ## The linter is a heuristic, not a verdict
155
+
156
+ In development and test only, `Clickwrap::Linter` scans policies, manifests, and rendered
157
+ fragments for objectively checkable mistakes. It warns; it never raises, never blocks a render,
158
+ and never certifies anything. A clean run means "none of the specific hazards below were
159
+ detected in what was inspected," and nothing else.
160
+
161
+ | Finding | What it noticed |
162
+ |---|---|
163
+ | `submit_control_before_clickwrap_block` | A submit control appears above the statements it is supposed to accept |
164
+ | `consent_control_preselected` | A rendered control carries `checked` |
165
+ | `document_link_missing` | A statement cites a document that nothing links to |
166
+ | `assertion_text_blank` | A statement would render with no sentence |
167
+ | `consent_statement_bundles_purposes` | A consent assertion contains "and", "and/or", "as well as", or "plus" — probably two purposes in one control |
168
+ | `optional_consent_required_for_another_action` | An optional consent has been made mandatory in practice by another statement's `requires:` |
169
+ | `rendered_manifest_differs_from_policy` | What was rendered does not match what the policy declared |
170
+
171
+ Every finding carries a stable symbol so a test can assert on it without matching English, and a
172
+ full-sentence explanation so somebody reading the log knows what to do.
173
+
174
+ ---
175
+
176
+ ## Reviewer checklist
177
+
178
+ Run this against a real page in a real browser, not against the partial.
179
+
180
+ **Structure and naming**
181
+
182
+ - [ ] Every control has a visible label whose text is exactly what the server offered — the
183
+ composed sentence, or the statement's own assertion. Pressing the label text toggles the
184
+ control.
185
+ - [ ] No control covers a statement the signed presentation did not say it covers, and no
186
+ statement the composed line covers has a second control of its own.
187
+ - [ ] Choice groups are a `fieldset` with a `legend`.
188
+ - [ ] Every control has an accessible name in the accessibility tree — check it, do not assume.
189
+
190
+ **State**
191
+
192
+ - [ ] Every control renders unselected on first load and after a failed submission.
193
+ - [ ] Nothing on your surrounding page conveys "required" by color or an asterisk alone.
194
+
195
+ **Keyboard and focus**
196
+
197
+ - [ ] Every control, link, and the submit button is reachable and operable by keyboard alone.
198
+ - [ ] The focus indicator is visible against your actual background, at every step.
199
+ - [ ] Tab order matches visual order across the whole form, not just the Clickwrap block.
200
+
201
+ **Errors**
202
+
203
+ - [ ] Submit with nothing selected. An error summary appears, receives focus, and each entry
204
+ links to its control.
205
+ - [ ] Each failing control has `aria-invalid="true"` and `aria-describedby` pointing at a
206
+ message that exists.
207
+ - [ ] Error text is readable with color disabled or in grayscale.
208
+
209
+ **Documents**
210
+
211
+ - [ ] Every document is linked, above the submit control, with the document's own name.
212
+ - [ ] The `sr-only` "opens in a new tab" hint is announced — check it in a screen reader, not
213
+ only in the markup, and check that your own CSS has not turned `.clickwrap-sr-only` into
214
+ `display: none`, which would remove it from the accessibility tree entirely.
215
+ - [ ] Following a link and returning does not lose the form state.
216
+
217
+ **Without JavaScript**
218
+
219
+ - [ ] Disable JavaScript. Render, fail validation, correct, and submit successfully.
220
+ - [ ] The error summary still receives focus.
221
+
222
+ **Page level**
223
+
224
+ - [ ] At 400% zoom and at 320 CSS pixels wide, nothing overlaps and nothing is cut off.
225
+ - [ ] Contrast of label text, link text, error text, and the focus indicator meets your target
226
+ against your real palette.
227
+ - [ ] Screen-reader pass: the whole sentence, its document links, and any error are all
228
+ announced, in an order that makes sense — and a link inside the sentence does not break
229
+ the sentence into fragments that stop reading as one statement.
230
+ - [ ] The submit button's text says what pressing it does.
231
+
232
+ **Locale**
233
+
234
+ - [ ] Each supported locale renders, including the document links and version labels.
235
+ - [ ] A missing translation fails loudly in development rather than rendering a key.
236
+
237
+ None of the above certifies anything. It is the list of things that are cheap to check and
238
+ expensive to miss.
239
+
240
+ ---
241
+
242
+ ## Sources
243
+
244
+ | Source | Class |
245
+ |---|---|
246
+ | [WCAG 2.2](https://www.w3.org/TR/WCAG22/) | Technical standard |
247
+ | [15 U.S.C. § 7001(d)](https://www.law.cornell.edu/uscode/text/15/7001) — accurate reflection, accessibility, and reproducibility of electronic records | Law |
248
+ | [Berman v. Freedom Financial Network](https://cdn.ca9.uscourts.gov/datastore/opinions/2022/04/05/20-16900.pdf), [Toth v. Everly Well](https://www.ca1.uscourts.gov/sites/ca1/files/opnfiles/23-1727P-01A.pdf) — US appellate decisions assess conspicuous notice and unambiguous assent in the context of the whole interface; no control, color, or placement is a universal safe harbor | Cases |
249
+ | The division of responsibility and the checklist above | Product-design inference |