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.
- checksums.yaml +4 -4
- data/.rubocop.yml +90 -0
- data/CHANGELOG.md +650 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +245 -0
- data/app/controllers/clickwrap/application_controller.rb +79 -0
- data/app/controllers/clickwrap/captures_controller.rb +145 -0
- data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
- data/app/controllers/clickwrap/receipts_controller.rb +115 -0
- data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
- data/app/helpers/clickwrap/engine_helper.rb +97 -0
- data/app/views/clickwrap/captures/show.html.erb +34 -0
- data/app/views/clickwrap/receipts/index.html.erb +38 -0
- data/app/views/clickwrap/receipts/show.html.erb +91 -0
- data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
- data/app/views/clickwrap/shared/_fields.html.erb +100 -0
- data/app/views/clickwrap/shared/_statement.html.erb +105 -0
- data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
- data/config/locales/en.yml +160 -0
- data/config/locales/es.yml +154 -0
- data/config/routes.rb +41 -0
- data/exe/clickwrap +374 -0
- data/guides/README.md +30 -0
- data/guides/accessibility.md +249 -0
- data/guides/consent-and-lifecycle.md +299 -0
- data/guides/integrating.md +610 -0
- data/guides/integrity.md +212 -0
- data/guides/migrating.md +335 -0
- data/guides/naming.md +320 -0
- data/guides/organizations.md +320 -0
- data/guides/receipts-and-verification.md +415 -0
- data/guides/request-evidence.md +512 -0
- data/guides/retention-and-legal-holds.md +438 -0
- data/lib/clickwrap/actor_proxy.rb +147 -0
- data/lib/clickwrap/anonymous_actor.rb +47 -0
- data/lib/clickwrap/authority.rb +174 -0
- data/lib/clickwrap/canonical_json.rb +216 -0
- data/lib/clickwrap/capture/event_builder.rb +220 -0
- data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
- data/lib/clickwrap/capture.rb +650 -0
- data/lib/clickwrap/configuration.rb +1129 -0
- data/lib/clickwrap/controller_helpers.rb +758 -0
- data/lib/clickwrap/current_state.rb +282 -0
- data/lib/clickwrap/digest.rb +125 -0
- data/lib/clickwrap/doctor.rb +418 -0
- data/lib/clickwrap/document_definition.rb +255 -0
- data/lib/clickwrap/document_renderer.rb +83 -0
- data/lib/clickwrap/document_renderers/markdown.rb +175 -0
- data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
- data/lib/clickwrap/dsl/policy_builder.rb +462 -0
- data/lib/clickwrap/dsl/retention_builder.rb +89 -0
- data/lib/clickwrap/durable_commit_callback.rb +37 -0
- data/lib/clickwrap/engine.rb +187 -0
- data/lib/clickwrap/errors.rb +181 -0
- data/lib/clickwrap/form_builder_extensions.rb +341 -0
- data/lib/clickwrap/front_matter.rb +67 -0
- data/lib/clickwrap/identifier.rb +112 -0
- data/lib/clickwrap/import/external_receipt.rb +241 -0
- data/lib/clickwrap/import/fine_print.rb +290 -0
- data/lib/clickwrap/import/legacy.rb +450 -0
- data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
- data/lib/clickwrap/integrity/anchor.rb +130 -0
- data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
- data/lib/clickwrap/integrity/attestor.rb +221 -0
- data/lib/clickwrap/integrity/chain.rb +313 -0
- data/lib/clickwrap/integrity/timestamp.rb +143 -0
- data/lib/clickwrap/ip_geolocation/location.rb +112 -0
- data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
- data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
- data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
- data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
- data/lib/clickwrap/ip_geolocation.rb +16 -0
- data/lib/clickwrap/lifecycle.rb +534 -0
- data/lib/clickwrap/linter.rb +382 -0
- data/lib/clickwrap/localized_text.rb +101 -0
- data/lib/clickwrap/macros.rb +203 -0
- data/lib/clickwrap/models/application_record.rb +20 -0
- data/lib/clickwrap/models/chain_head.rb +79 -0
- data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
- data/lib/clickwrap/models/disposition_plan.rb +208 -0
- data/lib/clickwrap/models/document.rb +46 -0
- data/lib/clickwrap/models/document_version.rb +163 -0
- data/lib/clickwrap/models/event.rb +743 -0
- data/lib/clickwrap/models/event_document.rb +79 -0
- data/lib/clickwrap/models/event_statement.rb +92 -0
- data/lib/clickwrap/models/external_action.rb +150 -0
- data/lib/clickwrap/models/integrity_attestation.rb +90 -0
- data/lib/clickwrap/models/legal_hold.rb +81 -0
- data/lib/clickwrap/models/policy_revision.rb +115 -0
- data/lib/clickwrap/models/presentation.rb +59 -0
- data/lib/clickwrap/models/receipt_access.rb +53 -0
- data/lib/clickwrap/models/recording_sequence.rb +21 -0
- data/lib/clickwrap/models/request_evidence.rb +378 -0
- data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
- data/lib/clickwrap/models/statement_state.rb +130 -0
- data/lib/clickwrap/pending_receipt.rb +177 -0
- data/lib/clickwrap/policy.rb +283 -0
- data/lib/clickwrap/presentation_manifest.rb +210 -0
- data/lib/clickwrap/presenter.rb +729 -0
- data/lib/clickwrap/privacy.rb +419 -0
- data/lib/clickwrap/protected_outcome.rb +120 -0
- data/lib/clickwrap/receipt.rb +606 -0
- data/lib/clickwrap/receipt_html.rb +235 -0
- data/lib/clickwrap/receipt_verifier.rb +978 -0
- data/lib/clickwrap/reference.rb +44 -0
- data/lib/clickwrap/registration.rb +236 -0
- data/lib/clickwrap/registry.rb +54 -0
- data/lib/clickwrap/remediation_token.rb +155 -0
- data/lib/clickwrap/request_evidence_extractor.rb +590 -0
- data/lib/clickwrap/request_evidence_policy.rb +261 -0
- data/lib/clickwrap/retention/applier.rb +231 -0
- data/lib/clickwrap/retention/disposition.rb +221 -0
- data/lib/clickwrap/retention/planner.rb +502 -0
- data/lib/clickwrap/retention_class.rb +97 -0
- data/lib/clickwrap/reviewed_text.rb +28 -0
- data/lib/clickwrap/schema_requirements.rb +196 -0
- data/lib/clickwrap/services/authorize_external_action.rb +149 -0
- data/lib/clickwrap/services/load_policies.rb +69 -0
- data/lib/clickwrap/services/publish_documents.rb +251 -0
- data/lib/clickwrap/services/validate_policy_references.rb +166 -0
- data/lib/clickwrap/statement.rb +248 -0
- data/lib/clickwrap/subject_fingerprint.rb +28 -0
- data/lib/clickwrap/submission.rb +169 -0
- data/lib/clickwrap/system_actor.rb +31 -0
- data/lib/clickwrap/test_helpers.rb +676 -0
- data/lib/clickwrap/testing.rb +211 -0
- data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
- data/lib/clickwrap/verification.rb +504 -0
- data/lib/clickwrap/version.rb +12 -1
- data/lib/clickwrap/view_helpers.rb +190 -0
- data/lib/clickwrap/vocabulary.rb +294 -0
- data/lib/clickwrap.rb +497 -7
- data/lib/generators/clickwrap/document_generator.rb +164 -0
- data/lib/generators/clickwrap/hardening_generator.rb +177 -0
- data/lib/generators/clickwrap/install_generator.rb +1287 -0
- data/lib/generators/clickwrap/link_generator.rb +56 -0
- data/lib/generators/clickwrap/policy_generator.rb +118 -0
- data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
- data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
- data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
- data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
- data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
- data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
- data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
- data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
- data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
- data/lib/generators/clickwrap/views_generator.rb +101 -0
- data/lib/tasks/clickwrap.rake +569 -0
- 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
|