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/lib/clickwrap.rb CHANGED
@@ -1,15 +1,505 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support"
4
+ require "active_support/core_ext/string/inflections"
5
+ require "active_support/core_ext/numeric/time"
6
+ require "active_support/core_ext/object/blank"
7
+
3
8
  require_relative "clickwrap/version"
9
+ require_relative "clickwrap/errors"
10
+ require_relative "clickwrap/vocabulary"
11
+ require_relative "clickwrap/canonical_json"
12
+ require_relative "clickwrap/digest"
13
+ require_relative "clickwrap/identifier"
14
+ require_relative "clickwrap/reference"
15
+ require_relative "clickwrap/protected_outcome"
16
+ require_relative "clickwrap/trusted_proxy_configuration"
17
+ require_relative "clickwrap/reviewed_text"
18
+ require_relative "clickwrap/ip_geolocation"
19
+ require_relative "clickwrap/authority"
20
+ require_relative "clickwrap/integrations/organizations_authority"
21
+ require_relative "clickwrap/subject_fingerprint"
22
+ require_relative "clickwrap/remediation_token"
23
+ require_relative "clickwrap/durable_commit_callback"
24
+ require_relative "clickwrap/front_matter"
25
+ require_relative "clickwrap/document_renderer"
26
+ require_relative "clickwrap/document_renderers/markdown"
27
+ require_relative "clickwrap/document_renderers/markdown_rails"
28
+ require_relative "clickwrap/configuration"
29
+ require_relative "clickwrap/macros"
30
+
31
+ require_relative "clickwrap/engine" if defined?(Rails::Engine)
4
32
 
5
- # clickwrap is not implemented yet.
33
+ # == Clickwrap
34
+ #
35
+ # The evidence-and-assent layer for Rails: versioned agreements,
36
+ # acknowledgments, consent, declarations, attestations, and authorizations,
37
+ # captured with the exact content and presentation they were offered under, and
38
+ # committed in the same transaction as the action they authorize.
39
+ #
40
+ # The public surface is intentionally small:
41
+ #
42
+ # Clickwrap.configure { |config| ... } # one block, in an initializer
43
+ # Clickwrap.document :terms, ... # immutable versioned content
44
+ # Clickwrap.policy :signup do ... end # what the server offers and accepts
45
+ # Clickwrap.retention :ordinary do ... # how long each part is kept
6
46
  #
7
- # This release exists only to hold the gem name while the product is being
8
- # defined. It ships no engine, no models, no migrations, and no public API,
9
- # and it deliberately declares no runtime dependencies. Requiring it does
10
- # nothing beyond defining this module and VERSION.
47
+ # has_clickwraps # on the model that can act
48
+ # form.clickwrap :signup, submit: "..." # render the controls and the action
11
49
  #
12
- # See https://github.com/rameerez/clickwrap for the README-first contract
13
- # describing the gem we intend to build.
50
+ # Clickwrap.capture!(:signup, actor:, http_request:, submission:)
51
+ # Clickwrap.capture_and!(:withdrawal, ...) { withdrawal.submit! }
52
+ # user.clickwraps.agreed_to?(:terms)
53
+ # Clickwrap.verify(:withdrawal, actor:, subject:)
54
+ #
55
+ # What this gem does is evidence mechanics. What it does not do is decide
56
+ # whether your agreement is enforceable, whether consent is the right lawful
57
+ # basis, whether a document change is material, who someone really is, or how
58
+ # long you must keep anything. Those belong to the application and its counsel,
59
+ # and no configuration flag here can stand in for them.
14
60
  module Clickwrap
61
+ DOCUMENT_OPTIONS = %i[
62
+ version locale media_type effective_at tenant from content resolver renderer link
63
+ ].freeze
64
+
65
+ # The Minitest helpers hosts include in their own suite. Autoloaded rather
66
+ # than required at boot: a test helper has no business being resident in a
67
+ # production process, and a host that never writes a Clickwrap test never
68
+ # loads the file.
69
+ #
70
+ # class ActiveSupport::TestCase
71
+ # include Clickwrap::TestHelpers
72
+ # end
73
+ autoload :TestHelpers, "clickwrap/test_helpers"
74
+
75
+ class << self
76
+ # --- Configuration --------------------------------------------------------
77
+
78
+ def config
79
+ @config ||= Configuration.new
80
+ end
81
+
82
+ alias configuration config
83
+
84
+ def configure
85
+ yield config if block_given?
86
+ config.validate!
87
+ config
88
+ end
89
+
90
+ # Reset all global state. Used by the test suite to keep examples isolated;
91
+ # also handy in a console when experimenting with configuration.
92
+ def reset!
93
+ @config = Configuration.new
94
+ @documents = nil
95
+ @policies = nil
96
+ @retention_classes = nil
97
+ # Every memoized verifier goes with the configuration it was built from.
98
+ # A verifier that outlived a reset keeps signing and accepting tokens
99
+ # under the previous secret, which is the kind of thing a test suite
100
+ # papers over (by resetting it itself) and a console session discovers
101
+ # the hard way.
102
+ RemediationToken.reset_verifier! if defined?(RemediationToken)
103
+ PresentationManifest.reset_verifier! if defined?(PresentationManifest)
104
+ SchemaRequirements.reset! if defined?(SchemaRequirements)
105
+ self
106
+ end
107
+
108
+ # --- Registries -----------------------------------------------------------
109
+
110
+ def documents = @documents ||= Registry.new(:document)
111
+ def policies = @policies ||= Registry.new(:policy)
112
+ def retention_classes = @retention_classes ||= Registry.new(:retention_class)
113
+
114
+ # Declares one immutable document version. Declaring it does not publish it:
115
+ # `bin/rails clickwrap:publish` reads the bytes once, digests them, and
116
+ # freezes a database snapshot. Until then the declaration is a promise about
117
+ # what will be published, and a policy that references an unpublished
118
+ # document fails loudly rather than presenting nothing.
119
+ def document(key, **options)
120
+ unknown = options.keys.map(&:to_sym) - DOCUMENT_OPTIONS
121
+ unless unknown.empty?
122
+ raise DefinitionError,
123
+ "Document #{key.inspect} has unknown option#{"s" if unknown.many?} " \
124
+ "#{unknown.map { |option| "`#{option}:`" }.join(", ")}. Supported options are: " \
125
+ "#{DOCUMENT_OPTIONS.map { |option| "`#{option}:`" }.join(", ")}. Clickwrap never " \
126
+ "ignores document options."
127
+ end
128
+
129
+ definition = DocumentDefinition.new(key: key, **options)
130
+ documents.register(definition.identity, definition)
131
+ definition
132
+ end
133
+
134
+ # Declares a server-owned policy. Compiles immediately so a mistake is a
135
+ # boot failure with a sentence explaining it, not a surprise at 3am.
136
+ def policy(key, &block)
137
+ raise DefinitionError, "Clickwrap.policy needs a block" unless block
138
+
139
+ builder = DSL::PolicyBuilder.new(key)
140
+ builder.instance_eval(&block)
141
+ compiled = builder.compile
142
+
143
+ policies.register(compiled.key, compiled)
144
+ compiled
145
+ end
146
+
147
+ # Declares a retention class. Clickwrap does not choose retention periods
148
+ # and cannot tell you whether yours are right; it makes a reviewed decision
149
+ # executable, auditable, and separable — the core event's schedule is
150
+ # independent of the optional personal request evidence attached to it.
151
+ def retention(key, &block)
152
+ raise DefinitionError, "Clickwrap.retention needs a block" unless block
153
+
154
+ builder = DSL::RetentionBuilder.new(key)
155
+ builder.instance_eval(&block)
156
+ compiled = builder.compile
157
+
158
+ retention_classes.register(compiled.key, compiled)
159
+ compiled
160
+ end
161
+
162
+ def policy!(key)
163
+ policies.fetch(key.to_s) do
164
+ raise UnknownPolicyError,
165
+ "No policy named #{key.inspect}. Defined policies: " \
166
+ "#{policies.keys.sort.join(", ").presence || "(none)"}. Policies are declared with " \
167
+ "`Clickwrap.policy #{key.inspect} do ... end`, conventionally in config/clickwrap.rb."
168
+ end
169
+ end
170
+
171
+ def retention_class!(key)
172
+ retention_classes.fetch(key.to_s) do
173
+ raise UnknownRetentionClassError,
174
+ "No retention class named #{key.inspect}. Defined classes: " \
175
+ "#{retention_classes.keys.sort.join(", ").presence || "(none)"}."
176
+ end
177
+ end
178
+
179
+ def document_definitions_for(key, tenant: nil)
180
+ documents.values.select do |definition|
181
+ definition.key == key.to_s && definition.tenant_key == tenant&.to_s
182
+ end
183
+ end
184
+
185
+ # --- Presentation and capture --------------------------------------------
186
+
187
+ def present(policy_key, **)
188
+ Presenter.new(policy: policy!(policy_key), **).present
189
+ end
190
+
191
+ def capture!(policy_key, actor:, subject: nil, tenant: nil, http_request: nil,
192
+ submission: nil, answers: nil, locale: nil, capture_channel: nil,
193
+ acting_for: nil, authentication_context: nil, attribution_method: nil,
194
+ idempotency_key: nil)
195
+ Capture.new(
196
+ policy: policy!(policy_key), actor: actor, subject: subject, tenant: tenant,
197
+ http_request: http_request, submission: submission, answers: answers, locale: locale,
198
+ capture_channel: capture_channel, acting_for: acting_for,
199
+ authentication_context: authentication_context, attribution_method: attribution_method,
200
+ idempotency_key: idempotency_key
201
+ ).capture!
202
+ end
203
+
204
+ def capture_and!(policy_key, actor:, subject: nil, tenant: nil, http_request: nil,
205
+ submission: nil, answers: nil, locale: nil, capture_channel: nil,
206
+ acting_for: nil, authentication_context: nil, attribution_method: nil,
207
+ idempotency_key: nil, &)
208
+ Capture.new(
209
+ policy: policy!(policy_key), actor: actor, subject: subject, tenant: tenant,
210
+ http_request: http_request, submission: submission, answers: answers, locale: locale,
211
+ capture_channel: capture_channel, acting_for: acting_for,
212
+ authentication_context: authentication_context, attribution_method: attribution_method,
213
+ idempotency_key: idempotency_key
214
+ ).capture_and!(&)
215
+ end
216
+
217
+ # A prospective represented-party flow for records such as a new customer
218
+ # organization. The form binds the record type and a server-owned browser
219
+ # flow before the record exists. This block must return a persisted record
220
+ # of that presented type after creating its host authority relationship;
221
+ # Clickwrap then verifies authority,
222
+ # rebinds the final stable reference, and commits all of it together.
223
+ def create_represented_party!(policy_key, actor:, represented_party:,
224
+ represented_party_creation_flow_id:,
225
+ subject: nil, tenant: nil, http_request: nil,
226
+ submission: nil, answers: nil, locale: nil,
227
+ capture_channel: nil, authentication_context: nil,
228
+ attribution_method: nil, idempotency_key: nil, &)
229
+ Capture.new(
230
+ policy: policy!(policy_key), actor: actor, subject: subject, tenant: tenant,
231
+ http_request: http_request, submission: submission, answers: answers, locale: locale,
232
+ capture_channel: capture_channel, acting_for: represented_party,
233
+ authentication_context: authentication_context, attribution_method: attribution_method,
234
+ idempotency_key: idempotency_key,
235
+ represented_party_creation_flow_id: represented_party_creation_flow_id
236
+ ).create_represented_party!(&)
237
+ end
238
+
239
+ # Signup, modeled honestly: at first render there is no persisted actor, so
240
+ # the presentation binds to a short-lived registration flow, and the account
241
+ # and its evidence commit together. The receipt records account-registration
242
+ # attribution rather than pretending someone was already authenticated.
243
+ #
244
+ # A prospective actor must be new. A public form's typed email address is
245
+ # not proof that its visitor controls an existing account or lead row. Use
246
+ # a distinct pending-request record, confirm the email address, and only
247
+ # then capture for the verified actor through the ordinary authenticated
248
+ # path.
249
+ def register!(policy_key, prospective_actor:, subject: nil, tenant: nil, http_request: nil,
250
+ submission: nil, answers: nil, locale: nil, capture_channel: nil,
251
+ acting_for: nil, authentication_context: nil, idempotency_key: nil,
252
+ registration_flow_id: nil, &)
253
+ Capture.new(
254
+ policy: policy!(policy_key), actor: nil, prospective_actor: prospective_actor,
255
+ subject: subject, tenant: tenant, http_request: http_request, submission: submission,
256
+ answers: answers, locale: locale, capture_channel: capture_channel,
257
+ acting_for: acting_for, authentication_context: authentication_context,
258
+ idempotency_key: idempotency_key, registration_flow_id: registration_flow_id
259
+ ).register!(&)
260
+ end
261
+
262
+ # Produces the strict, canonical result snapshot consumed by
263
+ # `record_protected_outcome_with`. Keeping this construction in the gem
264
+ # prevents every host from inventing a subtly different hash contract.
265
+ def protected_outcome(...) = ProtectedOutcome.build(...)
266
+
267
+ # Digest the effective proxy rules rather than a prose description of
268
+ # them. The Rails-specific helper includes Rails' actual defaults when the
269
+ # application has not overridden `action_dispatch.trusted_proxies`.
270
+ def trusted_proxy_configuration_digest_for(trusted_proxies)
271
+ TrustedProxyConfiguration.digest_for(trusted_proxies)
272
+ end
273
+
274
+ def trusted_proxy_configuration_digest_for_rails_application(application = Rails.application)
275
+ TrustedProxyConfiguration.digest_for_rails_application(application)
276
+ end
277
+
278
+ def submission_from(params, ...) = Submission.from_params(params, ...)
279
+
280
+ # --- Lifecycle ------------------------------------------------------------
281
+ #
282
+ # Each of these spells out the keywords its target accepts rather than
283
+ # forwarding `**`. A bare forward compiles fine and reads fine, and then an
284
+ # editor shows `**` where the argument list should be, a typo'd keyword
285
+ # travels one method further before failing, and the public API of the gem
286
+ # is documented only in the private method behind it.
287
+
288
+ def withdraw!(purpose_key, actor:, because:, tenant: nil, subject: nil,
289
+ acting_for: nil, http_request: nil)
290
+ Lifecycle.withdraw!(purpose_key, actor: actor, because: because, tenant: tenant,
291
+ subject: subject, acting_for: acting_for,
292
+ http_request: http_request)
293
+ end
294
+
295
+ # Correcting, renewing, and rescoping are new statements by the same
296
+ # person, so each takes the same `submission:` a first capture does: they
297
+ # are captured through a real presentation, not flipped administratively.
298
+ def correct_declaration!(statement_key, actor:, because:, subject: nil, tenant: nil,
299
+ replaces: nil, acting_for: nil, http_request: nil,
300
+ submission: nil, answers: nil)
301
+ Lifecycle.correct!(statement_key, actor: actor, because: because, subject: subject,
302
+ tenant: tenant, replaces: replaces, acting_for: acting_for,
303
+ http_request: http_request, submission: submission,
304
+ answers: answers)
305
+ end
306
+
307
+ def renew!(statement_key, actor:, because:, subject: nil, tenant: nil,
308
+ acting_for: nil, http_request: nil, submission: nil, answers: nil)
309
+ Lifecycle.renew!(statement_key, actor: actor, because: because, subject: subject,
310
+ tenant: tenant, acting_for: acting_for,
311
+ http_request: http_request, submission: submission,
312
+ answers: answers)
313
+ end
314
+
315
+ def change_consent_scope!(statement_key, actor:, because:, subject: nil, tenant: nil,
316
+ acting_for: nil, http_request: nil, submission: nil, answers: nil)
317
+ Lifecycle.change_consent_scope!(statement_key, actor: actor, because: because,
318
+ subject: subject, tenant: tenant,
319
+ acting_for: acting_for,
320
+ http_request: http_request,
321
+ submission: submission, answers: answers)
322
+ end
323
+
324
+ def revoke!(statement_key, actor:, because:, subject: nil, tenant: nil,
325
+ acting_for: nil, http_request: nil)
326
+ Lifecycle.revoke!(statement_key, actor: actor, because: because, subject: subject,
327
+ tenant: tenant, acting_for: acting_for,
328
+ http_request: http_request)
329
+ end
330
+
331
+ def supersede!(statement_key, actor:, because: nil, subject: nil, tenant: nil,
332
+ acting_for: nil, http_request: nil)
333
+ Lifecycle.supersede!(statement_key, actor: actor, because: because, subject: subject,
334
+ tenant: tenant, acting_for: acting_for,
335
+ http_request: http_request)
336
+ end
337
+
338
+ # An explicitly recorded system exemption. Seeds, imports, invitations, and
339
+ # service accounts must never "accept" by omitting a browser parameter or by
340
+ # fabricating a human click. An exemption says plainly that no human action
341
+ # occurred, records who created it and why, and never satisfies
342
+ # `agreed_to?` — it answers the separate `exempted_from?` question.
343
+ def exempt!(policy_key, actor:, because:, subject: nil, tenant: nil)
344
+ Lifecycle.exempt!(policy_key, actor: actor, because: because, subject: subject,
345
+ tenant: tenant)
346
+ end
347
+
348
+ # Captures evidence and commits a pending outbox row in one local
349
+ # transaction, for an action that has to cross a system boundary. This is a
350
+ # distributed reliability protocol, not a cross-system ACID transaction —
351
+ # see Clickwrap::Services::AuthorizeExternalAction for exactly what it does
352
+ # and does not promise.
353
+ def authorize_external_action!(policy_key,
354
+ after_pending_action_is_saved_inside_transaction: nil,
355
+ **,
356
+ &local_transaction_block)
357
+ if after_pending_action_is_saved_inside_transaction && local_transaction_block
358
+ raise ArgumentError,
359
+ "Pass either after_pending_action_is_saved_inside_transaction: or a block, not both."
360
+ end
361
+
362
+ local_transaction_hook =
363
+ after_pending_action_is_saved_inside_transaction || local_transaction_block
364
+
365
+ Services::AuthorizeExternalAction.new(
366
+ policy: policy!(policy_key),
367
+ after_pending_action_is_saved_inside_transaction: local_transaction_hook,
368
+ **
369
+ ).call
370
+ end
371
+
372
+ def import_external_receipt!(policy_key, actor:, provider_name:, provider_event_id:,
373
+ provider_receipt: nil, verified_with: nil, verified_at: nil,
374
+ occurred_at: nil, subject: nil, tenant: nil, because: nil,
375
+ statements: nil)
376
+ Import::ExternalReceipt.new(
377
+ policy: policy!(policy_key), actor: actor, provider_name: provider_name,
378
+ provider_event_id: provider_event_id, provider_receipt: provider_receipt,
379
+ verified_with: verified_with, verified_at: verified_at, occurred_at: occurred_at,
380
+ subject: subject, tenant: tenant, because: because, statements: statements
381
+ ).import!
382
+ end
383
+
384
+ def import_legacy!(policy_key, actor:, occurred_at:, because:, known: {}, unknown: [],
385
+ dry_run: false, subject: nil, tenant: nil, statements: nil,
386
+ capture_channel: "imported_provider", source: nil, counts_as_current: true)
387
+ Import::Legacy.new(
388
+ policy: policy!(policy_key), actor: actor, occurred_at: occurred_at, because: because,
389
+ known: known, unknown: unknown, dry_run: dry_run, subject: subject, tenant: tenant,
390
+ statements: statements, capture_channel: capture_channel, source: source,
391
+ counts_as_current: counts_as_current
392
+ ).import!
393
+ end
394
+
395
+ # --- Verification and gating ---------------------------------------------
396
+
397
+ def verify(policy_or_event, **) = Verification.verify(policy_or_event, **)
398
+
399
+ def require!(policy_key, **)
400
+ result = verify(policy_key, **)
401
+ raise VerificationFailed, result unless result.success?
402
+
403
+ result
404
+ end
405
+
406
+ def current?(policy_key, **) = verify(policy_key, **).success?
407
+
408
+ # True when the actor needs to complete this policy: either they have no
409
+ # current evidence, or a newer required document version has published since
410
+ # they last acted. The application decides which change is material;
411
+ # Clickwrap enforces the rule it is given.
412
+ def required?(policy_key, **) = !current?(policy_key, **)
413
+
414
+ def receipt(event_id) = Receipt.find(event_id)
415
+
416
+ def export_receipt(receipt, **) = Receipt.export(receipt, **)
417
+
418
+ # Retry optional timestamp/anchor work that left no immutable result after a
419
+ # committed event. This is intentionally explicit: it can call external
420
+ # providers, so applications normally run it from a scheduled job or the
421
+ # matching rake task rather than hiding it in a read path.
422
+ def reconcile_missing_integrity_attestations!(scope: Event.all, retry_failed_attestations: false)
423
+ Integrity::AttestationReconciler.new(
424
+ scope: scope,
425
+ retry_failed_attestations: retry_failed_attestations
426
+ ).call
427
+ end
428
+
429
+ # --- Disposition ----------------------------------------------------------
430
+
431
+ def delete_recorded_ip_address!(receipt, because:)
432
+ Retention::Disposition.delete_field!(receipt, :ip_address, because:)
433
+ end
434
+
435
+ def delete_recorded_browser_user_agent!(receipt, because:)
436
+ Retention::Disposition.delete_field!(receipt, :browser_user_agent, because:)
437
+ end
438
+
439
+ def delete_recorded_ip_geolocation!(receipt, because:)
440
+ Retention::Disposition.delete_field!(receipt, :ip_geolocation, because:)
441
+ end
442
+
443
+ # --- Actors ---------------------------------------------------------------
444
+
445
+ # A stable opaque identifier for someone who is not a persisted record. The
446
+ # host owns the identifier and any later account linking. An IP address is
447
+ # not an actor identifier and Clickwrap will not accept one here.
448
+ def anonymous_actor(identifier) = AnonymousActor.new(identifier)
449
+
450
+ # A named non-human actor, for seeds, imports, and background processes.
451
+ def system_actor(name) = SystemActor.new(name)
452
+
453
+ # --- Publishing -----------------------------------------------------------
454
+
455
+ def publish!(dry_run: false) = Services::PublishDocuments.new(dry_run:).call
456
+
457
+ def doctor = Doctor.new.report
458
+
459
+ # --- Internals ------------------------------------------------------------
460
+
461
+ def gem_version = VERSION
462
+ def canonical_schema_version = CANONICAL_SCHEMA_VERSION
463
+
464
+ # The server's own clock, used for every recorded time. It is described in
465
+ # receipts as exactly that — time recorded by the application server — and
466
+ # never as trusted time, which is a different thing supplied by a different
467
+ # kind of provider.
468
+ def now = Time.now.utc
469
+
470
+ def logger
471
+ defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil
472
+ end
473
+
474
+ # Post-commit hooks are observers, never authorization. A failure here is
475
+ # reported and swallowed, because the evidence and the action it protected
476
+ # have already committed and nothing an analytics call does may undo them.
477
+ def report_after_commit_failure(error, event)
478
+ config.report_after_commit_failure_with.call(error, event)
479
+ rescue StandardError => error
480
+ logger&.error("[clickwrap] after-commit failure reporter itself raised: #{error.class}")
481
+ nil
482
+ end
483
+ end
15
484
  end
485
+
486
+ require_relative "clickwrap/registry"
487
+ require_relative "clickwrap/localized_text"
488
+ require_relative "clickwrap/document_definition"
489
+ require_relative "clickwrap/statement"
490
+ require_relative "clickwrap/request_evidence_policy"
491
+ require_relative "clickwrap/policy"
492
+ require_relative "clickwrap/retention_class"
493
+ require_relative "clickwrap/dsl/policy_builder"
494
+ require_relative "clickwrap/dsl/retention_builder"
495
+
496
+ # The framework-integration modules are spine files, not autoloaded code: the
497
+ # engine's `on_load(:action_view)` / `on_load(:action_controller)` hooks run
498
+ # IMMEDIATELY when a host's other gems have already loaded that framework by
499
+ # the time Clickwrap's initializers register — which can be before the host's
500
+ # autoloader can serve these constants. They must exist the moment the engine
501
+ # file does.
502
+ require_relative "clickwrap/form_builder_extensions"
503
+ require_relative "clickwrap/view_helpers"
504
+ require_relative "clickwrap/controller_helpers"
505
+ require_relative "clickwrap/registration"
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Clickwrap
6
+ module Generators
7
+ # `rails generate clickwrap:document terms` — declares one document and
8
+ # creates the file its bytes will come from, with the file's own front
9
+ # matter naming its version.
10
+ #
11
+ # Declaring is not publishing. This generator adds a declaration and a
12
+ # placeholder file; `bin/rails clickwrap:publish` is what reads the bytes,
13
+ # digests them, and freezes the snapshot that receipts point at from then
14
+ # on (deploys do this automatically after `db:prepare`).
15
+ #
16
+ # Changing a document later is NOT another run of this generator: the file
17
+ # that holds the words also names its version, so a new version is one
18
+ # edit in one file — change the text, bump `last_updated:` (or add
19
+ # `clickwrap_version:` for a same-day correction), publish. The
20
+ # declaration never changes, published bytes are never edited in place,
21
+ # and every receipt goes on pointing at the exact version its accepted
22
+ # server offer bound.
23
+ class DocumentGenerator < Rails::Generators::Base
24
+ source_root File.expand_path("templates", __dir__)
25
+ desc "Declare a clickwrap document and create its content file"
26
+
27
+ argument :name, type: :string, banner: "DOCUMENT_NAME"
28
+
29
+ class_option :document_version, type: :string,
30
+ desc: "An explicit version label, written into the file's " \
31
+ "front matter (defaults to today's date as last_updated)"
32
+ class_option :locale, type: :string,
33
+ desc: "Declare this version for one locale (en, es…)"
34
+
35
+ def create_content_file
36
+ if host_file?(content_path)
37
+ say_status :skip, "#{content_path} (you already have a file there)", :yellow
38
+ return
39
+ end
40
+
41
+ create_file content_path, placeholder_content
42
+ end
43
+
44
+ def declare_document
45
+ create_policy_file_if_missing
46
+
47
+ if declaration_present?
48
+ say_status :skip, "#{policy_file} (:#{document_key} is already declared — a new " \
49
+ "version is a front-matter bump in #{content_path}, not a new " \
50
+ "declaration)", :yellow
51
+ return
52
+ end
53
+
54
+ append_to_file policy_file, declaration
55
+ end
56
+
57
+ def display_next_steps
58
+ say "\n☑️ Document :#{document_key} declared.", :green
59
+ say "\nTo finish:"
60
+ say " 1. Put the real text in #{content_path}, keeping the front matter at the top."
61
+ say " Clickwrap never writes legal text — the words are yours."
62
+ say " 2. Run 'bin/rails clickwrap:publish' to freeze an immutable snapshot."
63
+ say " Deploys do this for you — publishing rides `db:prepare`."
64
+ say " 3. Reference it from a policy in #{policy_file}."
65
+ say "\nThe file names its own version: its `last_updated:` front matter is the label,"
66
+ say "so changing the text later is one edit in one file — new words, bumped label,"
67
+ say "publish. Reusing a label for different bytes is refused rather than accepted,"
68
+ say "and old receipts go on pointing at the bytes their accepted server offers bound.\n"
69
+ end
70
+
71
+ private
72
+
73
+ def document_key
74
+ name.to_s.underscore.tr("-", "_").tr("/", "_")
75
+ end
76
+
77
+ def explicit_version_label
78
+ options[:document_version].presence
79
+ end
80
+
81
+ def locale
82
+ options[:locale].presence
83
+ end
84
+
85
+ def policy_file
86
+ "config/clickwrap.rb"
87
+ end
88
+
89
+ def content_path
90
+ return "app/content/legal/#{document_key}.#{locale}.md" if locale
91
+
92
+ "app/content/legal/#{document_key}.md"
93
+ end
94
+
95
+ def declaration
96
+ lines = ["\nClickwrap.document :#{document_key},"]
97
+ lines << " locale: :#{locale}," if locale
98
+ lines << " from: Rails.root.join(#{content_path.inspect})\n"
99
+ lines.join("\n")
100
+ end
101
+
102
+ # One declaration per document key (and per locale): the version lives in
103
+ # the file, so a "new version" never adds a declaration — and a second
104
+ # version-less declaration of the same key would be refused at boot as a
105
+ # duplicate anyway.
106
+ def declaration_present?
107
+ File.read(File.expand_path(policy_file, destination_root))
108
+ .include?("Clickwrap.document :#{document_key},#{"\n locale: :#{locale}," if locale}")
109
+ rescue StandardError
110
+ false
111
+ end
112
+
113
+ def create_policy_file_if_missing
114
+ return if host_file?(policy_file)
115
+
116
+ create_file policy_file, <<~RUBY
117
+ # frozen_string_literal: true
118
+
119
+ # Clickwrap documents, policies, and retention classes. Ordinary Ruby, so it is
120
+ # reviewable in a pull request and deploys with the code that depends on it.
121
+ RUBY
122
+ end
123
+
124
+ def host_file?(path)
125
+ File.exist?(File.expand_path(path, destination_root))
126
+ end
127
+
128
+ # The front matter is the version label. `last_updated:` is the usual
129
+ # key; an explicit --document-version lands as `clickwrap_version:`,
130
+ # which outranks it, so a hand-chosen label and a human-facing date can
131
+ # coexist without fighting.
132
+ def placeholder_front_matter
133
+ lines = ["---", "title: #{document_key.humanize}"]
134
+ lines << "clickwrap_version: #{explicit_version_label}" if explicit_version_label
135
+ lines << "last_updated: #{Date.today.iso8601}"
136
+ lines << "---"
137
+ "#{lines.join("\n")}\n\n"
138
+ end
139
+
140
+ def placeholder_content
141
+ <<~MARKDOWN
142
+ #{placeholder_front_matter.rstrip}
143
+
144
+ # PLACEHOLDER — replace this with your own reviewed text
145
+
146
+ **This is not a #{document_key.humanize.downcase}.** The `clickwrap` gem created this
147
+ file so the declaration in `config/clickwrap.rb` has bytes to point at, and it
148
+ deliberately contains no legal text of any kind: the gem does not know who you
149
+ are, where you operate, or which rules apply, and text that merely looked
150
+ plausible would be worse than this, because someone would ship it.
151
+
152
+ Replace this file, then run `bin/rails clickwrap:publish` to freeze an immutable
153
+ snapshot. Publishing records the exact bytes, media type, locale, and digest, so
154
+ a receipt written today can reproduce the document version its accepted server
155
+ offer bound years from now.
156
+
157
+ The front matter above is the version label: when the text changes, bump
158
+ `last_updated:` (or add `clickwrap_version:` for a same-day correction) and
159
+ publish again, rather than editing a published version in place.
160
+ MARKDOWN
161
+ end
162
+ end
163
+ end
164
+ end