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.
- checksums.yaml +4 -4
- data/.rubocop.yml +90 -0
- data/CHANGELOG.md +612 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +241 -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 +138 -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 +184 -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 +716 -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,610 @@
|
|
|
1
|
+
# Integrating clickwrap into a real application
|
|
2
|
+
|
|
3
|
+
The battle-tested playbook. Everything in this guide was learned by migrating
|
|
4
|
+
a production Rails app (Rails 8.1, PostGIS, UUID keys, Devise, Hotwire Native,
|
|
5
|
+
Spanish-first, a legacy acceptance ledger, and a wallet-debiting money path)
|
|
6
|
+
onto this gem, surface by surface, with the full test suite green after every
|
|
7
|
+
step. Follow it in order and you will not rediscover our mistakes.
|
|
8
|
+
|
|
9
|
+
It is written for humans and for AI agents alike: exact orders of operations,
|
|
10
|
+
the boot errors you will meet and what they mean, and the patterns that
|
|
11
|
+
survived contact with production code.
|
|
12
|
+
|
|
13
|
+
## 0. The mental model in five lines
|
|
14
|
+
|
|
15
|
+
1. **Documents** are frozen bytes with digests. Publishing is explicit.
|
|
16
|
+
2. **Policies** are server-owned offers. The browser answers; it never chooses.
|
|
17
|
+
3. **Presentations** are signed, short-lived, session-bound tokens. They
|
|
18
|
+
cannot be fabricated — not by attackers, and not by your tests.
|
|
19
|
+
4. **Captures** commit evidence and your protected action in one transaction.
|
|
20
|
+
5. **Verification** answers questions from the projection, live, with stable
|
|
21
|
+
error symbols and predicates.
|
|
22
|
+
|
|
23
|
+
## 1. Install, in this exact order
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
# Gemfile — `bundle add clickwrap` would install 0.0.0, a deliberately empty
|
|
27
|
+
# name placeholder on RubyGems. Install from GitHub until the first real
|
|
28
|
+
# version is published there.
|
|
29
|
+
gem "clickwrap", github: "rameerez/clickwrap"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
bundle install
|
|
34
|
+
bin/rails generate clickwrap:install # answers adapt to your app; say no to
|
|
35
|
+
# every request-evidence question first
|
|
36
|
+
bin/rails db:migrate
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then, before anything else works:
|
|
40
|
+
|
|
41
|
+
1. **Point the documents at your REAL legal content**, not generated
|
|
42
|
+
placeholders. If your legal pages already live at the Sitepress convention
|
|
43
|
+
(`app/content/pages/legal/terms.html.md` + `privacy.html.md`), the installer
|
|
44
|
+
detects them, points `from:` at those exact files, and writes no
|
|
45
|
+
placeholders — the text people accept and the text your `/legal` routes
|
|
46
|
+
serve must be one file, so they cannot drift. If your legal text lives
|
|
47
|
+
anywhere else, edit the two `from:` lines yourself. Then pick the renderer
|
|
48
|
+
that matches those pages: `config.document_renderer = :markdown` renders
|
|
49
|
+
through whichever Markdown library you already bundle (front matter
|
|
50
|
+
stripped), and `:markdown_rails` renders through your application's *own*
|
|
51
|
+
registered markdown-rails renderer — the exact pipeline those pages already
|
|
52
|
+
go through, which is what makes the accepted snapshot byte-for-byte the
|
|
53
|
+
rendered text those pages serve instead of merely similar to it.
|
|
54
|
+
2. **Let each file name its own version.** A page with leading YAML front
|
|
55
|
+
matter carrying `clickwrap_version:` or `last_updated:` names its own
|
|
56
|
+
label, so there is no `version:` line in `config/clickwrap.rb` and no second
|
|
57
|
+
copy to drift:
|
|
58
|
+
|
|
59
|
+
```markdown
|
|
60
|
+
---
|
|
61
|
+
title: Terms of Service
|
|
62
|
+
last_updated: 2026-08-15
|
|
63
|
+
---
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`clickwrap_version:` wins when both are present — that is how a same-day
|
|
67
|
+
correction gets a fresh label while the date readers see stays put.
|
|
68
|
+
Trailing `# comments` on the version line are read as YAML reads them:
|
|
69
|
+
not part of the label. If the app has a `TERMS_CURRENT_VERSION`
|
|
70
|
+
constant, move its exact value into the file's front matter and keep the
|
|
71
|
+
constant in lockstep until it retires.
|
|
72
|
+
Pass `version:` explicitly only for a source that cannot carry front matter
|
|
73
|
+
(a PDF, an HTML fragment, a `resolver:` whose bytes are read at publish
|
|
74
|
+
time). New text = new label; reusing a label for different bytes is refused
|
|
75
|
+
at publish, and a file with neither key and no `version:` fails the boot
|
|
76
|
+
with the fix in the sentence.
|
|
77
|
+
|
|
78
|
+
Your own pages usually need the same two answers from the same block, so
|
|
79
|
+
read it with the same reader instead of writing a third one:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
Clickwrap::FrontMatter.version_label_in(File.read(path)) # "2026-08-15", or nil
|
|
83
|
+
Clickwrap::FrontMatter.strip(File.read(path)) # the body, block removed
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
It takes a leading `---` block closed by `---` or `...`, reads simple
|
|
87
|
+
top-level `key: value` lines only, unquotes a quoted value, and drops an
|
|
88
|
+
unquoted trailing YAML comment — the two places every hand-rolled reader
|
|
89
|
+
eventually disagrees with this one, and then a page and its receipt name
|
|
90
|
+
different versions of the same file. `strip` affects the RENDERED
|
|
91
|
+
representation only; the source digest still covers the exact bytes, front
|
|
92
|
+
matter included, because that is what the file was.
|
|
93
|
+
3. **Declare a retention class and use it.** `retain_with` is mandatory on
|
|
94
|
+
every policy, on purpose — mark the period `TODO(counsel)` if you must,
|
|
95
|
+
but pick one. If the legacy system kept evidence forever, any finite
|
|
96
|
+
period is a tightening; say so in the comment.
|
|
97
|
+
4. `bin/rails clickwrap:publish`, then `bin/rails clickwrap:doctor`. The
|
|
98
|
+
doctor's output is your integration checklist from here on. That is the
|
|
99
|
+
only time you publish by hand: publishing rides `db:prepare`, so a deploy
|
|
100
|
+
that runs it also freezes the snapshots for what it declared, before the
|
|
101
|
+
server takes traffic. It is idempotent, silent when nothing is
|
|
102
|
+
declared, and a sentence rather than a crash when the tables are not
|
|
103
|
+
migrated yet — but a real refusal (a reused label over changed bytes)
|
|
104
|
+
fails the deploy out loud, which is what you want: the alternative is
|
|
105
|
+
signups failing quietly some hours later. Opt out with
|
|
106
|
+
`config.publish_documents_after_database_preparation = false`.
|
|
107
|
+
|
|
108
|
+
If a client needs special navigation attributes, keep the canonical partial
|
|
109
|
+
and configure the one narrow seam instead of ejecting it:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
config.document_link_html_options_with = lambda do |_document|
|
|
113
|
+
{ target: "_blank", rel: "noopener", data: { turbo: false } }
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
This callback may choose how the client opens the immutable URL. It cannot
|
|
118
|
+
return `href:`: the exact href is rendered from, and signed into, the same
|
|
119
|
+
presentation manifest.
|
|
120
|
+
|
|
121
|
+
Shipping a Hotwire Native app? Declare the answer once instead, and get both
|
|
122
|
+
halves — the href and the attributes — from the same setting:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
config.hotwire_native_document_links = {
|
|
126
|
+
open_in: :external_browser,
|
|
127
|
+
canonical_host: "https://www.example.com"
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
We learned this on a native authentication sheet: a same-host document link is
|
|
132
|
+
routed by the app itself, which pops the sheet and takes the half-filled signup
|
|
133
|
+
form with it. `:external_browser` absolutizes the signed path against your
|
|
134
|
+
canonical host (which may be a callable, and must be `https`) and opens it
|
|
135
|
+
outside the WebView, so the form survives the round trip. `:same_screen` keeps
|
|
136
|
+
a plain same-host link for your own native path configuration to route. When
|
|
137
|
+
this is set it answers native renders entirely and the callback above keeps
|
|
138
|
+
answering everything else — so an app that needs different answers on different
|
|
139
|
+
screens sets nothing here and stays with the lambda.
|
|
140
|
+
|
|
141
|
+
Boot errors you may meet, all working as intended:
|
|
142
|
+
|
|
143
|
+
| Error says | It means |
|
|
144
|
+
|---|---|
|
|
145
|
+
| "presents document X but no declaration exists" | An `acknowledge`/`agree_to` defaults its document to its own key. Point `document:` at a real document — or say `document: nil` for an operational fact whose statement text is the whole notice. |
|
|
146
|
+
| "has no retention class" | Add `retain_with`. The gem will not default evidence to forever. |
|
|
147
|
+
| "no published version … is effective" | You declared but didn't publish, or the locale doesn't match. Run `clickwrap:publish`; check `locale:`. |
|
|
148
|
+
| "unknown option" anything | Options are allowlisted. The error names the valid set — a typo'd option can never silently disable a rule. |
|
|
149
|
+
|
|
150
|
+
## 2. Test setup — do this before your first integration test
|
|
151
|
+
|
|
152
|
+
Presentations refuse unpublished documents in tests exactly as in
|
|
153
|
+
production, and signed tokens are session-bound so tests cannot mint them by
|
|
154
|
+
hand. Both facts produce the same two-part setup:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
# test/test_helper.rb
|
|
158
|
+
class ActiveSupport::TestCase
|
|
159
|
+
include Clickwrap::TestHelpers
|
|
160
|
+
parallelize_setup { Clickwrap.publish! } # once per parallel worker...
|
|
161
|
+
end
|
|
162
|
+
Clickwrap.publish! # ...and once per process, for
|
|
163
|
+
# the runs Rails does not fork
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Model and service tests get evidence from `submit_clickwrap`, which presents
|
|
167
|
+
the policy through the real presenter, answers it, and captures — raising when
|
|
168
|
+
the capture is refused, because in a test a failed capture is a failed test:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
receipt = submit_clickwrap(:signup, actor: user, answers: { terms: true, privacy_notice: true })
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
It is deliberately a different verb from the controller's `capture_clickwrap`,
|
|
175
|
+
which captures a submission a person actually sent and absorbs refusals into
|
|
176
|
+
`false`. One name with two opposite answers to "what happens when this is
|
|
177
|
+
refused" is not a helper.
|
|
178
|
+
|
|
179
|
+
Integration tests then read the token off the rendered page, the way a
|
|
180
|
+
browser does:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
post user_registration_path, params: {
|
|
184
|
+
user: { email: "person@example.com", password: "a-real-password" },
|
|
185
|
+
**clickwrap_params_from(new_user_registration_path) # GET the page, affirm all
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# Decline one statement instead:
|
|
189
|
+
declined = clickwrap_params_from(some_path, answers: { terms: false })
|
|
190
|
+
|
|
191
|
+
# Radio choices use their exact rendered values. The first rendered choice is
|
|
192
|
+
# the default; pass the domain choice when your test depends on it:
|
|
193
|
+
contractor = clickwrap_params_from(
|
|
194
|
+
some_path,
|
|
195
|
+
answers: { employment_kind: "contractor" }
|
|
196
|
+
)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Checkbox statements default to their affirmative value. Radio statements
|
|
200
|
+
default to the first choice rendered by the application. This keeps the helper
|
|
201
|
+
browser-faithful: it never substitutes the checkbox value `"1"` for an offered
|
|
202
|
+
choice such as `"employee"`. Pass an exact choice key when the choice matters.
|
|
203
|
+
For conventional `yes`/`no` radio groups, `false` selects `no`.
|
|
204
|
+
|
|
205
|
+
If a page renders several independent Clickwrap forms, select the exact form.
|
|
206
|
+
The helper refuses an ambiguous page rather than mixing one form's signed token
|
|
207
|
+
with another form's answers:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
submission = clickwrap_submission_params_from(
|
|
211
|
+
response,
|
|
212
|
+
form_css_selector: "form[action='/withdrawals/confirm']"
|
|
213
|
+
)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
For service-level tests with no page in the loop, mint the submission
|
|
217
|
+
directly: `submission_for(present_clickwrap(:policy, actor:, ...), answers)`.
|
|
218
|
+
|
|
219
|
+
Two patterns from the trenches:
|
|
220
|
+
|
|
221
|
+
- **Multi-step funnels**: write a helper that GETs the funnel page, extracts
|
|
222
|
+
whatever presentation is on it, POSTs it to the matching gate, and repeats.
|
|
223
|
+
A funnel parked on a non-gate step simply walks zero gates — exactly like
|
|
224
|
+
the person it simulates. Dispatch on which answer keys the extracted
|
|
225
|
+
params contain.
|
|
226
|
+
- **Stubbing around captures**: mint the signed params BEFORE installing
|
|
227
|
+
stubs that count calls — the GET that renders a page may itself trigger
|
|
228
|
+
the code you are counting.
|
|
229
|
+
|
|
230
|
+
## 3. Signup (Devise or Rails authentication)
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
class Users::RegistrationsController < Devise::RegistrationsController
|
|
234
|
+
clickwraps_registration_with :signup
|
|
235
|
+
end
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The adapter wraps exactly one thing — `resource.save` — so a heavily
|
|
239
|
+
customized `create` (bot checks, native handoffs, invitation prefills,
|
|
240
|
+
attribution) keeps working untouched. Account and evidence commit together;
|
|
241
|
+
refusals (stale token, missing box) re-render the form with localized
|
|
242
|
+
sentences, inline beside the control.
|
|
243
|
+
|
|
244
|
+
A hand-rolled door — Rails' authentication generator, an OAuth finish screen,
|
|
245
|
+
your own registration service — gets the same behavior from the non-bang
|
|
246
|
+
helper, which absorbs those same refusals into those same sentences and
|
|
247
|
+
returns false:
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
def create
|
|
251
|
+
@user = User.new(user_params)
|
|
252
|
+
|
|
253
|
+
unless register_with_clickwrap(:signup, user: @user) { @user.save! }
|
|
254
|
+
return render :new, status: :unprocessable_entity
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
start_new_session_for @user
|
|
258
|
+
redirect_to after_authentication_url
|
|
259
|
+
end
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`register_with_clickwrap!` is the raising half of the pair, for a flow that
|
|
263
|
+
handles the exceptions itself. Neither form absorbs an infrastructure failure:
|
|
264
|
+
a `Clickwrap::EventWriteFailed` escapes both, so the sign-in and the welcome
|
|
265
|
+
email that would follow never happen. Do not add a rescue that changes that.
|
|
266
|
+
|
|
267
|
+
**Migrating from a legacy checkbox?** Keep every required legacy evidence write
|
|
268
|
+
*inside* the new transaction during the transition:
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
clickwraps_registration_with :signup,
|
|
272
|
+
after_account_is_saved_inside_transaction: :record_legacy_acceptance!
|
|
273
|
+
|
|
274
|
+
private
|
|
275
|
+
|
|
276
|
+
def record_legacy_acceptance!(account:, pending_receipt:)
|
|
277
|
+
account.terms_acceptances.create!(
|
|
278
|
+
accepted_at: Time.current,
|
|
279
|
+
clickwrap_event_id: pending_receipt.event_id
|
|
280
|
+
)
|
|
281
|
+
end
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
The callback runs after the account save but before the shared transaction can
|
|
285
|
+
commit. Do not rescue it: a required legacy-write failure must roll the account
|
|
286
|
+
and Clickwrap event back together. Assert the parity contract in one test:
|
|
287
|
+
Clickwrap predicates true AND legacy evidence stamped.
|
|
288
|
+
|
|
289
|
+
**Public forms with no authenticated account** — a lead magnet, newsletter, or
|
|
290
|
+
waitlist — must not find an existing actor by the visitor's typed email and bind
|
|
291
|
+
evidence to it. Knowing an address is not proof of controlling it. Use a
|
|
292
|
+
separate pending-request row, send a single-purpose confirmation link, and only
|
|
293
|
+
capture consent for the real actor after that link verifies mailbox control:
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
request = LeadSignupRequest.create!(email: params[:email])
|
|
297
|
+
LeadSignupMailer.confirm(request).deliver_later
|
|
298
|
+
|
|
299
|
+
# After the signed, expiring email link resolves the request:
|
|
300
|
+
lead = Lead.find_or_create_by!(email: request.email)
|
|
301
|
+
capture_clickwrap!(:marketing_preferences, actor: lead)
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
The initial mail may deliver the requested transactional item. It must not
|
|
305
|
+
silently turn the form submit into marketing permission. Model the later box as
|
|
306
|
+
`consent_to ..., optional: true`: an unticked box records that the option was
|
|
307
|
+
offered and not taken, silence being neither refusal nor grant. Give it a real
|
|
308
|
+
`withdrawal_path:` where a signed email-footer token can call
|
|
309
|
+
`Clickwrap.withdraw!`; repeated withdrawal remains friendly and distinct from
|
|
310
|
+
"never granted".
|
|
311
|
+
|
|
312
|
+
## 4. Custom surfaces — the three contracts
|
|
313
|
+
|
|
314
|
+
Anything that is not a plain `form.clickwrap` renders three things that fail
|
|
315
|
+
*silently* when hand-typed wrong. The helpers own them; you own every class
|
|
316
|
+
and wrapper:
|
|
317
|
+
|
|
318
|
+
```erb
|
|
319
|
+
<% presentation = Clickwrap.present(:withdrawal_preparation, actor: current_user,
|
|
320
|
+
submit_button_text: "He leído todo: empezar") %>
|
|
321
|
+
<%= clickwrap_presentation_token_field(presentation) %>
|
|
322
|
+
|
|
323
|
+
<% if presentation.combined %>
|
|
324
|
+
<%= clickwrap_statement_check_box(presentation.combined, class: "your-checkbox") %>
|
|
325
|
+
<%= label_tag presentation.combined.control_id,
|
|
326
|
+
clickwrap_combined_sentence(presentation.combined) %>
|
|
327
|
+
<% end %>
|
|
328
|
+
|
|
329
|
+
<% presentation.itemized_statements.each do |statement| %>
|
|
330
|
+
<%= clickwrap_statement_check_box(statement, class: "your-checkbox") %>
|
|
331
|
+
<%= label_tag statement.control_id, statement.assertion %>
|
|
332
|
+
<% end %>
|
|
333
|
+
|
|
334
|
+
<%= clickwrap_submit_button(presentation, class: "your-button") %>
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Iterate `itemized_statements`, never `statements`: on a policy that composed, the
|
|
338
|
+
statements the line already covers have no control of their own, and rendering
|
|
339
|
+
one for each would offer a choice nobody has. (Present with `combined: false` if
|
|
340
|
+
you want a control per statement — then `combined` is nil and
|
|
341
|
+
`itemized_statements` is every statement, so the same template does both.)
|
|
342
|
+
|
|
343
|
+
`clickwrap_submit_button` is worded by the signed manifest itself — the CTA
|
|
344
|
+
is written once, at present time, so the recorded words and the pressed words
|
|
345
|
+
cannot drift.
|
|
346
|
+
|
|
347
|
+
### Keeping `form.clickwrap_fields` and your own button
|
|
348
|
+
|
|
349
|
+
If you want the gem's controls but your design system's button markup, take the
|
|
350
|
+
block form rather than dropping to a fully custom surface. The block is yielded
|
|
351
|
+
the presentation, so the wording still comes from the signed manifest:
|
|
352
|
+
|
|
353
|
+
```erb
|
|
354
|
+
<%= form.clickwrap_fields :signup, submit_button_text: "Create account" do |clickwrap| %>
|
|
355
|
+
<button type="submit" class="btn btn--primary" data-turbo-submits-with="Creating…">
|
|
356
|
+
<%= clickwrap.submit_button_text %>
|
|
357
|
+
</button>
|
|
358
|
+
<% end %>
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
`submit:` on `form.clickwrap` and `submit_button_text:` on
|
|
362
|
+
`form.clickwrap_fields` are a deliberate pair, not a duplication:
|
|
363
|
+
|
|
364
|
+
| | What it means |
|
|
365
|
+
|---|---|
|
|
366
|
+
| `form.clickwrap :signup, submit: "Create account"` | Bind these words **and render the button**. |
|
|
367
|
+
| `form.clickwrap_fields :signup, submit_button_text: "Create account"` | Bind these words; **I render the action myself**. |
|
|
368
|
+
|
|
369
|
+
With `clickwrap_fields`, render the action one of three ways:
|
|
370
|
+
`form.clickwrap_submit` (Rails' button, wording reused automatically), the
|
|
371
|
+
block above (your markup, wording read off the presentation), or an ordinary
|
|
372
|
+
`form.submit "Create account"` — which Clickwrap checks against the signed
|
|
373
|
+
wording and refuses if the two disagree. A raw `<button>` outside the block
|
|
374
|
+
cannot be checked at all, which is exactly why the block exists.
|
|
375
|
+
|
|
376
|
+
Hard-won rules for custom surfaces:
|
|
377
|
+
|
|
378
|
+
- **Let the policy own the on-screen words.** Render `statement.assertion` as
|
|
379
|
+
the visible text (a radio card's description, a declaration's body). One
|
|
380
|
+
string on the screen and in the receipt beats two strings and a linter.
|
|
381
|
+
- **Radio-shaped answers**: options share the statement's control name;
|
|
382
|
+
affirmative submits `"1"`, negative submits `"0"`
|
|
383
|
+
(`clickwrap_statement_radio_button(statement, "1")` / `"0"`). Any other
|
|
384
|
+
non-empty value reads as affirmative — never use a semantic word like
|
|
385
|
+
`"professional"` as the negative value.
|
|
386
|
+
- **Nothing preselected, ever.** A preselected control records the page's
|
|
387
|
+
default, not the person's answer. Preserve selections on re-render only.
|
|
388
|
+
- **A dynamic CTA is good evidence**: "Retirar 57,50 €" in the manifest means
|
|
389
|
+
the button they pressed named the amount.
|
|
390
|
+
- Your consent-gate JavaScript (disable submit until every box is ticked) is
|
|
391
|
+
welcome as UX; the server refuses partial answers regardless.
|
|
392
|
+
|
|
393
|
+
## 5. Refusals are one rescue
|
|
394
|
+
|
|
395
|
+
Everything a *person* can cause from a form — stale token, unparseable
|
|
396
|
+
submission, required box left empty — is one family with a message you can
|
|
397
|
+
put in front of them:
|
|
398
|
+
|
|
399
|
+
```ruby
|
|
400
|
+
def create
|
|
401
|
+
# ... capture_clickwrap_and! wrapping the protected action ...
|
|
402
|
+
rescue Clickwrap::CaptureRefused => refusal
|
|
403
|
+
redirect_to somewhere_path, alert: refusal.user_facing_message, status: :see_other
|
|
404
|
+
end
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Drop the bang and you get the same thing without writing the rescue.
|
|
408
|
+
`capture_clickwrap` and `capture_clickwrap_and` absorb exactly that family,
|
|
409
|
+
return `false`, put the per-statement message beside the control, and leave the
|
|
410
|
+
refusal on `clickwrap_refusal`:
|
|
411
|
+
|
|
412
|
+
```ruby
|
|
413
|
+
def create
|
|
414
|
+
receipt = capture_clickwrap_and(:withdrawal_authorization, subject: withdrawal) do
|
|
415
|
+
withdrawal.submit!
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
unless receipt
|
|
419
|
+
flash.now[:alert] = clickwrap_refusal.user_facing_message
|
|
420
|
+
return render :new, status: :unprocessable_entity
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
redirect_to withdrawal
|
|
424
|
+
end
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Everything outside the family (an evidence write failure above all) stays
|
|
428
|
+
loud on purpose, from the non-bang forms too. So do lifecycle conflicts:
|
|
429
|
+
`Clickwrap::ReplayRejected` and `Clickwrap::OneTimeAuthorizationConflict`
|
|
430
|
+
still raise, because "already done" needs a domain answer — usually "treat it
|
|
431
|
+
as done" — that a generic rescue cannot give. Never rescue `Clickwrap::Error`
|
|
432
|
+
wholesale: the fail-closed guarantee is that infrastructure problems refuse
|
|
433
|
+
the protected action.
|
|
434
|
+
|
|
435
|
+
## 6. Link domain rows to their evidence
|
|
436
|
+
|
|
437
|
+
Any row whose existence a capture authorized — a withdrawal, a signed
|
|
438
|
+
declaration, a provisioned contract — gets the one-column link:
|
|
439
|
+
|
|
440
|
+
```bash
|
|
441
|
+
bin/rails generate clickwrap:link payouts_withdrawals && bin/rails db:migrate
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
```ruby
|
|
445
|
+
class Payouts::Withdrawal < ApplicationRecord
|
|
446
|
+
has_clickwrap_evidence policy: :withdrawal_authorization,
|
|
447
|
+
statement: :withdrawal,
|
|
448
|
+
actor: :user,
|
|
449
|
+
subject: :self
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
capture_clickwrap_and!(:withdrawal_authorization) do |pending_receipt|
|
|
453
|
+
withdrawal.clickwrap_event_id = pending_receipt.event_id
|
|
454
|
+
withdrawal.save!
|
|
455
|
+
withdrawal
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
withdrawal.clickwrap_receipt.verify.success? # years later, one line
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
## 7. Protecting a money path (the full pattern)
|
|
462
|
+
|
|
463
|
+
The strongest shape we shipped, for anything where "the user once accepted
|
|
464
|
+
something" is not enough and you need "this exact evidence authorized this
|
|
465
|
+
exact operation":
|
|
466
|
+
|
|
467
|
+
- **One policy per gate**, each with its own `valid_for` freshness. A
|
|
468
|
+
multi-step funnel is multiple policies, not one policy squeezed into one
|
|
469
|
+
page.
|
|
470
|
+
- **Fingerprint the moving parts**: `subject_fingerprint_with:` recomputes
|
|
471
|
+
from committed rows at capture, so anything that changed between render
|
|
472
|
+
and submit refuses the submit instead of signing over a different state.
|
|
473
|
+
An exact retry of an already committed nonce is the deliberate exception:
|
|
474
|
+
Clickwrap verifies it against the frozen event and does not require the
|
|
475
|
+
protected action's pre-action fingerprint to survive its own success.
|
|
476
|
+
- **The final act is `authorize …, one_time: true`,** captured by
|
|
477
|
+
`capture_and!` INSIDE your own locked transaction, wrapping the debit or
|
|
478
|
+
transition itself. `capture_and!` joins an open transaction, so:
|
|
479
|
+
|
|
480
|
+
```ruby
|
|
481
|
+
ActiveRecord::Base.transaction do
|
|
482
|
+
wallet = user.money_wallet.lock!
|
|
483
|
+
# your own rechecks under the lock...
|
|
484
|
+
Clickwrap.capture_and!(:withdrawal_authorization, actor: user, subject: user,
|
|
485
|
+
http_request: request, submission: submission,
|
|
486
|
+
authentication_context: { "method" => "password_reauthentication", ... }) do |pending_receipt|
|
|
487
|
+
withdrawal = debit_and_create_row!(pending_receipt.event_id)
|
|
488
|
+
withdrawal # exact result passed to `record_protected_outcome_with`
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
Verification, one-time consumption (by unique index — a conflicting replay
|
|
494
|
+
cannot debit twice), the evidence event, and your debit commit together or
|
|
495
|
+
not at all.
|
|
496
|
+
- **Service-boundary checks read aloud**:
|
|
497
|
+
|
|
498
|
+
```ruby
|
|
499
|
+
preparation = Clickwrap.verify(:withdrawal_preparation, actor: user,
|
|
500
|
+
require_current_revision: true)
|
|
501
|
+
declaration = Clickwrap.verify(:coverage_exclusivity, actor: user, subject: user,
|
|
502
|
+
require_current_revision: true)
|
|
503
|
+
|
|
504
|
+
declaration.subject_fingerprint_mismatch? # the order set changed
|
|
505
|
+
declaration.stale_policy_revision? # legal reworded it → re-ask
|
|
506
|
+
declaration.recorded_after?(preparation) # order enforced, not assumed
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
`recorded_after?` reads a database-assigned recording sequence, so it holds
|
|
510
|
+
across actors, processes, and same-microsecond writes — ULID lexical order is
|
|
511
|
+
deliberately not chronology. Its `false` carries two meanings: "not after",
|
|
512
|
+
and "the order is not knowable", which is what evidence recorded before the
|
|
513
|
+
ordering migration and a missing event both answer. An upgrade cannot invent
|
|
514
|
+
honest order for rows written before it. Use it as a guard
|
|
515
|
+
(`return unless declaration.recorded_after?(preparation)`); never read a
|
|
516
|
+
`false` as proof that the reverse is true.
|
|
517
|
+
|
|
518
|
+
The same call takes an event id when the question is about one specific
|
|
519
|
+
recorded act rather than "does this actor currently satisfy the policy":
|
|
520
|
+
|
|
521
|
+
```ruby
|
|
522
|
+
Clickwrap.verify(event_id, subject: order_batch, require_current_revision: true)
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
Both keywords do the same work on that branch — the fingerprint is
|
|
526
|
+
re-derived from the record as it is now, and the act's recorded revision is
|
|
527
|
+
compared against the wording compiled today — so a host never reaches into
|
|
528
|
+
`Clickwrap::PolicyRevision` or `Clickwrap::SubjectFingerprint` to ask
|
|
529
|
+
whether stored evidence is still good. A policy that is no longer declared
|
|
530
|
+
answers `:unknown_policy` rather than passing.
|
|
531
|
+
- **Non-browser callers fail closed.** Pass `submission: nil` from a job or
|
|
532
|
+
console and capture refuses — which is correct: nothing can mint a
|
|
533
|
+
presentation but a real render. Give operators their own explicit rail.
|
|
534
|
+
- **Fresh-password proofs stay yours.** Validate them your way, then record
|
|
535
|
+
them by reference in `authentication_context:` — never the credential.
|
|
536
|
+
|
|
537
|
+
## 8. Migrating history (do this once, early)
|
|
538
|
+
|
|
539
|
+
`Clickwrap.import_legacy!` exists so the gem answers for ALL acceptance
|
|
540
|
+
history, not just post-migration. The shape that worked:
|
|
541
|
+
|
|
542
|
+
- **Group legacy rows into acts** (by user + context + accepted-at instant):
|
|
543
|
+
one legacy act becomes one `imported_legacy` event, exactly as a live
|
|
544
|
+
capture would have.
|
|
545
|
+
- **`occurred_at` is the old record's time**; the import's own time stays
|
|
546
|
+
separate as `recorded_at_by_server`. The gap between them is itself
|
|
547
|
+
evidence.
|
|
548
|
+
- **Name what the source never recorded** in `unknown:` — bytes,
|
|
549
|
+
presentation, button text. Nothing is invented, and the receipt says so.
|
|
550
|
+
- **Do NOT copy raw IP/user-agent into `known:`** — that would move personal
|
|
551
|
+
data out of whatever protection it has into the un-encrypted core payload.
|
|
552
|
+
Record a pointer to where they are retained; migrate or delete them with
|
|
553
|
+
your request-evidence retention review.
|
|
554
|
+
- Imports are content-addressed idempotent: re-running imports nothing,
|
|
555
|
+
partial runs resume safely. Plan first (`dry_run: true`), always.
|
|
556
|
+
- Users with no evidence at all are **counted, never invented** — turning
|
|
557
|
+
them into reviewed `Clickwrap.exempt!` events is its own explicit step.
|
|
558
|
+
- Imported evidence satisfies `agreed_to?` / `current_for?` exactly as live
|
|
559
|
+
captures do — a migration must keep answering what the old system
|
|
560
|
+
answered, or it is a mass forced re-acceptance. The receipt stays honest
|
|
561
|
+
about the difference.
|
|
562
|
+
|
|
563
|
+
## 9. Request evidence, when a surface earns it
|
|
564
|
+
|
|
565
|
+
Default to collecting nothing; enable per policy, per field, when a surface
|
|
566
|
+
has a real purpose (our money path did; our signup did not):
|
|
567
|
+
|
|
568
|
+
```ruby
|
|
569
|
+
policy.record_ip_address(
|
|
570
|
+
encrypted: true,
|
|
571
|
+
delete_after: 2.years,
|
|
572
|
+
because: "Investigate disputes and account takeovers on withdrawals",
|
|
573
|
+
legal_basis_reference: "TODO(counsel): LIA payout evidence"
|
|
574
|
+
)
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
Enabling any IP field requires `config.trusted_proxy_configuration_digest` —
|
|
578
|
+
a digest of the effective proxy rules, not a prose label, so old evidence
|
|
579
|
+
records which configuration was in force. Generate it from Rails' configured
|
|
580
|
+
rules (or Rails' actual defaults when none were overridden):
|
|
581
|
+
|
|
582
|
+
```ruby
|
|
583
|
+
config.trusted_proxy_configuration_digest =
|
|
584
|
+
Clickwrap.trusted_proxy_configuration_digest_for_rails_application
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
This records configuration provenance; it does not prove the rules were
|
|
588
|
+
correctly deployed or reviewed. Sharing the same fields across several policies?
|
|
589
|
+
A plain Ruby lambda in `config/clickwrap.rb` calling
|
|
590
|
+
`policy.record_ip_address(...)` is exactly right — each policy still names
|
|
591
|
+
its own enablement.
|
|
592
|
+
|
|
593
|
+
## 10. The rollout doctrine
|
|
594
|
+
|
|
595
|
+
What let us migrate a live money path with zero downtime and zero weakening:
|
|
596
|
+
|
|
597
|
+
1. **Dual-write inside the capture transaction.** Legacy evidence rows keep
|
|
598
|
+
being written — from inside `capture_and!`'s block, so both systems
|
|
599
|
+
commit together or neither.
|
|
600
|
+
2. **Dual-belt at the boundary, strictest answer wins.** Keep every legacy
|
|
601
|
+
check verbatim and add clickwrap verification on top. Map clickwrap's
|
|
602
|
+
error symbols onto your existing error vocabulary so no UI copy changes.
|
|
603
|
+
3. **Only then retire** legacy columns, with the import (§8) already done and
|
|
604
|
+
a parity test standing guard until the day you delete it.
|
|
605
|
+
|
|
606
|
+
Expect two intentional behavior *sharpenings* when gates become real: blocked
|
|
607
|
+
funnels can no longer be blind-POSTed into acknowledged state (no rendered
|
|
608
|
+
presentation = nothing to submit), and crafted POSTs outside the right step
|
|
609
|
+
bounce to the screen that explains why. Your tests may assert the old, looser
|
|
610
|
+
behavior; update them to assert the true one.
|