clickwrap 0.0.0 → 0.1.1

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 +650 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +245 -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 +154 -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 +187 -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 +729 -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,1287 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+
6
+ module Clickwrap
7
+ module Generators
8
+ # `rails generate clickwrap:install` — the adaptive migration, one annotated
9
+ # initializer, a conventional policy file, placeholder legal content, and an
10
+ # optional engine mount.
11
+ #
12
+ # Two things make this installer different from an ordinary one.
13
+ #
14
+ # First, it asks. Recording an IP address or a provider-estimated city is a
15
+ # decision with consequences for the people using the host application, so
16
+ # every request-evidence field is a separate question in plain English with
17
+ # the consequence stated before the choice — never a category enabled as a
18
+ # side effect of something else.
19
+ #
20
+ # Second, it refuses to guess. The actor class is inferred only when it is
21
+ # unambiguous, legal text is never invented, and no purpose, retention
22
+ # period or resolver is written on the host's behalf. Proxy provenance is
23
+ # derived from the effective Rails rules themselves rather than invented.
24
+ # An incomplete personal-data choice stops the generator before it writes a
25
+ # file.
26
+ #
27
+ # The only `--request-evidence-recipe` is `privacy-minimized`, a convenient
28
+ # spelling of the off-by-default posture. It disappears after generation;
29
+ # it is deliberately not a runtime concept. There is no
30
+ # `record_network_context`, `record_everything`, `full_evidence`, or
31
+ # regulation-named mode switch anywhere in this gem, because a flag that
32
+ # enables a whole category of personal data is the thing this gem exists not
33
+ # to do, and no flag can make a legal determination on a host's behalf.
34
+ class InstallGenerator < Rails::Generators::Base
35
+ include ActiveRecord::Generators::Migration
36
+
37
+ source_root File.expand_path("templates", __dir__)
38
+ desc "Install clickwrap: adaptive migration, annotated initializer, policy file, and placeholders"
39
+
40
+ # The adapters the gem's portable core behavior is tested against.
41
+ SUPPORTED_ADAPTERS = %w[sqlite sqlite3 postgresql postgis mysql mysql2 trilogy].freeze
42
+
43
+ RECIPES = %w[privacy-minimized].freeze
44
+
45
+ # Places applications already keep the legal text their public routes
46
+ # serve. Both documents must exist for a convention to count; order
47
+ # matters, so a Sitepress-style content directory wins over files a
48
+ # previous install of this generator wrote.
49
+ #
50
+ # `rendered_by_the_application` separates the two cases: the Sitepress
51
+ # directory holds pages the application itself renders and serves, which
52
+ # is what makes rendering Clickwrap's snapshot through the application's
53
+ # own Markdown pipeline the right default. The second convention is a
54
+ # previous install's placeholder files, which nothing else renders.
55
+ EXISTING_LEGAL_CONVENTIONS = [
56
+ { dir: "app/content/pages/legal", terms: "terms.html.md", privacy: "privacy.html.md",
57
+ rendered_by_the_application: true },
58
+ { dir: "app/content/legal", terms: "terms.md", privacy: "privacy.md",
59
+ rendered_by_the_application: false }
60
+ ].freeze
61
+
62
+ # The names the configuration uses, which are also the names this
63
+ # generator's one geolocation flag accepts. There used to be nine separate
64
+ # `class_option`s here, spelled in the plural, plus a table translating
65
+ # them back into the singular configuration names — twenty-odd lines of
66
+ # generator surface for one list.
67
+ IP_GEOLOCATION_FIELDS = Clickwrap::Vocabulary::IP_GEOLOCATION_DATA_FIELDS
68
+
69
+ # --- Request evidence: one explicit flag per field ------------------------
70
+ #
71
+ # No defaults are declared for these booleans on purpose: an absent key
72
+ # means "the operator did not choose", which is what lets an explicit
73
+ # `--no-record-...` override a recipe, and what stops the installer from
74
+ # asking a question that was already answered on the command line.
75
+
76
+ class_option :record_ip_addresses_by_default,
77
+ type: :boolean, desc: "Record the request IP address for every policy"
78
+ class_option :record_browser_user_agents_by_default,
79
+ type: :boolean, desc: "Record the browser User-Agent for every policy"
80
+ # One flag, one explicit allowlist. Naming a field here still does not
81
+ # enable a category as a side effect: each named field needs the same
82
+ # purpose, deletion rule, and resolver every other route to it needs, and
83
+ # a name that is not on the list stops the generator before it writes a
84
+ # file. The runtime keeps one setter per field — that is the posture a
85
+ # host reads and reviews; this is only the command line.
86
+ class_option :record_ip_geolocation_fields,
87
+ type: :array,
88
+ desc: "IP-geolocation fields to record by default, from: " \
89
+ "#{IP_GEOLOCATION_FIELDS.join(", ")}. Every one is an estimate for the " \
90
+ "IP address, never a person's physical location."
91
+
92
+ class_option :delete_recorded_ip_addresses_after_days,
93
+ type: :numeric, desc: "Delete recorded IP addresses after N days"
94
+ class_option :delete_recorded_browser_user_agents_after_days,
95
+ type: :numeric, desc: "Delete recorded browser User-Agent strings after N days"
96
+ class_option :delete_recorded_ip_geolocation_after_days,
97
+ type: :numeric, desc: "Delete recorded IP geolocation after N days"
98
+
99
+ class_option :reason_for_recording_ip_addresses_by_default,
100
+ type: :string,
101
+ desc: "Plain-English reason for recording IP addresses by default"
102
+ class_option :reason_for_recording_browser_user_agents_by_default,
103
+ type: :string,
104
+ desc: "Plain-English reason for recording browser User-Agent strings by default"
105
+ class_option :reason_for_recording_ip_geolocation_by_default,
106
+ type: :string,
107
+ desc: "Plain-English reason for recording IP geolocation by default"
108
+ class_option :trusted_proxy_configuration_digest,
109
+ type: :string,
110
+ desc: "Prefixed SHA-2 digest of the reviewed trusted-proxy configuration"
111
+ class_option :ip_geolocation_resolver_class_name,
112
+ type: :string,
113
+ desc: "Resolver class to instantiate when IP geolocation is enabled"
114
+
115
+ class_option :request_evidence_recipe,
116
+ type: :string, enum: RECIPES,
117
+ desc: "Use the privacy-minimized, all-fields-off scaffold. " \
118
+ "Generator-only: it does not survive as a runtime concept."
119
+
120
+ # --- Optional tables ------------------------------------------------------
121
+ #
122
+ # Seven of the seventeen tables this gem knows about cannot receive a row
123
+ # until a matching configuration is turned on, and every one of those is
124
+ # off by default. Emitting them all anyway makes an installation's schema
125
+ # claim capabilities and data categories that installation does not have.
126
+ #
127
+ # Each flag adds one migration, and re-running the installer later with
128
+ # the flag adds it then. `clickwrap:hardening` is the precedent: an opt-in
129
+ # generator, not a switch inside a migration nobody re-reads.
130
+ OPTIONAL_TABLE_MIGRATIONS = {
131
+ with_persisted_presentations: "create_clickwrap_presentation_tables",
132
+ with_request_evidence: "create_clickwrap_request_evidence_tables",
133
+ with_integrity: "create_clickwrap_integrity_tables",
134
+ with_retention_ops: "create_clickwrap_retention_tables",
135
+ with_external_actions: "create_clickwrap_external_action_tables"
136
+ }.freeze
137
+
138
+ # What each omitted flag would have added, in one line, for the
139
+ # post-install message.
140
+ OPTIONAL_TABLE_SUMMARIES = {
141
+ with_persisted_presentations:
142
+ "--with-persisted-presentations presentations retained before submission",
143
+ with_request_evidence:
144
+ "--with-request-evidence IP address / user-agent / geolocation annex",
145
+ with_integrity:
146
+ "--with-integrity event chaining, anchoring, timestamp attestations",
147
+ with_retention_ops:
148
+ "--with-retention-ops legal holds and reviewed disposition plans",
149
+ with_external_actions:
150
+ "--with-external-actions the outbox for authorize_external_action!"
151
+ }.freeze
152
+
153
+ class_option :with_persisted_presentations,
154
+ type: :boolean, default: false,
155
+ desc: "Add the table for policies that retain pre-submit presentations"
156
+ class_option :with_request_evidence,
157
+ type: :boolean, default: false,
158
+ desc: "Add the IP address / user-agent / geolocation annex table " \
159
+ "(implied when this run enables any of those fields)"
160
+ class_option :with_integrity,
161
+ type: :boolean, default: false,
162
+ desc: "Add the event-chain and anchoring/timestamp attestation tables"
163
+ class_option :with_retention_ops,
164
+ type: :boolean, default: false,
165
+ desc: "Add the legal-hold and reviewed-disposition tables"
166
+ class_option :with_external_actions,
167
+ type: :boolean, default: false,
168
+ desc: "Add the outbox table for Clickwrap.authorize_external_action!"
169
+
170
+ class_option :actor_class,
171
+ type: :string,
172
+ desc: "The model that can act (User, Account, Member…) when it can't be inferred"
173
+ class_option :skip_routes,
174
+ type: :boolean, default: false,
175
+ desc: "Don't offer to mount Clickwrap::Engine in config/routes.rb"
176
+ class_option :skip_questions,
177
+ type: :boolean, default: false,
178
+ desc: "Non-interactive: ask nothing and write the safe defaults"
179
+
180
+ def self.next_migration_number(dir)
181
+ ActiveRecord::Generators::Base.next_migration_number(dir)
182
+ end
183
+
184
+ # Everything this run decides about the host is resolved HERE, before a
185
+ # single file is written, and memoized. The steps below create
186
+ # config/initializers/clickwrap.rb, config/clickwrap.rb, and content files;
187
+ # re-reading the environment halfway through would answer a different
188
+ # question than the one this step asked, and the post-install message would
189
+ # describe an application that did not exist when the decisions were made.
190
+ def detect_environment!
191
+ database_adapter
192
+ primary_key_type
193
+ devise_detected?
194
+ rails_authentication_detected?
195
+ actor_class_name
196
+
197
+ say "\n☑️ Installing clickwrap.", :green
198
+ say " Database adapter: #{database_adapter || "not detected"}"
199
+ say " Primary key type: #{primary_key_type_description}"
200
+ say " Authentication: #{detected_authentication || "none detected (both integrations are optional)"}"
201
+ say " Actor class: #{actor_class_name || "not inferred — see the initializer"}"
202
+
203
+ return if actor_class_name
204
+
205
+ say "\n⚠️ Clickwrap could not infer your actor class unambiguously.", :yellow
206
+ say " #{actor_class_reason}", :yellow
207
+ say " `config.actor_class_name` is therefore left COMMENTED OUT in the", :yellow
208
+ say " generated initializer, with an explanation beside it. Which record", :yellow
209
+ say " can act is a security-relevant identity mapping, and a wrong guess", :yellow
210
+ say " attributes evidence to the wrong kind of record for years. Set it", :yellow
211
+ say " yourself, or re-run with --actor-class=YourModel.", :yellow
212
+ end
213
+
214
+ # The questions come verbatim from the request-evidence design: each one
215
+ # states the consequence before asking for the choice, because a developer
216
+ # who has not thought about IP addresses this week deserves the context in
217
+ # the question rather than in a document they will read later.
218
+ def ask_about_request_evidence
219
+ ask_request_evidence_questions unless skip_request_evidence_questions?
220
+ validate_request_evidence_choices!
221
+
222
+ # The summary prints in every mode, before any file is written, so the
223
+ # operator sees every enabled field, purpose, encryption choice,
224
+ # source posture, access behavior, and retention rule together.
225
+ summarize_request_evidence_choices
226
+ end
227
+
228
+ def create_migration_file
229
+ migration_template "create_clickwrap_tables.rb.erb",
230
+ File.join(db_migrate_path, "create_clickwrap_tables.rb")
231
+
232
+ requested_optional_table_migrations.each do |name|
233
+ migration_template "#{name}.rb.erb", File.join(db_migrate_path, "#{name}.rb")
234
+ end
235
+ end
236
+
237
+ def create_initializer
238
+ template "initializer.rb.erb", "config/initializers/clickwrap.rb"
239
+ end
240
+
241
+ def create_policy_file
242
+ template "clickwrap_policies.rb.erb", "config/clickwrap.rb"
243
+ end
244
+
245
+ # Placeholders only, and only when the application has no legal text
246
+ # anywhere Clickwrap recognizes. Clickwrap never invents legal text: the
247
+ # words are the host's, reviewed by the host's counsel, and a gem that
248
+ # shipped plausible-looking Terms would be inviting an application to
249
+ # publish text nobody read.
250
+ #
251
+ # When the app ALREADY serves legal pages (a Sitepress content directory,
252
+ # or a previous install's files), the generated config points at those
253
+ # exact files instead of writing a second set: what people accept must be
254
+ # the same bytes the public legal routes render, and two copies of the
255
+ # Terms is how they silently stop being the same document.
256
+ def create_legal_content_placeholders
257
+ if detected_legal_documents
258
+ say_status :found, "existing legal pages in #{detected_legal_documents[:dir]} — " \
259
+ "config/clickwrap.rb points at them; no placeholders written", :green
260
+ return
261
+ end
262
+
263
+ write_legal_placeholder "terms.md.erb", "app/content/legal/terms.md"
264
+ write_legal_placeholder "privacy.md.erb", "app/content/legal/privacy.md"
265
+ end
266
+
267
+ def mount_engine
268
+ return if options[:skip_routes]
269
+ return say_missing_routes_file unless routes_file?
270
+ return say_already_mounted if engine_already_mounted?
271
+ return mount_engine_without_asking unless interactive?
272
+
273
+ return unless ask_question(<<~QUESTION)
274
+ Mount the Clickwrap engine at "/agreements" in config/routes.rb?
275
+ It adds actor-owned capture, receipt, consent-withdrawal, and
276
+ document-history screens using your parent controller, layout, locale,
277
+ and authorization callbacks — so a required agreement can be completed
278
+ in place instead of becoming a dead end. Nothing is exposed publicly:
279
+ access still goes through your own authorization callbacks. [y/N]
280
+ QUESTION
281
+
282
+ route "mount Clickwrap::Engine => \"/agreements\""
283
+ @mounted_engine = true
284
+ end
285
+
286
+ def print_unsupported_adapter_notes
287
+ return if supported_adapter?
288
+
289
+ say "\n⚠️ #{database_adapter || "Your database adapter"} is outside the set clickwrap tests.", :yellow
290
+ say " Tested: SQLite, PostgreSQL, and MySQL. On anything else these are", :yellow
291
+ say " unverified rather than known-broken, and worth checking yourself:", :yellow
292
+ say " • the JSON/JSONB column types and their defaults in the migration;", :yellow
293
+ say " • long document bodies (a silently truncated agreement is the", :yellow
294
+ say " worst failure this gem has);", :yellow
295
+ say " • whether the unique index on (policy_key, idempotency_key) really", :yellow
296
+ say " rejects a duplicate submit under concurrency; and", :yellow
297
+ say " • `rails generate clickwrap:hardening --database`, whose update and", :yellow
298
+ say " delete protections are written for PostgreSQL only.", :yellow
299
+ end
300
+
301
+ def display_post_install_message
302
+ say "\n☑️ The `clickwrap` gem has been installed.", :green
303
+ say "\nTo complete the setup:"
304
+
305
+ step = 0
306
+ say " #{step += 1}. Run 'rails db:migrate' to create the clickwrap tables."
307
+ say " ⚠️ You must run migrations before starting your app!", :yellow
308
+ say_optional_tables
309
+
310
+ say " #{step += 1}. Declare which records can act:"
311
+ say " class #{actor_class_name || "User"} < ApplicationRecord"
312
+ say " has_clickwraps"
313
+ say " end"
314
+ say " (and set `config.actor_class_name` in the initializer)" unless actor_class_name
315
+
316
+ if detected_legal_documents
317
+ say " #{step += 1}. Your existing legal pages are the documents (same bytes people"
318
+ say " read and accept):"
319
+ say " #{terms_document_path}"
320
+ say " #{privacy_document_path}"
321
+ if documents_needing_an_explicit_version.empty?
322
+ say " Each page names its own version in its front matter, so config/clickwrap.rb"
323
+ say " declares both without a `version:` line."
324
+ else
325
+ say " A page that names its own version in its front matter is declared in"
326
+ say " config/clickwrap.rb without a `version:` line."
327
+ say_documents_needing_an_explicit_version
328
+ end
329
+ else
330
+ say " #{step += 1}. Replace the placeholder legal text with your own reviewed documents:"
331
+ say " #{terms_document_path}"
332
+ say " #{privacy_document_path}"
333
+ say " Keep the `last_updated:` front matter at the top of each file accurate:"
334
+ say " that line is the version label, so a text change is one edit in one file."
335
+ end
336
+
337
+ say " #{step += 1}. Publish immutable snapshots:"
338
+ say " bin/rails clickwrap:publish"
339
+ say " Deploys do this for you — publishing rides `db:prepare`, so a snapshot"
340
+ say " exists before the server takes traffic."
341
+
342
+ say " #{step += 1}. Render the policy and its bound submit action:"
343
+ say " <%= form.clickwrap :signup, submit: \"Create account\" %>"
344
+ say " That renders ONE line — a checkbox and a sentence with your documents"
345
+ say " linked inside it:"
346
+ say " [ ] I agree to the Terms and I acknowledge the Privacy Notice."
347
+ say " Point each document at the page people read it on, and the links in that"
348
+ say " sentence go there:"
349
+ say " Clickwrap.document :terms, from: ..., link: \"/legal/terms\""
350
+
351
+ step = say_the_authentication_door_step(step)
352
+
353
+ say " #{step += 1}. Set up your test suite (presentations refuse unpublished documents"
354
+ say " in tests exactly as in production):"
355
+ say " # test/test_helper.rb"
356
+ say " class ActiveSupport::TestCase"
357
+ say " include Clickwrap::TestHelpers"
358
+ say " parallelize_setup { Clickwrap.publish! } # per parallel worker..."
359
+ say " end"
360
+ say " Clickwrap.publish! # ...and once per process"
361
+
362
+ unless @mounted_engine
363
+ say " #{step += 1}. Mount the standalone capture/receipt/withdrawal screens when you want them:"
364
+ say " # config/routes.rb"
365
+ say " mount Clickwrap::Engine => \"/agreements\""
366
+ end
367
+
368
+ if any_ip_geolocation_field?
369
+ say " #{step + 1}. Ensure #{ip_geolocation_resolver_class_name} and its data source", :yellow
370
+ say " are available in every environment. The initializer uses the resolver you", :yellow
371
+ say " explicitly selected; Clickwrap refuses to boot with fields it cannot resolve.", :yellow
372
+ end
373
+
374
+ print_review_checklist
375
+ end
376
+
377
+ private
378
+
379
+ # Named out loud, because a schema that quietly contains seven tables an
380
+ # installation can never write to is a schema that claims capabilities and
381
+ # data categories that installation does not have. Saying which ones were
382
+ # left out — and that one flag brings each back later — is what makes the
383
+ # omission a decision rather than a surprise.
384
+ def say_optional_tables
385
+ written = requested_optional_table_migrations
386
+ omitted = OPTIONAL_TABLE_MIGRATIONS.reject { |_option, name| written.include?(name) }
387
+ return if omitted.empty?
388
+
389
+ say " Emitted #{written.length + 1} migration#{"s" unless written.empty?}. Not emitted, " \
390
+ "because nothing in this install can write to them:"
391
+ omitted.each_key { |option| say " #{OPTIONAL_TABLE_SUMMARIES.fetch(option)}" }
392
+ say " Add any of them later by re-running this generator with the flag."
393
+ end
394
+
395
+ # Which optional migrations this run writes. The request-evidence annex is
396
+ # implied whenever the run turns a request-evidence field on: an
397
+ # installation that records IP addresses into a table it never created is
398
+ # not a schema choice, it is a broken install, and the operator already
399
+ # answered the question that matters.
400
+ def requested_optional_table_migrations
401
+ OPTIONAL_TABLE_MIGRATIONS.filter_map do |option, name|
402
+ next name if options[option]
403
+ next name if option == :with_request_evidence && any_request_evidence_field?
404
+
405
+ nil
406
+ end
407
+ end
408
+
409
+ def any_request_evidence_field?
410
+ record_ip_addresses? || record_browser_user_agents? || any_ip_geolocation_field?
411
+ end
412
+
413
+ # The step nothing else can stand in for. `form.clickwrap` renders the
414
+ # policy and binds the submit action, but the form is only half the
415
+ # circuit: some line in the signup door has to be the one that creates
416
+ # the account and writes the evidence in the same transaction. Leave it
417
+ # out and everything still LOOKS right — the checkbox renders, the person
418
+ # ticks it, the account is created — and there is no evidence at all.
419
+ #
420
+ # This is the one instruction whose omission is silent, so it is printed
421
+ # in every run, with the host's own file path and class name filled in.
422
+ def say_the_authentication_door_step(step)
423
+ say " #{step += 1}. Record the acceptance in the door that creates the account."
424
+ say " ⚠️ Nothing else does this. Without the line below the form still", :yellow
425
+ say " renders and the box still ticks, and every account is created with", :yellow
426
+ say " no evidence — silently.", :yellow
427
+
428
+ say_devise_door_step if devise_detected?
429
+ say_generic_door_step
430
+
431
+ step
432
+ end
433
+
434
+ def say_devise_door_step
435
+ say " # #{devise_registrations_controller_path}"
436
+ say " class #{devise_registrations_controller_class_name} < Devise::RegistrationsController"
437
+ say " clickwraps_registration_with :signup"
438
+ say " end"
439
+ say " # config/routes.rb — Devise has to route at that subclass:"
440
+ say " devise_for :#{devise_scope_name}, controllers: " \
441
+ "{ registrations: \"#{devise_registrations_controller_path_for_routes}\" }"
442
+ end
443
+
444
+ def say_generic_door_step
445
+ say " # Rails authentication, an OAuth finish screen, a service object —"
446
+ say " # any door that builds the record itself:"
447
+ say " unless register_with_clickwrap(:signup, user: @user) { @user.save! }"
448
+ say " return render :new, status: :unprocessable_entity"
449
+ say " end"
450
+ end
451
+
452
+ # Devise's own convention scopes the registrations controller under the
453
+ # plural of the mapped model, so a `User` mapping is served by
454
+ # Users::RegistrationsController.
455
+ def devise_scope_name
456
+ (actor_class_name || "User").underscore.tr("/", "_").pluralize
457
+ end
458
+
459
+ def devise_registrations_controller_class_name
460
+ "#{(actor_class_name || "User").pluralize}::RegistrationsController"
461
+ end
462
+
463
+ def devise_registrations_controller_path_for_routes
464
+ "#{(actor_class_name || "User").underscore.pluralize}/registrations"
465
+ end
466
+
467
+ def devise_registrations_controller_path
468
+ "app/controllers/#{devise_registrations_controller_path_for_routes}_controller.rb"
469
+ end
470
+
471
+ def migration_version
472
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
473
+ end
474
+
475
+ # Said only when a detected page could not name its own version. What was
476
+ # generated boots — it carries an explicit label — but the label now lives
477
+ # somewhere other than the text it describes, and that is exactly the pair
478
+ # that drifts apart the next time someone edits the page in a hurry.
479
+ def say_documents_needing_an_explicit_version
480
+ paths = documents_needing_an_explicit_version
481
+ return if paths.empty?
482
+
483
+ say " ⚠️ #{paths.join(" and ")}", :yellow
484
+ say " #{paths.one? ? "has" : "have"} no `clickwrap_version:` or `last_updated:` " \
485
+ "front-matter key, so", :yellow
486
+ say " config/clickwrap.rb carries an explicit `version: \"#{Date.today.iso8601}\"` " \
487
+ "for #{paths.one? ? "it" : "them"}.", :yellow
488
+ say " Move that label into the page's own front matter and delete the line, so", :yellow
489
+ say " the text names its version and there is only one copy of the label.", :yellow
490
+ end
491
+
492
+ # --- The review checklist -------------------------------------------------
493
+
494
+ # Deliberately a list of things a human still has to do. The installer
495
+ # copied files; it did not review anything, and it is not in a position to
496
+ # tell anyone their agreements are in order.
497
+ def print_review_checklist
498
+ say "\nBefore this carries any weight, review — with counsel where that applies:"
499
+ if detected_legal_documents
500
+ say " ☐ Legal text — the Terms and Privacy Notice are yours; the gem points at your existing pages."
501
+ else
502
+ say " ☐ Legal text — the Terms and Privacy Notice are yours; the gem wrote placeholders."
503
+ end
504
+ say " ☐ Policy semantics — is each act the right verb? agree_to for terms, acknowledge"
505
+ say " for a notice, consent_to only where consent is genuinely your chosen basis,"
506
+ say " declare/attest for statements of fact, authorize for one protected action."
507
+ say " ☐ Lawful basis and, where applicable, a data-protection impact assessment for"
508
+ say " every request-evidence field you enabled."
509
+ say " ☐ Retention periods — the generated ones are placeholders, not recommendations."
510
+ say " ☐ Your privacy notice — does it describe what you now record, and for how long?"
511
+ say " ☐ Trusted proxies — Clickwrap reads request.remote_ip; if you record IP addresses,"
512
+ say " verify and TEST your ActionDispatch::RemoteIp configuration behind your"
513
+ say " load balancer or CDN, or you are recording a header someone else controls."
514
+ say " ☐ The whole page — placement, wording, contrast, the call to action, and"
515
+ say " accessibility of the screen your controls appear on. Clickwrap renders the"
516
+ say " controls; the page around them is yours."
517
+ say " ☐ Styles — add <%= stylesheet_link_tag \"clickwrap\" %> to the layouts that render"
518
+ say " clickwrap blocks (the engine's own screens emit their styles themselves)."
519
+ say " Every rule is scoped under .clickwrap, so it cannot repaint the rest of your"
520
+ say " page. Rather own the CSS? `rails generate clickwrap:views` ejects the templates."
521
+ say " ☐ Tests — a real signup, a refused submit, and a forced evidence-write failure"
522
+ say " that proves the account is not created without its evidence."
523
+ say "\nClickwrap records evidence mechanics and keeps them verifiable. The words, the"
524
+ say "lawful basis, the periods, and the legal judgment stay yours.\n", :green
525
+ end
526
+
527
+ # --- Detection ------------------------------------------------------------
528
+
529
+ def database_adapter
530
+ return @database_adapter if defined?(@database_adapter)
531
+
532
+ @database_adapter = ActiveRecord::Base.connection_db_config.adapter.to_s
533
+ rescue StandardError
534
+ @database_adapter = nil
535
+ end
536
+
537
+ def supported_adapter?
538
+ SUPPORTED_ADAPTERS.include?(database_adapter.to_s.downcase)
539
+ end
540
+
541
+ # The same setting `rails g model` reads, so an application generated with
542
+ # `g.orm :active_record, primary_key_type: :uuid` gets uuid clickwrap
543
+ # tables and uuid foreign keys without being asked.
544
+ def primary_key_type
545
+ return @primary_key_type if defined?(@primary_key_type)
546
+
547
+ generators = Rails.configuration.generators
548
+ @primary_key_type = generators.options[generators.orm][:primary_key_type]
549
+ rescue StandardError
550
+ @primary_key_type = nil
551
+ end
552
+
553
+ def primary_key_type_description
554
+ primary_key_type ? primary_key_type.to_s : "integer (the Rails default)"
555
+ end
556
+
557
+ def devise_detected?
558
+ return @devise_detected if defined?(@devise_detected)
559
+
560
+ @devise_detected = defined?(::Devise) ? true : false
561
+ end
562
+
563
+ # The class names behind the host's `devise_for` scopes, as strings. Never
564
+ # constantized: a generator has no business coupling itself to the host's
565
+ # model boot order.
566
+ def devise_actor_class_names
567
+ return @devise_actor_class_names if defined?(@devise_actor_class_names)
568
+ return @devise_actor_class_names = [] unless devise_detected?
569
+
570
+ @devise_actor_class_names = ::Devise.mappings.values.map { |mapping| mapping.class_name.to_s }.uniq.sort
571
+ rescue StandardError
572
+ @devise_actor_class_names = []
573
+ end
574
+
575
+ # Rails 8's `bin/rails generate authentication` writes both an
576
+ # `app/models/session.rb` and an `Authentication` concern. Requiring both
577
+ # keeps an unrelated Session model in an older application from being read
578
+ # as omakase authentication.
579
+ def rails_authentication_detected?
580
+ return @rails_authentication_detected if defined?(@rails_authentication_detected)
581
+
582
+ @rails_authentication_detected =
583
+ host_file?("app/models/session.rb") && host_file?("app/controllers/concerns/authentication.rb")
584
+ end
585
+
586
+ def detected_authentication
587
+ return @detected_authentication if defined?(@detected_authentication)
588
+
589
+ detected = []
590
+ detected << "Rails authentication" if rails_authentication_detected?
591
+ detected << "Devise" if devise_detected?
592
+ @detected_authentication = detected.any? ? detected.join(" + ") : nil
593
+ end
594
+
595
+ def actor_class_name
596
+ return @actor_class_name if defined?(@actor_class_name)
597
+
598
+ @actor_class_name, @actor_class_reason = infer_actor_class
599
+ @actor_class_name
600
+ end
601
+
602
+ def actor_class_reason
603
+ actor_class_name
604
+ @actor_class_reason
605
+ end
606
+
607
+ # The reason travels into the generated initializer as a comment, so it is
608
+ # wrapped rather than left as one long line in someone else's file.
609
+ def actor_class_reason_comment
610
+ lines = actor_class_reason.to_s.split.each_with_object([""]) do |word, wrapped|
611
+ if wrapped.last.empty?
612
+ wrapped[-1] = word
613
+ elsif wrapped.last.length + word.length + 1 <= 72
614
+ wrapped[-1] = "#{wrapped.last} #{word}"
615
+ else
616
+ wrapped << word
617
+ end
618
+ end
619
+
620
+ lines.map { |line| " # #{line}" }.join("\n")
621
+ end
622
+
623
+ # Inference stops at the first ambiguity. "Probably User" is not a good
624
+ # enough answer for the question "whose agreement is this".
625
+ def infer_actor_class
626
+ explicit = options[:actor_class].to_s.strip
627
+ return [explicit, nil] unless explicit.empty?
628
+
629
+ names = devise_actor_class_names
630
+
631
+ if names.length > 1
632
+ return [nil, "Devise maps several models (#{names.join(", ")}), so there is no single " \
633
+ "conventional actor to choose."]
634
+ end
635
+
636
+ return [names.first, nil] if names.length == 1
637
+ return ["User", nil] if user_model?
638
+
639
+ [nil, "No `User` model was found, and nothing else identified itself as the conventional actor."]
640
+ end
641
+
642
+ def user_model?
643
+ host_file?("app/models/user.rb") || (defined?(::User) ? true : false)
644
+ end
645
+
646
+ def current_actor_method_name
647
+ return "current_user" unless actor_class_name
648
+
649
+ "current_#{actor_class_name.underscore.tr("/", "_")}"
650
+ end
651
+
652
+ # Whether what we detected is what the gem already assumes. When it is,
653
+ # the initializer shows the setting commented with its value rather than
654
+ # writing a live line that changes nothing: a file full of restated
655
+ # defaults reads as a file full of decisions, and then nobody can tell
656
+ # which lines were actually chosen.
657
+ def default_identity_settings?
658
+ actor_class_name == "User" && current_actor_method_name == "current_user"
659
+ end
660
+
661
+ def host_file?(path)
662
+ File.exist?(File.expand_path(path, destination_root))
663
+ end
664
+
665
+ def detected_legal_documents
666
+ return @detected_legal_documents if defined?(@detected_legal_documents)
667
+
668
+ @detected_legal_documents = EXISTING_LEGAL_CONVENTIONS.find do |convention|
669
+ host_file?(File.join(convention[:dir], convention[:terms])) &&
670
+ host_file?(File.join(convention[:dir], convention[:privacy]))
671
+ end
672
+ end
673
+
674
+ # The paths the generated config's `from:` lines point at — the app's own
675
+ # legal files when they exist, the freshly written placeholders otherwise.
676
+ def terms_document_path
677
+ convention = detected_legal_documents
678
+ convention ? File.join(convention[:dir], convention[:terms]) : "app/content/legal/terms.md"
679
+ end
680
+
681
+ def privacy_document_path
682
+ convention = detected_legal_documents
683
+ convention ? File.join(convention[:dir], convention[:privacy]) : "app/content/legal/privacy.md"
684
+ end
685
+
686
+ # A document declared without `version:` reads its label from the file's
687
+ # own leading front matter, which is where a version label belongs: in the
688
+ # file that IS the legal text, with nothing to drift against. The
689
+ # placeholders this installer writes carry one.
690
+ #
691
+ # An application's existing legal pages may not, and a versionless
692
+ # declaration over a page with no version key fails the next boot. So the
693
+ # generated declaration for THAT document carries an explicit label, plus
694
+ # the one line telling a developer where it really belongs.
695
+ def terms_document_names_its_own_version?
696
+ document_names_its_own_version?(terms_document_path)
697
+ end
698
+
699
+ def privacy_document_names_its_own_version?
700
+ document_names_its_own_version?(privacy_document_path)
701
+ end
702
+
703
+ def documents_needing_an_explicit_version
704
+ {
705
+ terms_document_path => terms_document_names_its_own_version?,
706
+ privacy_document_path => privacy_document_names_its_own_version?
707
+ }.reject { |_path, names_its_own| names_its_own }.keys
708
+ end
709
+
710
+ def document_names_its_own_version?(path)
711
+ # The placeholders this installer writes open with front matter, so a
712
+ # fresh install always has its version labels in the files themselves.
713
+ return true unless detected_legal_documents
714
+
715
+ @document_names_its_own_version ||= {}
716
+ return @document_names_its_own_version[path] if @document_names_its_own_version.key?(path)
717
+
718
+ @document_names_its_own_version[path] = front_matter_version_label_in(path).present?
719
+ end
720
+
721
+ # An unreadable file answers the same way a file with no version key
722
+ # does. Writing the explicit label is the recoverable mistake; leaving a
723
+ # declaration that cannot resolve a version is a boot failure.
724
+ def front_matter_version_label_in(path)
725
+ Clickwrap::FrontMatter.version_label_in(
726
+ File.read(File.expand_path(path, destination_root))
727
+ )
728
+ rescue StandardError
729
+ nil
730
+ end
731
+
732
+ # The lockfile rather than the Gemfile, because the lockfile is the bundle
733
+ # this application actually resolved. It is evidence that the gem is
734
+ # there, not proof that the legal pages render through it — which is why
735
+ # the generated setting also requires the application to be serving those
736
+ # exact pages, and why the worst case is a boot error naming the missing
737
+ # gem rather than a document published through the wrong pipeline.
738
+ def host_bundles_markdown_rails?
739
+ return @host_bundles_markdown_rails if defined?(@host_bundles_markdown_rails)
740
+
741
+ @host_bundles_markdown_rails =
742
+ begin
743
+ lockfile = File.expand_path("Gemfile.lock", destination_root)
744
+ File.exist?(lockfile) && File.read(lockfile).match?(/^\s+markdown-rails[\s(]/)
745
+ rescue StandardError
746
+ false
747
+ end
748
+ end
749
+
750
+ # Both halves have to be true for this to be the right default: the
751
+ # application renders its own legal pages through markdown-rails, and
752
+ # those same pages are the documents Clickwrap will publish. Then the
753
+ # snapshot people accept and the page they read come out of one renderer
754
+ # instead of two that have to be kept in agreement by hand.
755
+ def renders_documents_through_markdown_rails?
756
+ return false unless detected_legal_documents&.fetch(:rendered_by_the_application, false)
757
+
758
+ host_bundles_markdown_rails?
759
+ end
760
+
761
+ def routes_file?
762
+ host_file?("config/routes.rb")
763
+ end
764
+
765
+ def engine_already_mounted?
766
+ File.read(File.expand_path("config/routes.rb", destination_root)).include?("Clickwrap::Engine")
767
+ rescue StandardError
768
+ false
769
+ end
770
+
771
+ def say_missing_routes_file
772
+ say "\n No config/routes.rb found, so nothing was mounted."
773
+ end
774
+
775
+ def say_already_mounted
776
+ say "\n config/routes.rb already mounts Clickwrap::Engine — left untouched."
777
+ @mounted_engine = true
778
+ end
779
+
780
+ # Every other question this generator declines to ask non-interactively
781
+ # resolves to "collect nothing", because for personal data the quiet
782
+ # answer is the safe one. This question is the exception, and it is worth
783
+ # saying why out loud.
784
+ #
785
+ # Unmounted, the engine has no document route. Clickwrap refuses to sign a
786
+ # document link that resolves to nothing, so an unmounted install cannot
787
+ # present a policy at all — the failure is not "a screen is missing", it
788
+ # is "signup raises". Taking the [y/N] default here was politeness that
789
+ # produced a broken application, so a non-interactive run mounts and says
790
+ # so, and `--skip-routes` is the one flag that declines.
791
+ def mount_engine_without_asking
792
+ route "mount Clickwrap::Engine => \"/agreements\""
793
+ @mounted_engine = true
794
+
795
+ say "\n Mounted Clickwrap::Engine at \"/agreements\" in config/routes.rb (nothing was asked)."
796
+ say " Unmounted, there is no document route, and Clickwrap refuses to sign a document"
797
+ say " link that resolves to nothing — so an unmounted install cannot present a policy"
798
+ say " at all. Re-run with --skip-routes to decline, or delete the line if you route the"
799
+ say " documents yourself and bind that route with `document_version_path_with:`."
800
+ end
801
+
802
+ def interactive?
803
+ return false if options[:skip_questions]
804
+
805
+ # No terminal, no questions: a piped or scripted run (CI, a provisioning
806
+ # script, an AI agent) would otherwise stream every prompt into a jumble
807
+ # and take the [y/N] defaults anyway. Skipping deliberately keeps the
808
+ # same collect-nothing outcome and says so once, instead of pretending
809
+ # a conversation happened.
810
+ unless $stdin.tty?
811
+ @announced_non_interactive ||= begin
812
+ say "Non-interactive run detected: skipping questions and writing the safe, " \
813
+ "collect-nothing defaults (same as --skip-questions).", :yellow
814
+ true
815
+ end
816
+ return false
817
+ end
818
+
819
+ true
820
+ end
821
+
822
+ # --- The questions --------------------------------------------------------
823
+
824
+ def answers
825
+ @answers ||= {}
826
+ end
827
+
828
+ def recipe
829
+ options[:request_evidence_recipe]
830
+ end
831
+
832
+ def privacy_minimized_recipe?
833
+ recipe == "privacy-minimized"
834
+ end
835
+
836
+ def request_evidence_option_keys
837
+ @request_evidence_option_keys ||= %i[
838
+ record_ip_addresses_by_default
839
+ record_browser_user_agents_by_default
840
+ record_ip_geolocation_fields
841
+ ]
842
+ end
843
+
844
+ # Privacy-minimized and --skip-questions are complete postures. Individual
845
+ # flags are not: in an interactive run, Clickwrap still asks about every
846
+ # category the operator did not explicitly decide.
847
+ def skip_request_evidence_questions?
848
+ !interactive? || privacy_minimized_recipe?
849
+ end
850
+
851
+ def ask_request_evidence_questions
852
+ say "\nClickwrap records none of the following unless you say so here. Each one is a"
853
+ say "separate question because each one is a separate decision.\n"
854
+
855
+ ask_about_ip_addresses
856
+ ask_about_browser_user_agents
857
+ ask_about_ip_geolocation
858
+ ask_about_ip_geolocation_resolver
859
+ end
860
+
861
+ # Thor's prompt escapes newlines, so a multi-line question handed straight
862
+ # to `yes?` prints as one long line of literal \n. The question is said in
863
+ # full first, and only its last line — the one carrying [y/N] — becomes the
864
+ # prompt, which keeps the wording exactly as written.
865
+ def ask_question(text)
866
+ lines = text.strip.lines.map(&:chomp)
867
+
868
+ say ""
869
+ lines[0..-2].each { |line| say line }
870
+ yes?(lines.last)
871
+ end
872
+
873
+ def ask_about_ip_addresses
874
+ if options[:record_ip_addresses_by_default].nil?
875
+ answers[:ip_address] = ask_question(<<~QUESTION)
876
+ Should Clickwrap record IP addresses by default?
877
+ IP addresses can help investigate disputes, but they are personal data and
878
+ need a documented purpose, access policy, and deletion schedule. [y/N]
879
+ QUESTION
880
+ end
881
+
882
+ return unless record_ip_addresses?
883
+
884
+ if options[:reason_for_recording_ip_addresses_by_default].nil?
885
+ answers[:ip_address_purpose] = ask_purpose("recording IP addresses")
886
+ end
887
+ return unless options[:delete_recorded_ip_addresses_after_days].nil?
888
+
889
+ answers[:ip_address_days] = ask_retention_days("recorded IP addresses")
890
+ end
891
+
892
+ def ask_about_browser_user_agents
893
+ if options[:record_browser_user_agents_by_default].nil?
894
+ answers[:browser_user_agent] = ask_question(<<~QUESTION)
895
+ Should Clickwrap record browser User-Agent headers by default?
896
+ The value is supplied by the browser, may be spoofed, and is not a unique
897
+ device identity. [y/N]
898
+ QUESTION
899
+ end
900
+
901
+ return unless record_browser_user_agents?
902
+
903
+ if options[:reason_for_recording_browser_user_agents_by_default].nil?
904
+ answers[:browser_user_agent_purpose] = ask_purpose("recording browser User-Agent strings")
905
+ end
906
+ return unless options[:delete_recorded_browser_user_agents_after_days].nil?
907
+
908
+ answers[:browser_user_agent_days] = ask_retention_days("recorded browser User-Agent strings")
909
+ end
910
+
911
+ def ask_about_ip_geolocation
912
+ # Supplying any geolocation field on the command line makes that list an
913
+ # explicit allowlist. Asking about the remaining fields would make a
914
+ # non-interactive-looking command unexpectedly collect more data.
915
+ return if ip_geolocation_options_supplied?
916
+
917
+ answers["country"] = ask_question(<<~QUESTION)
918
+ Should Clickwrap estimate and record a country from each IP address?
919
+ This is an estimate for the IP address, not the person's physical location.
920
+ It requires an IP geolocation resolver such as trackdown. [y/N]
921
+ QUESTION
922
+
923
+ say <<~FRAMING
924
+
925
+ Should Clickwrap estimate and record region, city, or postal-code fields?
926
+ Each field is provider-estimated from the IP address, may be inaccurate, and
927
+ must have a documented purpose and retention rule. Select fields individually.
928
+ FRAMING
929
+
930
+ answers["region"] = yes?(" Record the estimated region? [y/N]")
931
+ answers["city"] = yes?(" Record the estimated city? [y/N]")
932
+ answers["postal_code"] = yes?(" Record the estimated postal code? [y/N]")
933
+
934
+ answers["latitude_and_longitude"] = ask_question(<<~QUESTION)
935
+ Should Clickwrap record provider-estimated latitude and longitude?
936
+ These coordinates describe an approximate IP-network location, not GPS or the
937
+ person's physical position. Accuracy radius is stored when available. [y/N]
938
+ QUESTION
939
+
940
+ ask_about_remaining_ip_geolocation_fields
941
+ ask_about_ip_geolocation_purpose
942
+ end
943
+
944
+ # The remaining fields get the same treatment as the ones above: named
945
+ # individually, estimated, and off unless asked for.
946
+ def ask_about_remaining_ip_geolocation_fields
947
+ say <<~FRAMING
948
+
949
+ Clickwrap can also record four smaller provider-estimated fields. Each is an
950
+ estimate about the IP address and needs the same purpose and retention rule
951
+ as the ones above.
952
+ FRAMING
953
+
954
+ answers["timezone"] = yes?(" Record the estimated timezone? [y/N]")
955
+ answers["continent"] = yes?(" Record the estimated continent? [y/N]")
956
+ answers["metro_code"] = yes?(" Record the estimated metro code? [y/N]")
957
+ answers["accuracy_radius_in_kilometers"] = ask_question(<<~QUESTION)
958
+ Record the accuracy radius in kilometers? Keeping it is how a later reader
959
+ can tell how uncertain the estimate above actually was. [y/N]
960
+ QUESTION
961
+ end
962
+
963
+ # One purpose and one retention rule cover the IP-geolocation category:
964
+ # the fields are separate decisions about what to keep, but they are all
965
+ # resolved from the same address by the same provider at the same moment.
966
+ def ask_about_ip_geolocation_purpose
967
+ return unless any_ip_geolocation_field?
968
+
969
+ if options[:reason_for_recording_ip_geolocation_by_default].nil?
970
+ answers[:ip_geolocation_purpose] = ask_purpose("recording IP geolocation")
971
+ end
972
+ return unless options[:delete_recorded_ip_geolocation_after_days].nil?
973
+
974
+ answers[:ip_geolocation_days] = ask_retention_days("recorded IP geolocation")
975
+ end
976
+
977
+ def ask_purpose(label)
978
+ say "\n Why does the application need #{label}? One plain sentence, in your own"
979
+ say " words — it goes into the initializer and the privacy inventory. A blank"
980
+ say " or scaffolding answer stops generation before Clickwrap writes any files."
981
+ ask(" Purpose:").to_s.strip
982
+ end
983
+
984
+ def ask_retention_days(label)
985
+ say "\n After how many days should Clickwrap delete #{label}?"
986
+ say " Clickwrap does not invent a period. Enter the positive number your application"
987
+ say " has reviewed; a blank or zero answer stops generation before files are written."
988
+ ask(" Days:").to_s.strip.to_i
989
+ end
990
+
991
+ def ask_about_ip_geolocation_resolver
992
+ return unless any_ip_geolocation_field?
993
+ return unless options[:ip_geolocation_resolver_class_name].nil?
994
+
995
+ say "\n Which resolver class should Clickwrap instantiate for IP geolocation?"
996
+ say " Example: Clickwrap::IpGeolocation::TrackdownResolver (requires `trackdown`)."
997
+ answers[:ip_geolocation_resolver_class_name] = ask(" Resolver class:").to_s.strip
998
+ end
999
+
1000
+ # --- Resolved answers (flag > question > privacy-minimized/off) -----------
1001
+
1002
+ def record_ip_addresses?
1003
+ resolve_record_choice(:record_ip_addresses_by_default, :ip_address)
1004
+ end
1005
+
1006
+ def record_browser_user_agents?
1007
+ resolve_record_choice(:record_browser_user_agents_by_default, :browser_user_agent)
1008
+ end
1009
+
1010
+ def record_ip_geolocation_field?(field)
1011
+ requested = requested_ip_geolocation_fields
1012
+ return requested.include?(field.to_s) if requested
1013
+ return false if privacy_minimized_recipe?
1014
+
1015
+ answers.fetch(field, false) == true
1016
+ end
1017
+
1018
+ # The allowlist from `--record-ip-geolocation-fields`, or nil when the
1019
+ # operator did not use the flag at all. Accepts either shell convention —
1020
+ # `--record-ip-geolocation-fields city country` and
1021
+ # `--record-ip-geolocation-fields=city,country` — because getting that
1022
+ # wrong would silently record a field named "city,country", which is to
1023
+ # say none.
1024
+ #
1025
+ # A misspelled field stops the generator before it writes anything. The
1026
+ # alternative is an install that quietly records less than the operator
1027
+ # asked for, discovered years later by the person who needed the evidence.
1028
+ def requested_ip_geolocation_fields
1029
+ return @requested_ip_geolocation_fields if defined?(@requested_ip_geolocation_fields)
1030
+
1031
+ raw = options[:record_ip_geolocation_fields]
1032
+ return @requested_ip_geolocation_fields = nil if raw.nil?
1033
+
1034
+ fields = Array(raw).flat_map { |value| value.to_s.split(",") }
1035
+ .map { |value| value.strip.downcase }.reject(&:empty?).uniq
1036
+ unknown = fields - IP_GEOLOCATION_FIELDS.map(&:to_s)
1037
+
1038
+ unless unknown.empty?
1039
+ raise Thor::Error,
1040
+ "--record-ip-geolocation-fields does not know " \
1041
+ "#{unknown.map(&:inspect).join(", ")}. The fields Clickwrap can record are: " \
1042
+ "#{IP_GEOLOCATION_FIELDS.join(", ")}. Nothing was written."
1043
+ end
1044
+
1045
+ @requested_ip_geolocation_fields = fields
1046
+ end
1047
+
1048
+ def resolve_record_choice(option_key, answer_key)
1049
+ return options[option_key] unless options[option_key].nil?
1050
+ return false if privacy_minimized_recipe?
1051
+
1052
+ answers.fetch(answer_key, false) == true
1053
+ end
1054
+
1055
+ def ip_geolocation_fields
1056
+ Clickwrap::Vocabulary::IP_GEOLOCATION_DATA_FIELDS
1057
+ end
1058
+
1059
+ def enabled_ip_geolocation_fields
1060
+ ip_geolocation_fields.select { |field| record_ip_geolocation_field?(field) }
1061
+ end
1062
+
1063
+ def any_ip_geolocation_field?
1064
+ enabled_ip_geolocation_fields.any?
1065
+ end
1066
+
1067
+ def records_any_request_evidence?
1068
+ record_ip_addresses? || record_browser_user_agents? || any_ip_geolocation_field?
1069
+ end
1070
+
1071
+ def records_ip_derived_request_evidence?
1072
+ record_ip_addresses? || any_ip_geolocation_field?
1073
+ end
1074
+
1075
+ def ip_geolocation_options_supplied?
1076
+ !requested_ip_geolocation_fields.nil?
1077
+ end
1078
+
1079
+ def delete_recorded_ip_addresses_after_days
1080
+ retention_days(:delete_recorded_ip_addresses_after_days, :ip_address_days)
1081
+ end
1082
+
1083
+ def delete_recorded_browser_user_agents_after_days
1084
+ retention_days(:delete_recorded_browser_user_agents_after_days, :browser_user_agent_days)
1085
+ end
1086
+
1087
+ def delete_recorded_ip_geolocation_after_days
1088
+ retention_days(:delete_recorded_ip_geolocation_after_days, :ip_geolocation_days)
1089
+ end
1090
+
1091
+ def retention_days(option_key, answer_key)
1092
+ (options[option_key] || answers[answer_key]).to_i
1093
+ end
1094
+
1095
+ def purpose_for(option_key, answer_key)
1096
+ (options[option_key] || answers[answer_key]).to_s.strip
1097
+ end
1098
+
1099
+ def reason_for_recording_ip_addresses
1100
+ purpose_for(:reason_for_recording_ip_addresses_by_default, :ip_address_purpose)
1101
+ end
1102
+
1103
+ def reason_for_recording_browser_user_agents
1104
+ purpose_for(:reason_for_recording_browser_user_agents_by_default, :browser_user_agent_purpose)
1105
+ end
1106
+
1107
+ def reason_for_recording_ip_geolocation
1108
+ purpose_for(:reason_for_recording_ip_geolocation_by_default, :ip_geolocation_purpose)
1109
+ end
1110
+
1111
+ def trusted_proxy_configuration_digest
1112
+ (options[:trusted_proxy_configuration_digest] ||
1113
+ answers[:trusted_proxy_configuration_digest]).to_s.strip.presence
1114
+ end
1115
+
1116
+ def ip_geolocation_resolver_class_name
1117
+ (options[:ip_geolocation_resolver_class_name] ||
1118
+ answers[:ip_geolocation_resolver_class_name]).to_s.strip.presence
1119
+ end
1120
+
1121
+ # Every enabled personal-data category must be complete before Thor moves
1122
+ # on to its first file-writing task. This makes the command atomic from the
1123
+ # host developer's point of view: either it writes a bootable initializer
1124
+ # containing their decisions, or it writes nothing.
1125
+ def validate_request_evidence_choices!
1126
+ # First, because a misspelled field name has to stop the run before any
1127
+ # of the checks below reason about a list that is missing an entry.
1128
+ requested_ip_geolocation_fields
1129
+
1130
+ validate_enabled_category!(
1131
+ "IP addresses",
1132
+ enabled: record_ip_addresses?,
1133
+ because: reason_for_recording_ip_addresses,
1134
+ delete_after_days: delete_recorded_ip_addresses_after_days,
1135
+ reason_option: "--reason-for-recording-ip-addresses-by-default",
1136
+ retention_option: "--delete-recorded-ip-addresses-after-days"
1137
+ )
1138
+ validate_enabled_category!(
1139
+ "browser User-Agent strings",
1140
+ enabled: record_browser_user_agents?,
1141
+ because: reason_for_recording_browser_user_agents,
1142
+ delete_after_days: delete_recorded_browser_user_agents_after_days,
1143
+ reason_option: "--reason-for-recording-browser-user-agents-by-default",
1144
+ retention_option: "--delete-recorded-browser-user-agents-after-days"
1145
+ )
1146
+ validate_enabled_category!(
1147
+ "IP geolocation",
1148
+ enabled: any_ip_geolocation_field?,
1149
+ because: reason_for_recording_ip_geolocation,
1150
+ delete_after_days: delete_recorded_ip_geolocation_after_days,
1151
+ reason_option: "--reason-for-recording-ip-geolocation-by-default",
1152
+ retention_option: "--delete-recorded-ip-geolocation-after-days"
1153
+ )
1154
+ validate_ip_geolocation_coordinates!
1155
+ validate_trusted_proxy_configuration!
1156
+ validate_ip_geolocation_resolver_class_name!
1157
+ end
1158
+
1159
+ def validate_enabled_category!(label, enabled:, because:, delete_after_days:,
1160
+ reason_option:, retention_option:)
1161
+ return unless enabled
1162
+
1163
+ unless Clickwrap::ReviewedText.present_and_reviewed?(because)
1164
+ raise Thor::Error,
1165
+ "Clickwrap cannot enable #{label} with a blank or scaffolding reason. " \
1166
+ "Give the application's reviewed, present-tense reason with " \
1167
+ "#{reason_option}=\"...\", or turn that category off. No files were written."
1168
+ end
1169
+
1170
+ return if delete_after_days.positive?
1171
+
1172
+ raise Thor::Error,
1173
+ "Clickwrap cannot enable #{label} without a positive deletion period. " \
1174
+ "Set #{retention_option}=DAYS to the period your application reviewed, or " \
1175
+ "turn that category off. No files were written."
1176
+ end
1177
+
1178
+ def validate_ip_geolocation_coordinates!
1179
+ return unless record_ip_geolocation_field?("latitude_and_longitude")
1180
+ return if record_ip_geolocation_field?("accuracy_radius_in_kilometers")
1181
+
1182
+ raise Thor::Error,
1183
+ "Clickwrap cannot record provider-estimated latitude and longitude without " \
1184
+ "their accuracy radius. Add " \
1185
+ "--record-ip-geolocation-accuracy-radius-in-kilometers-by-default, or turn " \
1186
+ "coordinates off. No files were written."
1187
+ end
1188
+
1189
+ def validate_trusted_proxy_configuration!
1190
+ digest = trusted_proxy_configuration_digest
1191
+ return if digest.nil?
1192
+ return if Clickwrap::Digest.well_formed?(digest)
1193
+
1194
+ raise Thor::Error,
1195
+ "--trusted-proxy-configuration-digest must be a complete prefixed SHA-2 digest " \
1196
+ "(for example sha256: followed by 64 lowercase hexadecimal characters). " \
1197
+ "Omit it to derive provenance from Rails' effective trusted-proxy rules, or " \
1198
+ "supply the reviewed digest and try again. No files were written."
1199
+ end
1200
+
1201
+ def validate_ip_geolocation_resolver_class_name!
1202
+ class_name = ip_geolocation_resolver_class_name
1203
+ return if class_name.nil? && !any_ip_geolocation_field?
1204
+ return if class_name&.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/)
1205
+
1206
+ raise Thor::Error,
1207
+ "Recording IP geolocation requires --ip-geolocation-resolver-class-name with " \
1208
+ "a Ruby class name such as Clickwrap::IpGeolocation::TrackdownResolver. " \
1209
+ "Clickwrap will not choose a provider or dependency for the application. " \
1210
+ "No files were written."
1211
+ end
1212
+
1213
+ # A date one year out, so an enabled field gets looked at again by someone
1214
+ # rather than quietly outliving the reason it was turned on.
1215
+ def review_request_evidence_configuration_on
1216
+ Date.today.next_year
1217
+ end
1218
+
1219
+ def review_date_literal
1220
+ date = review_request_evidence_configuration_on
1221
+ "Date.new(#{date.year}, #{date.month}, #{date.day})"
1222
+ end
1223
+
1224
+ # --- The pre-write summary ------------------------------------------------
1225
+
1226
+ def summarize_request_evidence_choices
1227
+ say "\nRequest evidence Clickwrap will record BY DEFAULT for every policy:"
1228
+
1229
+ unless records_any_request_evidence?
1230
+ say " • nothing."
1231
+ say " That is the safe default and it stays true until you change it. An"
1232
+ say " individual policy can still enable a field it genuinely needs."
1233
+ return
1234
+ end
1235
+
1236
+ summarize_category("IP address", record_ip_addresses?, reason_for_recording_ip_addresses,
1237
+ delete_recorded_ip_addresses_after_days)
1238
+ summarize_category("browser User-Agent", record_browser_user_agents?,
1239
+ reason_for_recording_browser_user_agents,
1240
+ delete_recorded_browser_user_agents_after_days)
1241
+
1242
+ if any_ip_geolocation_field?
1243
+ summarize_category("IP geolocation (#{enabled_ip_geolocation_fields.join(", ")})", true,
1244
+ reason_for_recording_ip_geolocation, delete_recorded_ip_geolocation_after_days)
1245
+ say " source: provider-estimated from the IP address — not GPS, not a street"
1246
+ say " address, and not proof of where anyone was. Provider name,"
1247
+ say " source, estimated state, and accuracy provenance are stored"
1248
+ say " alongside every value."
1249
+ end
1250
+
1251
+ say "\n encryption: on (config.encrypt_recorded_* = true)."
1252
+ say " access: unredacted values are DENIED until you write"
1253
+ say " `authorize_unredacted_request_evidence_access_with`; every export"
1254
+ say " needs a human-readable reason and appends an access event."
1255
+ say " review on: #{review_request_evidence_configuration_on.iso8601}"
1256
+ say "\n These are the explicit settings you supplied, not a legal verdict. Review"
1257
+ say " them in the generated initializer before relying on them.", :yellow
1258
+ say " The recipe flag, if you used one, stops here: it expanded into the individual", :yellow
1259
+ say " settings written into the initializer and does not exist at runtime.", :yellow
1260
+ end
1261
+
1262
+ def summarize_category(label, enabled, purpose, days)
1263
+ return unless enabled
1264
+
1265
+ say " • #{label}"
1266
+ say " purpose: #{purpose}"
1267
+ say " delete: after #{days} days"
1268
+ end
1269
+
1270
+ # --- Files ----------------------------------------------------------------
1271
+
1272
+ # A template rather than a copy, because the placeholder opens with front
1273
+ # matter whose `last_updated:` is the document's version label, and the
1274
+ # only honest value for it is the day the file was written. That is also
1275
+ # why the generated declaration in config/clickwrap.rb needs no `version:`
1276
+ # at all: the file carrying the words names its own version.
1277
+ def write_legal_placeholder(source, destination)
1278
+ if host_file?(destination)
1279
+ say_status :skip, "#{destination} (you already have a file there)", :yellow
1280
+ return
1281
+ end
1282
+
1283
+ template source, destination
1284
+ end
1285
+ end
1286
+ end
1287
+ end