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,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # `form.clickwrap` and `form.clickwrap_fields`, mixed into the standard Rails
5
+ # form builder by the engine so the one-line happy path works inside an
6
+ # ordinary `form_with`.
7
+ #
8
+ # Both methods do the same three things: ask the server-owned presenter for a
9
+ # presentation, render the reference partial with it, and put the signed
10
+ # presentation token in the form. The difference is only who renders the
11
+ # submit button.
12
+ #
13
+ # `form.clickwrap` is the recommended one because it renders the controls AND
14
+ # the action as a single presentation. The call to action recorded in the
15
+ # manifest is then, by construction, the words on the button the person can
16
+ # actually press — there is no second place for that string to live and drift.
17
+ #
18
+ # What this helper deliberately never renders is a hidden field carrying a
19
+ # decision the server owns: no IP address, no browser user-agent, no
20
+ # geolocation, no policy version, no document digest, no validity window, no
21
+ # retention rule, no subject binding. All of those are resolved server-side
22
+ # and rechecked at submit. A form field is not a safe place to keep a security
23
+ # decision, and a client that tries to send one gets a loud failure from
24
+ # Clickwrap::Submission rather than a quiet acceptance.
25
+ module FormBuilderExtensions
26
+ # Distinguishes "the caller did not mention this" from "the caller
27
+ # explicitly passed nil". `actor: nil` is a real, meaningful value: it is how
28
+ # a signup form says there is no persisted actor yet.
29
+ NOT_GIVEN = Object.new.freeze
30
+ private_constant :NOT_GIVEN
31
+
32
+ # The strongest path, and the one the README leads with:
33
+ #
34
+ # <%= form.clickwrap :signup, submit: "Create account" %>
35
+ #
36
+ # <%= form.clickwrap :signup,
37
+ # actor: current_user,
38
+ # subject: @organization,
39
+ # locale: I18n.locale,
40
+ # submit: {
41
+ # text: "Create organization",
42
+ # class: "button button--primary",
43
+ # data: { turbo_submits_with: "Creating…" }
44
+ # } %>
45
+ #
46
+ # `submit:` takes a String, or a Hash of `text:` plus any ordinary HTML
47
+ # options for the button. Everything else in `**html_options` decorates the
48
+ # wrapper element, so a design system can hang its own classes on the block.
49
+ #
50
+ # What it renders, by default, is ONE line:
51
+ #
52
+ # [ ] I agree to the Terms of Service and I acknowledge the Privacy Policy.
53
+ #
54
+ # …whenever the policy's statements are all ordinary, required,
55
+ # default-worded agreements and acknowledgments. Anything the sentence
56
+ # cannot honestly absorb keeps a control of its own below it, and a policy
57
+ # with nothing composable is untouched. `combined: false` asks for the
58
+ # itemized shape regardless — one boolean, no style registry — and it
59
+ # reaches the PRESENTER rather than the template, so the manifest signs the
60
+ # shape that was actually offered.
61
+ def clickwrap(policy_key, submit:, actor: NOT_GIVEN, subject: nil, tenant: NOT_GIVEN,
62
+ acting_for: nil, locale: nil, capture_channel: nil, errors: nil,
63
+ combined: true, **html_options)
64
+ clickwrap_reserve_form_presentation!(policy_key)
65
+ text, button_options = clickwrap_split_submit(submit)
66
+
67
+ presentation = clickwrap_present(
68
+ policy_key,
69
+ submit_button_text: text,
70
+ actor: actor,
71
+ subject: subject,
72
+ tenant: tenant,
73
+ acting_for: acting_for,
74
+ locale: locale,
75
+ capture_channel: capture_channel,
76
+ combined: combined
77
+ )
78
+ clickwrap_render_fields(
79
+ presentation,
80
+ submit: { text: text, options: button_options },
81
+ errors: errors,
82
+ html_options: html_options
83
+ )
84
+ end
85
+
86
+ # The split API, for design systems that render the action somewhere this
87
+ # helper cannot reach:
88
+ #
89
+ # <%= form.clickwrap_fields :signup, submit_button_text: "Create account" %>
90
+ # <%= form.clickwrap_submit %>
91
+ #
92
+ # The manifest records the call to action the person was offered, so the
93
+ # wording is declared here. `form.clickwrap_submit` reuses it without a
94
+ # second string. An ordinary `form.submit "Create account"` is supported too,
95
+ # but Clickwrap verifies that it says exactly what the manifest says.
96
+ #
97
+ # A design system that renders its OWN button markup — not `form.submit`,
98
+ # so not something Clickwrap can check — takes the block form and reads the
99
+ # wording off the presentation instead of retyping it:
100
+ #
101
+ # <%= form.clickwrap_fields :signup, submit_button_text: "Create account" do |clickwrap| %>
102
+ # <button class="btn btn--primary" data-turbo-submits-with="Creating…">
103
+ # <%= clickwrap.submit_button_text %>
104
+ # </button>
105
+ # <% end %>
106
+ #
107
+ # The block is yielded the whole Presenter::Result — `submit_button_text`,
108
+ # `statements`, `policy_key`, `locale` — and its output is rendered inside
109
+ # the same wrapper, after the controls. This is the shape that makes drift
110
+ # impossible rather than merely detected: there is one string, it is the
111
+ # signed one, and nothing has to compare two copies of it afterwards.
112
+ #
113
+ # `submit:` on `form.clickwrap` and `submit_button_text:` here are a
114
+ # deliberate pair, not a duplication. `submit:` says "render the button
115
+ # too"; `submit_button_text:` says "bind these exact words into the
116
+ # manifest, and I will render the button myself".
117
+ def clickwrap_fields(policy_key, submit_button_text:, actor: NOT_GIVEN, subject: nil,
118
+ tenant: NOT_GIVEN, acting_for: nil, locale: nil, capture_channel: nil, errors: nil,
119
+ combined: true, **html_options, &block)
120
+ clickwrap_reserve_form_presentation!(policy_key)
121
+ presentation = clickwrap_present(
122
+ policy_key,
123
+ submit_button_text: submit_button_text,
124
+ actor: actor,
125
+ subject: subject,
126
+ tenant: tenant,
127
+ acting_for: acting_for,
128
+ locale: locale,
129
+ capture_channel: capture_channel,
130
+ combined: combined
131
+ )
132
+
133
+ # A block that renders the action itself has already single-sourced the
134
+ # wording off the signed presentation, so there is no second copy left to
135
+ # verify — and arming the `form.submit` check would then reject a form
136
+ # that never called `form.submit` at all.
137
+ @clickwrap_expected_submit_button_text = presentation.submit_button_text unless block
138
+
139
+ clickwrap_render_fields(
140
+ presentation,
141
+ submit: nil,
142
+ errors: errors,
143
+ html_options: html_options,
144
+ after: (@template.capture(presentation, &block) if block)
145
+ )
146
+ end
147
+
148
+ # The DRY split-form action. `clickwrap_fields` already declared and signed
149
+ # the exact wording, so this helper renders that same wording without asking
150
+ # the host to repeat it:
151
+ #
152
+ # <%= form.clickwrap_fields :signup, submit_button_text: "Create account" %>
153
+ # <%= form.clickwrap_submit class: "button" %>
154
+ #
155
+ # Ordinary `form.submit "Create account"` remains supported and is checked
156
+ # below. This helper simply removes the second string and therefore removes
157
+ # the possibility of drift by construction.
158
+ def clickwrap_submit(**options)
159
+ unless @clickwrap_expected_submit_button_text
160
+ raise ConfigurationError,
161
+ "form.clickwrap_submit needs form.clickwrap_fields earlier in the same form. " \
162
+ "The fields declare the exact call to action that Clickwrap signs into evidence."
163
+ end
164
+
165
+ submit(@clickwrap_expected_submit_button_text, options)
166
+ end
167
+
168
+ # When a split integration uses Rails' ordinary form.submit, compare the
169
+ # button Rails actually rendered with the words already signed into the
170
+ # presentation, refused in every environment rather than left as a
171
+ # development log. Honest bound: this hook covers `form.submit` — a raw
172
+ # <button> tag, `form.button`, or `submit_tag` bypasses it, which is why
173
+ # the custom-surface helpers exist (`clickwrap_submit_button` words the
174
+ # button FROM the manifest, so there is nothing to drift).
175
+ def submit(value = nil, options = {})
176
+ html = super
177
+ clickwrap_verify_split_submit_button!(html) if @clickwrap_expected_submit_button_text
178
+ html
179
+ end
180
+
181
+ private
182
+
183
+ def clickwrap_reserve_form_presentation!(policy_key)
184
+ return @clickwrap_policy_rendered_in_this_form = policy_key.to_s if @clickwrap_policy_rendered_in_this_form.nil?
185
+
186
+ raise ConfigurationError,
187
+ "One Rails form can submit only one Clickwrap presentation. This form already renders " \
188
+ "#{@clickwrap_policy_rendered_in_this_form.inspect} and tried to render #{policy_key.to_s.inspect}. " \
189
+ "Put the statements in one Clickwrap policy, or use a separate form for each policy, so " \
190
+ "the request carries one unambiguous signed presentation token."
191
+ end
192
+
193
+ def clickwrap_present(policy_key, submit_button_text:, actor:, subject:, tenant:, acting_for:,
194
+ locale:, capture_channel:, combined:)
195
+ options = {
196
+ actor: actor.equal?(NOT_GIVEN) ? clickwrap_actor_from_view_context : actor,
197
+ subject: subject,
198
+ acting_for: acting_for,
199
+ tenant: tenant.equal?(NOT_GIVEN) ? clickwrap_tenant_from_view_context(policy_key) : tenant,
200
+ locale: locale || clickwrap_locale_from_view_context,
201
+ submit_button_text: submit_button_text,
202
+ combined: combined
203
+ }
204
+ controller = @template.try(:controller)
205
+ if controller.respond_to?(:clickwrap_document_version_path_for_presentation, true)
206
+ options[:default_document_version_path_with] = lambda do |version, declared_link|
207
+ controller.send(:clickwrap_document_version_path_for_presentation, version,
208
+ declared_link: declared_link)
209
+ end
210
+ elsif @template.respond_to?(:clickwrap_document_version_path)
211
+ options[:default_document_version_path_with] = lambda do |version, declared_link|
212
+ declared_link.presence || @template.clickwrap_document_version_path(version)
213
+ end
214
+ end
215
+ if controller.respond_to?(:clickwrap_authentication_context, true)
216
+ options[:authentication_context] = controller.send(:clickwrap_authentication_context)
217
+ end
218
+ options[:capture_channel] = capture_channel if capture_channel
219
+
220
+ if acting_for.respond_to?(:new_record?) && acting_for.new_record?
221
+ unless controller.respond_to?(:clickwrap_represented_party_creation_flow_id, true)
222
+ raise ConfigurationError,
223
+ "Creating a represented party with Clickwrap needs a controller session to " \
224
+ "bind the browser flow. Include Clickwrap::ControllerHelpers in the parent controller."
225
+ end
226
+
227
+ options[:represented_party_creation_flow_id] =
228
+ controller.send(:clickwrap_represented_party_creation_flow_id, policy_key)
229
+ end
230
+
231
+ if options[:actor].nil? && @object.respond_to?(:new_record?) && @object.new_record?
232
+ controller = @template.try(:controller)
233
+
234
+ unless controller.respond_to?(:clickwrap_registration_flow_id, true)
235
+ raise ConfigurationError,
236
+ "A signup clickwrap needs a controller session to bind its registration flow. " \
237
+ "Include Clickwrap::ControllerHelpers in the parent controller."
238
+ end
239
+
240
+ options[:prospective_actor] = @object
241
+ options[:registration_flow_id] = controller.send(:clickwrap_registration_flow_id, policy_key)
242
+ end
243
+
244
+ Clickwrap.present(policy_key, **options)
245
+ end
246
+
247
+ # Rendered through the view context by partial NAME, never by absolute path,
248
+ # so a host copy at app/views/clickwrap/shared/_fields.html.erb shadows the
249
+ # gem's copy with no configuration at all. That is the whole ejection story:
250
+ #
251
+ # bin/rails generate clickwrap:views
252
+ #
253
+ # The gem's own file lives at
254
+ # Clickwrap::Engine.root.join("app/views/clickwrap/shared/_fields.html.erb").
255
+ def clickwrap_render_fields(presentation, submit:, errors:, html_options:, after: nil)
256
+ @template.render(
257
+ partial: "clickwrap/shared/fields",
258
+ locals: {
259
+ presentation: presentation,
260
+ submit: submit,
261
+ after: after,
262
+ errors: errors || clickwrap_errors_from_view_context,
263
+ wrapper_options: html_options
264
+ }
265
+ ).tap do |html|
266
+ Linter.review_rendered_fields(html, presentation: presentation) if Linter.enabled?
267
+ end
268
+ end
269
+
270
+ # `submit: "Create account"` and `submit: { text: "…", class: "…" }` are the
271
+ # same thing with different amounts of decoration.
272
+ def clickwrap_split_submit(submit)
273
+ case submit
274
+ when Hash
275
+ options = submit.symbolize_keys
276
+ text = options.delete(:text)
277
+
278
+ unless text.is_a?(String) && !text.strip.empty?
279
+ raise ArgumentError,
280
+ "form.clickwrap needs the exact words on the submit button: " \
281
+ "`submit: { text: \"Create account\", class: \"…\" }`. That text is recorded in " \
282
+ "the presentation manifest, so it cannot be inferred."
283
+ end
284
+
285
+ [text, options]
286
+ when String, Symbol
287
+ [submit.to_s, {}]
288
+ else
289
+ raise ArgumentError,
290
+ "form.clickwrap needs `submit:` to be the button text, or a hash of `text:` plus " \
291
+ "ordinary HTML options. Got #{submit.inspect}."
292
+ end
293
+ end
294
+
295
+ def clickwrap_verify_split_submit_button!(html)
296
+ control = Loofah.fragment(html.to_s).css("input[type='submit'], button[type='submit'], button:not([type])").first
297
+ actual = control&.name == "input" ? control["value"] : control&.text
298
+ expected = @clickwrap_expected_submit_button_text
299
+
300
+ unless actual == expected
301
+ raise ConfigurationError,
302
+ "The Clickwrap presentation records #{expected.inspect} as the submit button text, " \
303
+ "but form.submit rendered #{actual.inspect}. Use the same exact words, or call " \
304
+ "form.clickwrap_submit so the signed wording is the only source of truth."
305
+ end
306
+
307
+ @clickwrap_expected_submit_button_text = nil
308
+ end
309
+
310
+ # The signed-in actor, resolved through the host's configured controller
311
+ # method, exactly as the rest of the gem resolves it. A form that has no
312
+ # actor yet (signup) passes `actor: nil` explicitly and gets a
313
+ # prospective-actor presentation instead of a fabricated one.
314
+ def clickwrap_actor_from_view_context
315
+ method_name = Clickwrap.config.current_actor_method_name
316
+ return nil unless @template.respond_to?(method_name, true)
317
+
318
+ @template.send(method_name)
319
+ end
320
+
321
+ def clickwrap_tenant_from_view_context(policy_key)
322
+ controller = @template.try(:controller)
323
+ return nil if controller.nil?
324
+
325
+ ControllerHelpers.resolve_current_tenant(controller, Clickwrap.policy!(policy_key))
326
+ end
327
+
328
+ def clickwrap_locale_from_view_context
329
+ defined?(::I18n) ? ::I18n.locale : nil
330
+ end
331
+
332
+ # Server-side validation errors from a failed capture, so a no-JavaScript
333
+ # re-render shows what went wrong beside the control it went wrong on. The
334
+ # controller helper exposes this hash; an unaware host simply gets none.
335
+ def clickwrap_errors_from_view_context
336
+ return {} unless @template.respond_to?(:clickwrap_errors, true)
337
+
338
+ @template.send(:clickwrap_errors) || {}
339
+ end
340
+ end
341
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # The leading YAML front-matter block — the Jekyll/Sitepress convention that
5
+ # content-file Rails apps already use to describe their own pages:
6
+ #
7
+ # ---
8
+ # title: Terms of Service
9
+ # last_updated: 2026-01-01
10
+ # ---
11
+ #
12
+ # Clickwrap reads it in exactly two places, and this module is both so the
13
+ # two can never disagree about what counts as front matter: the Markdown
14
+ # renderer strips the block from the RENDERED representation (the source
15
+ # digest still covers the exact file bytes, front matter included), and a
16
+ # document declared without a `version:` resolves its version label from the
17
+ # block's own `clickwrap_version:` or `last_updated:` key — the file that IS
18
+ # the legal text also names its own version, so there is no second copy of
19
+ # the label anywhere to drift.
20
+ module FrontMatter
21
+ # `---` opens; `---` or `...` closes (both are valid YAML document ends).
22
+ LEADING_BLOCK = /\A---\s*\n(.*?)\n(?:---|\.\.\.)\s*(?:\n|\z)/m
23
+
24
+ # `clickwrap_version:` outranks `last_updated:` on purpose: a same-day
25
+ # point release (a typo fix that still changes bytes) needs a fresh label
26
+ # while the human-facing date stays put.
27
+ VERSION_LABEL_KEYS = %w[clickwrap_version last_updated].freeze
28
+
29
+ def self.strip(text)
30
+ text.sub(LEADING_BLOCK, "")
31
+ end
32
+
33
+ # The version label the front matter declares, or nil when the bytes carry
34
+ # no front matter or no version key. Only simple top-level `key: value`
35
+ # lines are read — a version label is a short string, and anything that
36
+ # needs real YAML structure to express is not a version label.
37
+ def self.version_label_in(bytes)
38
+ block = bytes.to_s[LEADING_BLOCK, 1]
39
+ return nil if block.nil?
40
+
41
+ pairs = block.scan(/^(\w+):[ \t]*(.+?)[ \t]*$/).to_h
42
+ VERSION_LABEL_KEYS.each do |key|
43
+ value = unquote(pairs[key])
44
+ return value unless value.nil?
45
+ end
46
+ nil
47
+ end
48
+
49
+ def self.unquote(value)
50
+ return nil if value.nil?
51
+
52
+ unquoted = value.strip
53
+ unquoted = if (unquoted.start_with?('"') && unquoted.end_with?('"')) ||
54
+ (unquoted.start_with?("'") && unquoted.end_with?("'"))
55
+ unquoted[1..-2].to_s
56
+ else
57
+ # A trailing YAML comment is not part of the value —
58
+ # `last_updated: 2026-11-01 # was 2026-08-15` names the label
59
+ # "2026-11-01", exactly as YAML reads it. Inside quotes, a hash is
60
+ # just a character.
61
+ unquoted.sub(/\s+#.*\z/, "")
62
+ end
63
+ unquoted.empty? ? nil : unquoted
64
+ end
65
+ private_class_method :unquote
66
+ end
67
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Clickwrap
6
+ # Event identifiers.
7
+ #
8
+ # Clickwrap events use ULIDs (https://github.com/ulid/spec): 26 Crockford
9
+ # base32 characters, the first ten encoding milliseconds since the Unix
10
+ # epoch. They sort lexicographically in creation order, which keeps evidence
11
+ # exports and index scans in a sensible sequence, and they carry no host
12
+ # database sequence a reader could use to count unrelated records.
13
+ #
14
+ # The embedded timestamp is a convenience for ordering. It is not the
15
+ # evidentiary time: that is `recorded_at_by_server` on the event, recorded
16
+ # from the application server's clock and described as exactly that.
17
+ module Identifier
18
+ ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
19
+ ENCODING_LENGTH = 32
20
+ TIME_LENGTH = 10
21
+ RANDOM_LENGTH = 16
22
+ LENGTH = TIME_LENGTH + RANDOM_LENGTH
23
+
24
+ PATTERN = /\A[0-7][#{ENCODING}]{25}\z/
25
+
26
+ MUTEX = Mutex.new
27
+ private_constant :MUTEX
28
+
29
+ class << self
30
+ # Returns a new ULID. `moment` is accepted so tests and importers can
31
+ # produce deterministic, correctly ordered identifiers.
32
+ #
33
+ # Identifiers generated within the same millisecond increment instead of
34
+ # re-randomizing, so a batch of events captured together still sorts in
35
+ # the order it was written. That matters for exports and for reading a
36
+ # lifecycle chain by identifier alone.
37
+ def generate(moment = Time.now)
38
+ MUTEX.synchronize { monotonic_ulid(encode_time(moment)) }
39
+ end
40
+
41
+ def valid?(value)
42
+ PATTERN.match?(value.to_s)
43
+ end
44
+
45
+ # Returns the Time encoded in the identifier, or nil when it is not a
46
+ # ULID. Callers must not treat this as the recorded server time.
47
+ def time_from(value)
48
+ return nil unless valid?(value)
49
+
50
+ milliseconds = value.to_s[0, TIME_LENGTH].each_char.reduce(0) do |total, char|
51
+ (total * ENCODING_LENGTH) + ENCODING.index(char)
52
+ end
53
+
54
+ Time.at(milliseconds / 1000.0).utc
55
+ end
56
+
57
+ private
58
+
59
+ def monotonic_ulid(time_part)
60
+ @last_time_part = nil unless defined?(@last_time_part)
61
+
62
+ random_part =
63
+ if time_part == @last_time_part
64
+ increment(@last_random_part)
65
+ else
66
+ encode_random
67
+ end
68
+
69
+ @last_time_part = time_part
70
+ @last_random_part = random_part
71
+ time_part + random_part
72
+ end
73
+
74
+ # Adds one to a Crockford base32 string, rolling over from the right. On
75
+ # the vanishingly unlikely overflow of every character in one
76
+ # millisecond, start from a fresh random value rather than wrapping to a
77
+ # smaller identifier.
78
+ def increment(random_part)
79
+ characters = random_part.chars
80
+
81
+ (characters.length - 1).downto(0) do |index|
82
+ position = ENCODING.index(characters[index])
83
+
84
+ if position < ENCODING_LENGTH - 1
85
+ characters[index] = ENCODING[position + 1]
86
+ return characters.join
87
+ end
88
+
89
+ characters[index] = ENCODING[0]
90
+ end
91
+
92
+ encode_random
93
+ end
94
+
95
+ def encode_time(moment)
96
+ milliseconds = (moment.to_f * 1000).floor
97
+ buffer = +""
98
+
99
+ TIME_LENGTH.times do
100
+ buffer.prepend(ENCODING[milliseconds % ENCODING_LENGTH])
101
+ milliseconds /= ENCODING_LENGTH
102
+ end
103
+
104
+ buffer
105
+ end
106
+
107
+ def encode_random
108
+ Array.new(RANDOM_LENGTH) { ENCODING[SecureRandom.random_number(ENCODING_LENGTH)] }.join
109
+ end
110
+ end
111
+ end
112
+ end