clickwrap 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +90 -0
  3. data/CHANGELOG.md +612 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +241 -0
  7. data/app/controllers/clickwrap/application_controller.rb +79 -0
  8. data/app/controllers/clickwrap/captures_controller.rb +145 -0
  9. data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
  10. data/app/controllers/clickwrap/receipts_controller.rb +115 -0
  11. data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
  12. data/app/helpers/clickwrap/engine_helper.rb +97 -0
  13. data/app/views/clickwrap/captures/show.html.erb +34 -0
  14. data/app/views/clickwrap/receipts/index.html.erb +38 -0
  15. data/app/views/clickwrap/receipts/show.html.erb +91 -0
  16. data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
  17. data/app/views/clickwrap/shared/_fields.html.erb +100 -0
  18. data/app/views/clickwrap/shared/_statement.html.erb +105 -0
  19. data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
  20. data/config/locales/en.yml +160 -0
  21. data/config/locales/es.yml +138 -0
  22. data/config/routes.rb +41 -0
  23. data/exe/clickwrap +374 -0
  24. data/guides/README.md +30 -0
  25. data/guides/accessibility.md +249 -0
  26. data/guides/consent-and-lifecycle.md +299 -0
  27. data/guides/integrating.md +610 -0
  28. data/guides/integrity.md +212 -0
  29. data/guides/migrating.md +335 -0
  30. data/guides/naming.md +320 -0
  31. data/guides/organizations.md +320 -0
  32. data/guides/receipts-and-verification.md +415 -0
  33. data/guides/request-evidence.md +512 -0
  34. data/guides/retention-and-legal-holds.md +438 -0
  35. data/lib/clickwrap/actor_proxy.rb +147 -0
  36. data/lib/clickwrap/anonymous_actor.rb +47 -0
  37. data/lib/clickwrap/authority.rb +174 -0
  38. data/lib/clickwrap/canonical_json.rb +216 -0
  39. data/lib/clickwrap/capture/event_builder.rb +220 -0
  40. data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
  41. data/lib/clickwrap/capture.rb +650 -0
  42. data/lib/clickwrap/configuration.rb +1129 -0
  43. data/lib/clickwrap/controller_helpers.rb +758 -0
  44. data/lib/clickwrap/current_state.rb +282 -0
  45. data/lib/clickwrap/digest.rb +125 -0
  46. data/lib/clickwrap/doctor.rb +418 -0
  47. data/lib/clickwrap/document_definition.rb +255 -0
  48. data/lib/clickwrap/document_renderer.rb +83 -0
  49. data/lib/clickwrap/document_renderers/markdown.rb +175 -0
  50. data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
  51. data/lib/clickwrap/dsl/policy_builder.rb +462 -0
  52. data/lib/clickwrap/dsl/retention_builder.rb +89 -0
  53. data/lib/clickwrap/durable_commit_callback.rb +37 -0
  54. data/lib/clickwrap/engine.rb +184 -0
  55. data/lib/clickwrap/errors.rb +181 -0
  56. data/lib/clickwrap/form_builder_extensions.rb +341 -0
  57. data/lib/clickwrap/front_matter.rb +67 -0
  58. data/lib/clickwrap/identifier.rb +112 -0
  59. data/lib/clickwrap/import/external_receipt.rb +241 -0
  60. data/lib/clickwrap/import/fine_print.rb +290 -0
  61. data/lib/clickwrap/import/legacy.rb +450 -0
  62. data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
  63. data/lib/clickwrap/integrity/anchor.rb +130 -0
  64. data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
  65. data/lib/clickwrap/integrity/attestor.rb +221 -0
  66. data/lib/clickwrap/integrity/chain.rb +313 -0
  67. data/lib/clickwrap/integrity/timestamp.rb +143 -0
  68. data/lib/clickwrap/ip_geolocation/location.rb +112 -0
  69. data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
  70. data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
  71. data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
  72. data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
  73. data/lib/clickwrap/ip_geolocation.rb +16 -0
  74. data/lib/clickwrap/lifecycle.rb +534 -0
  75. data/lib/clickwrap/linter.rb +382 -0
  76. data/lib/clickwrap/localized_text.rb +101 -0
  77. data/lib/clickwrap/macros.rb +203 -0
  78. data/lib/clickwrap/models/application_record.rb +20 -0
  79. data/lib/clickwrap/models/chain_head.rb +79 -0
  80. data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
  81. data/lib/clickwrap/models/disposition_plan.rb +208 -0
  82. data/lib/clickwrap/models/document.rb +46 -0
  83. data/lib/clickwrap/models/document_version.rb +163 -0
  84. data/lib/clickwrap/models/event.rb +743 -0
  85. data/lib/clickwrap/models/event_document.rb +79 -0
  86. data/lib/clickwrap/models/event_statement.rb +92 -0
  87. data/lib/clickwrap/models/external_action.rb +150 -0
  88. data/lib/clickwrap/models/integrity_attestation.rb +90 -0
  89. data/lib/clickwrap/models/legal_hold.rb +81 -0
  90. data/lib/clickwrap/models/policy_revision.rb +115 -0
  91. data/lib/clickwrap/models/presentation.rb +59 -0
  92. data/lib/clickwrap/models/receipt_access.rb +53 -0
  93. data/lib/clickwrap/models/recording_sequence.rb +21 -0
  94. data/lib/clickwrap/models/request_evidence.rb +378 -0
  95. data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
  96. data/lib/clickwrap/models/statement_state.rb +130 -0
  97. data/lib/clickwrap/pending_receipt.rb +177 -0
  98. data/lib/clickwrap/policy.rb +283 -0
  99. data/lib/clickwrap/presentation_manifest.rb +210 -0
  100. data/lib/clickwrap/presenter.rb +716 -0
  101. data/lib/clickwrap/privacy.rb +419 -0
  102. data/lib/clickwrap/protected_outcome.rb +120 -0
  103. data/lib/clickwrap/receipt.rb +606 -0
  104. data/lib/clickwrap/receipt_html.rb +235 -0
  105. data/lib/clickwrap/receipt_verifier.rb +978 -0
  106. data/lib/clickwrap/reference.rb +44 -0
  107. data/lib/clickwrap/registration.rb +236 -0
  108. data/lib/clickwrap/registry.rb +54 -0
  109. data/lib/clickwrap/remediation_token.rb +155 -0
  110. data/lib/clickwrap/request_evidence_extractor.rb +590 -0
  111. data/lib/clickwrap/request_evidence_policy.rb +261 -0
  112. data/lib/clickwrap/retention/applier.rb +231 -0
  113. data/lib/clickwrap/retention/disposition.rb +221 -0
  114. data/lib/clickwrap/retention/planner.rb +502 -0
  115. data/lib/clickwrap/retention_class.rb +97 -0
  116. data/lib/clickwrap/reviewed_text.rb +28 -0
  117. data/lib/clickwrap/schema_requirements.rb +196 -0
  118. data/lib/clickwrap/services/authorize_external_action.rb +149 -0
  119. data/lib/clickwrap/services/load_policies.rb +69 -0
  120. data/lib/clickwrap/services/publish_documents.rb +251 -0
  121. data/lib/clickwrap/services/validate_policy_references.rb +166 -0
  122. data/lib/clickwrap/statement.rb +248 -0
  123. data/lib/clickwrap/subject_fingerprint.rb +28 -0
  124. data/lib/clickwrap/submission.rb +169 -0
  125. data/lib/clickwrap/system_actor.rb +31 -0
  126. data/lib/clickwrap/test_helpers.rb +676 -0
  127. data/lib/clickwrap/testing.rb +211 -0
  128. data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
  129. data/lib/clickwrap/verification.rb +504 -0
  130. data/lib/clickwrap/version.rb +12 -1
  131. data/lib/clickwrap/view_helpers.rb +190 -0
  132. data/lib/clickwrap/vocabulary.rb +294 -0
  133. data/lib/clickwrap.rb +497 -7
  134. data/lib/generators/clickwrap/document_generator.rb +164 -0
  135. data/lib/generators/clickwrap/hardening_generator.rb +177 -0
  136. data/lib/generators/clickwrap/install_generator.rb +1287 -0
  137. data/lib/generators/clickwrap/link_generator.rb +56 -0
  138. data/lib/generators/clickwrap/policy_generator.rb +118 -0
  139. data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
  140. data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
  141. data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
  142. data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
  143. data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
  144. data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
  145. data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
  146. data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
  147. data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
  148. data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
  149. data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
  150. data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
  151. data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
  152. data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
  153. data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
  154. data/lib/generators/clickwrap/views_generator.rb +101 -0
  155. data/lib/tasks/clickwrap.rake +569 -0
  156. metadata +311 -16
data/SECURITY.md ADDED
@@ -0,0 +1,33 @@
1
+ # Security Policy
2
+
3
+ `clickwrap` stores legal-evidence records: frozen document snapshots, signed presentation tokens, append-oriented evidence events with fixed named disposition transitions, canonical receipts, and — only when a policy explicitly asks for them — encrypted IP addresses, browser user-agent strings, and IP-geolocation estimates. Please report suspected vulnerabilities privately, and do not include real evidence records, receipts, presentation tokens, IP addresses, or any other personal data in a report. Redact or synthesize a reproduction instead; a minimal fabricated policy and document reproduce almost every issue just as well.
4
+
5
+ ## Supported versions
6
+
7
+ Security fixes are released for the latest published version. The maintained test matrix covers Ruby 3.2, 3.3, 3.4, and 4.0 with patched Rails 7.1, 7.2, 8.0, and 8.1 releases. Older Ruby and Rails versions may remain installable for compatibility, but runtimes that no longer receive upstream security fixes are not security-supported.
8
+
9
+ ## Reporting a vulnerability
10
+
11
+ Use GitHub's **Report a vulnerability** button on the [`clickwrap` security advisories page](https://github.com/rameerez/clickwrap/security/advisories) so the report and any proposed fix remain private. If GitHub's private reporting flow is unavailable, email `rubygems@rameerez.com` with the subject `clickwrap security report`.
12
+
13
+ Include:
14
+
15
+ - the affected version and environment;
16
+ - a minimal reproduction or proof of concept, using synthetic documents, policies, and actors;
17
+ - the impact you believe is possible — in particular, whether it affects the capture path (evidence that could be recorded wrongly) or only the verifier/display path (evidence that could be read or rendered wrongly); and
18
+ - any suggested mitigation or patch.
19
+
20
+ Do not open a public issue for an undisclosed vulnerability. We will acknowledge the report, investigate it, and coordinate disclosure and credit with you. If the issue affects downstream applications, we will prioritize a patched release and clear upgrade guidance, and say plainly whether historical evidence needs review.
21
+
22
+ ## Operational security
23
+
24
+ `clickwrap` provides evidence mechanics. Applications remain responsible for:
25
+
26
+ - Rails credentials, the `secret_key_base` that signs presentation tokens, and the `ActiveRecord::Encryption` keys that protect the request-evidence annex — including key rotation and a documented recovery path, since evidence outlives the deployment that wrote it;
27
+ - trusted-proxy configuration (`config.hosts`, `config.action_dispatch.trusted_proxies`) so that `request.remote_ip` reflects the connection the application actually observed rather than a client-supplied header;
28
+ - CSRF protection, session cookie configuration, authentication, and the authorization of every controller action that renders a policy or submits evidence;
29
+ - database, backup, replica, log, and observability access controls, and keeping presentation tokens and request evidence out of application and proxy logs;
30
+ - deciding who may read unredacted request evidence, wiring `config.authorize_receipt_access_with` and `config.authorize_unredacted_request_evidence_access_with` accordingly, and auditing that access; and
31
+ - retention periods, legal holds, and disposition decisions — the gem executes the retention classes you write and refuses to dispose of held evidence, but the periods, the lawful basis for keeping or deleting a record, and the decision to place or release a hold are yours.
32
+
33
+ The gem does not, and does not claim to, provide compliance with any law, enforceability of any agreement, tamper-proof storage, trusted or attested time, or verified identity. A receipt is evidence of what your application recorded and can be verified against its own canonical bytes; it is not proof that the recorded facts are true, that a person read or understood a document, or that the record could not have been fabricated by someone who controls every source. Independent anchors and provider signatures add only the origin and time evidence they actually supply.
@@ -0,0 +1,241 @@
1
+ /*
2
+ * clickwrap — bundled default styles.
3
+ *
4
+ * Deliberately small and unopinionated. It sets the things that change whether
5
+ * a person can USE the block — focus you can see, links that look like links,
6
+ * targets big enough to press, errors that read as errors in words — and stops
7
+ * well short of a design. Everything is scoped under `.clickwrap`, so dropping
8
+ * this file into a host stylesheet cannot repaint anything else on the page.
9
+ *
10
+ * Self-contained on purpose: engine views can't lean on the host's CSS
11
+ * framework (a Tailwind host never scans gem view files, so utility classes
12
+ * used here would simply not exist in its build). Semantic clickwrap-* classes
13
+ * plus plain modern CSS work in any host with zero build integration.
14
+ *
15
+ * Theme it with CSS variables from any host stylesheet:
16
+ *
17
+ * :root {
18
+ * --clickwrap-link: #1d4ed8;
19
+ * --clickwrap-error: #b3261e;
20
+ * }
21
+ *
22
+ * Want something completely different? `bin/rails generate clickwrap:views`
23
+ * ejects the templates into your app; restyle them and skip this file.
24
+ *
25
+ * What this file cannot do is make a page accessible. Placement, contrast
26
+ * against YOUR background, surrounding clutter, reading order, and the words
27
+ * themselves belong to the host page. These are defaults that try not to be the
28
+ * problem.
29
+ */
30
+
31
+ .clickwrap {
32
+ --clickwrap-text: #111827;
33
+ --clickwrap-muted: #4b5563;
34
+ --clickwrap-link: #1d4ed8;
35
+ --clickwrap-link-visited: #6b21a8;
36
+ --clickwrap-error: #b3261e;
37
+ --clickwrap-error-surface: #fdf2f2;
38
+ --clickwrap-border: #6b7280;
39
+ --clickwrap-focus: #111827;
40
+ --clickwrap-radius: 0.375rem;
41
+
42
+ color: var(--clickwrap-text);
43
+ font: inherit;
44
+ line-height: 1.5;
45
+ }
46
+
47
+ /* --- Focus ------------------------------------------------------------------
48
+ * A visible focus ring on everything focusable, in both directions: the outline
49
+ * is drawn, and it is offset so it stays visible on top of a control that has
50
+ * its own border. Never `outline: none`.
51
+ */
52
+
53
+ .clickwrap :is(a, button, input, summary, [tabindex]):focus-visible {
54
+ outline: 3px solid var(--clickwrap-focus);
55
+ outline-offset: 2px;
56
+ border-radius: 2px;
57
+ }
58
+
59
+ /* --- Screen-reader-only text -------------------------------------------------
60
+ * The "(opens in a new tab)" truth without the clutter: announced, never drawn.
61
+ * Not `display: none` and not `visibility: hidden` — both remove it from the
62
+ * accessibility tree, which is the one place it needs to exist.
63
+ */
64
+
65
+ .clickwrap-sr-only {
66
+ position: absolute;
67
+ width: 1px;
68
+ height: 1px;
69
+ padding: 0;
70
+ margin: -1px;
71
+ overflow: hidden;
72
+ clip-path: inset(50%);
73
+ white-space: nowrap;
74
+ border: 0;
75
+ }
76
+
77
+ /* --- Statements --------------------------------------------------------------
78
+ * The default is one line: a checkbox and a sentence with links in it. Aligned
79
+ * on the first line's baseline rather than the box's top edge, so the control
80
+ * sits with the words instead of above them, and nothing wraps to a second row
81
+ * that a person has to read as a second thing.
82
+ */
83
+
84
+ .clickwrap-statements { display: flex; flex-direction: column; gap: 1rem; }
85
+
86
+ .clickwrap-statement__control { display: flex; align-items: flex-start; gap: 0.6rem; }
87
+
88
+ .clickwrap-statement__checkbox,
89
+ .clickwrap-statement__radio {
90
+ /* Sized in em so the box matches the sentence it belongs to instead of
91
+ towering over small text, and optically centered against the FIRST line
92
+ of the label: (line-height minus box) halved. Flex `baseline` cannot do
93
+ this — a checkbox aligns its bottom edge to the baseline and ends up
94
+ floating high. The rem fallback covers engines without the lh unit. */
95
+ width: 1em;
96
+ height: 1em;
97
+ margin: 0.2em 0 0;
98
+ margin-top: calc((1lh - 1em) / 2);
99
+ flex: 0 0 auto;
100
+ accent-color: var(--clickwrap-focus);
101
+ }
102
+
103
+ .clickwrap-statement__label,
104
+ .clickwrap-statement__choice-label { cursor: pointer; }
105
+
106
+ .clickwrap-statement__choices {
107
+ border: 1px solid var(--clickwrap-border);
108
+ border-radius: var(--clickwrap-radius);
109
+ padding: 0.75rem 1rem;
110
+ margin: 0;
111
+ }
112
+
113
+ .clickwrap-statement__legend { font-weight: 600; padding: 0 0.25rem; }
114
+ .clickwrap-statement__choice { display: inline-flex; align-items: center; gap: 0.4rem; margin-right: 1rem; }
115
+
116
+ /* --- Documents ---------------------------------------------------------------
117
+ * Links look like links: underlined, in a conventional link color, and distinct
118
+ * from the surrounding text by more than color alone. In the composed line they
119
+ * sit inside the sentence; on an itemized statement they sit under it.
120
+ */
121
+
122
+ .clickwrap-documents { list-style: none; margin: 0.4rem 0 0; padding: 0 0 0 1.75rem; }
123
+ .clickwrap-documents__item { margin: 0.15rem 0; font-size: 0.9375rem; }
124
+
125
+ .clickwrap-documents__link,
126
+ .clickwrap-link {
127
+ /* The links read AS PART of the sentence: same color as the words around
128
+ them, distinguished by a subtle underline — still a non-color signal, so
129
+ the distinction survives color-blindness and forced-colors modes. The
130
+ underline firms up on hover/focus as the affordance. */
131
+ color: inherit;
132
+ text-decoration: underline;
133
+ text-decoration-thickness: 1px;
134
+ text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
135
+ text-underline-offset: 0.18em;
136
+ }
137
+
138
+ .clickwrap-documents__link:hover,
139
+ .clickwrap-documents__link:focus-visible,
140
+ .clickwrap-link:hover,
141
+ .clickwrap-link:focus-visible { text-decoration-color: currentColor; }
142
+
143
+ .clickwrap-documents__link:visited,
144
+ .clickwrap-link:visited { color: inherit; }
145
+
146
+ /* Versions belong on a receipt, where somebody is reading the record. Beside a
147
+ checkbox they are noise nobody can act on. */
148
+ .clickwrap-documents__version { color: var(--clickwrap-muted); font-size: 0.8125rem; }
149
+
150
+ /* --- Errors ------------------------------------------------------------------
151
+ * Never color alone. The message is words, it is prefixed by the word "Error",
152
+ * and the block carries a heavy left rule — so it still reads as an error in
153
+ * grayscale, in high-contrast mode, and to someone who cannot see red.
154
+ */
155
+
156
+ .clickwrap-error-summary {
157
+ border: 2px solid var(--clickwrap-error);
158
+ border-left-width: 8px;
159
+ background: var(--clickwrap-error-surface);
160
+ border-radius: var(--clickwrap-radius);
161
+ padding: 0.85rem 1rem;
162
+ margin: 0 0 1rem;
163
+ }
164
+
165
+ .clickwrap-error-summary__heading { margin: 0 0 0.5rem; font-size: 1.0625rem; font-weight: 700; }
166
+ .clickwrap-error-summary__list { margin: 0; padding-left: 1.25rem; }
167
+ .clickwrap-error-summary__link { color: var(--clickwrap-error); text-decoration: underline; }
168
+
169
+ .clickwrap-statement--invalid {
170
+ border-left: 4px solid var(--clickwrap-error);
171
+ padding-left: 0.75rem;
172
+ }
173
+
174
+ .clickwrap-statement__error { color: var(--clickwrap-error); margin: 0.35rem 0 0; font-size: 0.9375rem; }
175
+ .clickwrap-statement__error-prefix { font-weight: 700; }
176
+
177
+ .clickwrap-flash { padding: 0.75rem 1rem; border-radius: var(--clickwrap-radius); }
178
+ .clickwrap-flash--alert {
179
+ border: 2px solid var(--clickwrap-error);
180
+ border-left-width: 8px;
181
+ background: var(--clickwrap-error-surface);
182
+ }
183
+
184
+ /* --- Actions ------------------------------------------------------------------ */
185
+
186
+ .clickwrap-actions { margin-top: 1.25rem; }
187
+
188
+ .clickwrap-submit {
189
+ font: inherit;
190
+ padding: 0.6rem 1.1rem;
191
+ border-radius: var(--clickwrap-radius);
192
+ border: 1px solid var(--clickwrap-focus);
193
+ background: var(--clickwrap-focus);
194
+ color: #ffffff;
195
+ cursor: pointer;
196
+ min-height: 2.75rem;
197
+ }
198
+
199
+ /* --- Engine screens ------------------------------------------------------------
200
+ * Only the four pages this gem ships. A host page that embeds the form helper
201
+ * keeps its own layout entirely.
202
+ */
203
+
204
+ .clickwrap-screen { max-width: 42rem; margin: 0 auto; padding: 1.5rem 1.25rem 3rem; }
205
+ .clickwrap-screen__title { font-size: 1.5rem; margin: 0 0 0.35rem; }
206
+ .clickwrap-screen__subtitle { font-size: 1.125rem; margin: 1.5rem 0 0.5rem; }
207
+ .clickwrap-screen__intro { color: var(--clickwrap-muted); margin: 0 0 1.25rem; }
208
+ .clickwrap-screen__actions { margin-top: 1.5rem; }
209
+
210
+ .clickwrap-facts { display: grid; grid-template-columns: minmax(10rem, auto) 1fr; gap: 0.35rem 1rem; margin: 0; }
211
+ .clickwrap-facts dt { color: var(--clickwrap-muted); }
212
+ .clickwrap-facts dd { margin: 0; }
213
+
214
+ .clickwrap-receipts, .clickwrap-acts { list-style: none; margin: 0; padding: 0; }
215
+
216
+ .clickwrap-receipts__item,
217
+ .clickwrap-acts__item {
218
+ padding: 0.75rem 0;
219
+ border-bottom: 1px solid rgba(0, 0, 0, 0.12);
220
+ }
221
+
222
+ .clickwrap-receipts__link { color: var(--clickwrap-link); text-decoration: underline; font-weight: 600; }
223
+ .clickwrap-receipts__meta, .clickwrap-acts__meta { display: block; color: var(--clickwrap-muted); font-size: 0.875rem; }
224
+ .clickwrap-acts__assertion { margin: 0; }
225
+
226
+ .clickwrap-code {
227
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
228
+ font-size: 0.8125rem;
229
+ word-break: break-all;
230
+ }
231
+
232
+ .clickwrap-code--digest { display: block; color: var(--clickwrap-muted); }
233
+ .clickwrap-empty, .clickwrap-note { color: var(--clickwrap-muted); }
234
+ .clickwrap-note { margin-top: 2rem; font-size: 0.875rem; }
235
+
236
+ /* Respect a reader's own contrast and motion settings rather than overriding
237
+ them: in forced-colors mode the system palette wins outright. */
238
+ @media (forced-colors: active) {
239
+ .clickwrap :is(a, button, input, [tabindex]):focus-visible { outline: 3px solid CanvasText; }
240
+ .clickwrap-error-summary, .clickwrap-flash--alert { border-color: CanvasText; }
241
+ }
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # Base controller for every engine screen. It inherits from the HOST's
5
+ # controller (`config.parent_controller_class_name`, "::ApplicationController"
6
+ # by default) so the host's layout, helpers, authentication filters, locale
7
+ # switching, and exception handling all apply to these screens for free — the
8
+ # same integration style as the sessions, chats, and api_keys gems.
9
+ #
10
+ # NOTE: the superclass is resolved when this class is AUTOLOADED, which in a
11
+ # booted app happens after initializers have run — so a
12
+ # `config.parent_controller_class_name` set in
13
+ # config/initializers/clickwrap.rb is honored. In development the class
14
+ # reloads on change and picks up configuration changes with it.
15
+ class ApplicationController < Clickwrap.config.parent_controller_class_name.constantize
16
+ # The view-layer DSL (`helper`, `helper_method`, `layout`) does not exist on
17
+ # ActionController::API, and an API-only host that bundles this gem for its
18
+ # model and service APIs still eager-loads this class in production, mounted
19
+ # or not. Guarding keeps such a host bootable; the HTML screens themselves
20
+ # still need a Base-derived parent controller, which is the default.
21
+ helper Clickwrap::EngineHelper if respond_to?(:helper)
22
+ helper_method :clickwrap_current_actor, :clickwrap_errors if respond_to?(:helper_method)
23
+
24
+ private
25
+
26
+ # The host's authentication filters run INSIDE these engine controllers —
27
+ # that is the entire point of inheriting from the host's parent controller —
28
+ # and those filters reference the HOST's own route helpers
29
+ # (`new_session_path` in the Rails authentication generator, custom
30
+ # redirects in hand-rolled filters), which an isolated engine's route set
31
+ # cannot resolve. Delegating unknown `*_path`/`*_url` calls to `main_app`
32
+ # lets the host's code work in here unmodified. It is the standard engine
33
+ # idiom, and the alternative is asking every host to special-case its own
34
+ # authentication for these four screens.
35
+ def method_missing(method, *, &)
36
+ if method.to_s.end_with?("_path", "_url") && main_app.respond_to?(method)
37
+ main_app.public_send(method, *, &)
38
+ else
39
+ super
40
+ end
41
+ end
42
+
43
+ def respond_to_missing?(method, include_private = false)
44
+ (method.to_s.end_with?("_path", "_url") && main_app.respond_to?(method)) || super
45
+ end
46
+
47
+ # A destination this application is willing to send someone back to.
48
+ #
49
+ # Return-to values arrive from the browser, so they are treated as untrusted
50
+ # navigation input: a relative path on this host, or nothing. An absolute
51
+ # URL, a protocol-relative "//evil.example", a scheme, or anything carrying
52
+ # control characters falls back to the default rather than being repaired
53
+ # into something that looks close enough.
54
+ def clickwrap_safe_return_to(candidate, fallback:)
55
+ value = candidate.to_s.strip
56
+ return fallback if value.empty?
57
+ return fallback unless value.start_with?("/")
58
+ return fallback if value.start_with?("//", "/\\")
59
+ return fallback if value.match?(/[[:cntrl:]]/)
60
+
61
+ uri = begin
62
+ URI.parse(value)
63
+ rescue URI::InvalidURIError
64
+ nil
65
+ end
66
+ return fallback if uri.nil? || uri.scheme.present? || uri.host.present?
67
+
68
+ value
69
+ end
70
+
71
+ # Actor-owned engine screens authorize only after a real actor exists.
72
+ # Otherwise a host callback such as `current_user == receipt.actor` could
73
+ # accidentally authorize the `nil == nil` case for imported or unlinked
74
+ # evidence.
75
+ def require_clickwrap_actor
76
+ head :unauthorized unless clickwrap_current_actor
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # The standalone screen for one policy: the same server-owned presentation the
5
+ # form-builder helper renders inline, on a page of its own.
6
+ #
7
+ # This is what stops a required agreement from becoming a dead end. A gate
8
+ # redirects here, the person completes the policy in place, and they are
9
+ # returned to whatever they were trying to do. It is also the screen a host
10
+ # links to directly for a declaration that has expired or a new Terms version
11
+ # that needs accepting.
12
+ class CapturesController < ApplicationController
13
+ before_action :find_policy
14
+ before_action :require_clickwrap_actor
15
+ before_action :load_remediation_context
16
+ before_action :remember_return_destination
17
+
18
+ rescue_from RemediationInvalid, with: :remediation_not_found
19
+
20
+ def show
21
+ @presentation = present_policy
22
+ end
23
+
24
+ def create
25
+ capture_clickwrap!(@policy.key, subject: @remediation_subject,
26
+ acting_for: @remediation_represented_party,
27
+ **remediation_tenant_option)
28
+
29
+ redirect_to @return_to, allow_other_host: false, notice: t("clickwrap.captures.recorded")
30
+ rescue AnswerInvalid => error
31
+ re_present_with_error(error.statement_key, t("clickwrap.errors.answer_not_accepted"))
32
+ rescue SubmissionInvalid, PresentationExpired, PresentationInvalid, ReplayRejected,
33
+ OneTimeAuthorizationConflict
34
+ # A stale, replayed, or swapped presentation is not something to repair
35
+ # quietly: the server offers the policy again and the person answers the
36
+ # offer they can actually see.
37
+ re_present_with_error(nil, t("clickwrap.errors.presentation_no_longer_valid"))
38
+ rescue AuthorityNotVerified
39
+ # Authority is rechecked at submit. A role removed after the page was
40
+ # rendered must not become a 500 or a fresh offer that can never succeed.
41
+ head :forbidden
42
+ rescue RetryableTransactionError
43
+ response.set_header("Retry-After", "1")
44
+ re_present_with_error(nil, t("clickwrap.errors.temporarily_unavailable"), status: :service_unavailable)
45
+ end
46
+
47
+ private
48
+
49
+ def find_policy
50
+ @policy = Clickwrap.policy!(params[:policy_key])
51
+ rescue UnknownPolicyError
52
+ head :not_found
53
+ end
54
+
55
+ # Where to go after the policy is satisfied. The gate puts this in the URL
56
+ # and the form carries it through the POST; both are browser-supplied, so
57
+ # both go through the same safety check and fall back to this engine's own
58
+ # root rather than to anywhere interesting.
59
+ def remember_return_destination
60
+ candidate = @remediation_context&.return_to || params[:return_to]
61
+ @return_to = clickwrap_safe_return_to(candidate, fallback: clickwrap_engine_routes.root_path)
62
+ end
63
+
64
+ def load_remediation_context
65
+ token = params[:remediation_token].presence
66
+
67
+ if token.nil?
68
+ if @policy.subject_bound?
69
+ raise RemediationInvalid,
70
+ "This subject-bound policy needs a signed remediation route from the blocked action."
71
+ end
72
+
73
+ # A policy that permits acting for a represented party exists to record
74
+ # WHO was represented. Completing it on the bare engine screen with no
75
+ # represented party would write permanent evidence whose statements
76
+ # assert representative authority over nobody — orphan evidence that
77
+ # reads as more than it is. Those policies arrive here only through a
78
+ # signed remediation route that carries the represented party.
79
+ if @policy.authority_rule.present?
80
+ raise RemediationInvalid,
81
+ "This policy records representative authority, so it needs a signed remediation " \
82
+ "route naming the represented party. It cannot be completed standalone."
83
+ end
84
+
85
+ @remediation_token = nil
86
+ @remediation_subject = nil
87
+ @remediation_represented_party = nil
88
+ @remediation_tenant = nil
89
+ return
90
+ end
91
+
92
+ @remediation_context = resolve_clickwrap_remediation!(@policy.key, token: token)
93
+ @remediation_token = token
94
+ @remediation_subject = @remediation_context.subject
95
+ @remediation_represented_party = @remediation_context.represented_party
96
+ # The signed token carries the tenant the gate resolved; the engine's own
97
+ # routes have no ambient tenant, so this is the only truthful source.
98
+ @remediation_tenant = @remediation_context.tenant_reference.presence
99
+ end
100
+
101
+ def present_policy
102
+ present_clickwrap(
103
+ @policy.key,
104
+ subject: @remediation_subject,
105
+ acting_for: @remediation_represented_party,
106
+ locale: I18n.locale,
107
+ submit_button_text: submit_button_text,
108
+ **remediation_tenant_option
109
+ )
110
+ end
111
+
112
+ # Included only when a signed token carried a tenant: a token-less flow
113
+ # keeps the ordinary policy-aware ambient resolution, while a tokened flow
114
+ # must use exactly the tenant the issuing gate resolved and signed.
115
+ def remediation_tenant_option
116
+ @remediation_tenant.nil? ? {} : { tenant: @remediation_tenant }
117
+ end
118
+
119
+ # The words on the button, recorded in the manifest exactly as rendered. A
120
+ # host that wants different words translates the key; there is no way for
121
+ # the rendered button and the recorded text to disagree, because this is the
122
+ # only place either of them comes from.
123
+ def submit_button_text
124
+ t("clickwrap.captures.submit_button_text")
125
+ end
126
+
127
+ def re_present_with_error(statement_key, message, status: 422)
128
+ if statement_key
129
+ clickwrap_errors[statement_key.to_s] = message
130
+ else
131
+ flash.now[:alert] = message
132
+ end
133
+
134
+ # A new presentation, not the old one: its nonce is spent, and re-offering
135
+ # a spent token would fail again for a reason that has nothing to do with
136
+ # what the person got wrong.
137
+ @presentation = present_policy
138
+ render :show, status: status
139
+ end
140
+
141
+ def remediation_not_found
142
+ head :not_found
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # The exact rendered bytes of one published document version.
5
+ #
6
+ # This is where every document link in every presentation points, and where an
7
+ # auditor reading a three-year-old receipt ends up. Both get the same response
8
+ # from the same row, verified against the digest that was recorded when it was
9
+ # published — `DocumentVersion#content_bytes` refuses to hand back bytes that
10
+ # no longer match, because silently serving edited content would turn this
11
+ # action into a way to launder a changed document into an old agreement.
12
+ #
13
+ # Retired versions stay reachable on purpose. A version stops being presentable
14
+ # when it is retired; it never stops being the thing somebody agreed to.
15
+ class DocumentVersionsController < ApplicationController
16
+ # Published legal documents are read before anyone is signed in — the signup
17
+ # form links to them, and that is the moment they matter most. The host's
18
+ # authentication filter is skipped here and only here. (`raise: false`
19
+ # because most hosts have neither of these filters; between them they cover
20
+ # the Rails authentication generator and Devise.)
21
+ skip_before_action :require_authentication, raise: false
22
+ skip_before_action :authenticate_user!, raise: false
23
+
24
+ def show
25
+ version = DocumentVersion.find_by(id: params[:id])
26
+ return head :not_found if version.nil? || !version.published?
27
+
28
+ # The linked representation is the exact rendered snapshot bound into the
29
+ # presentation, not mutable source and never raw unsanitized HTML.
30
+ response.headers["X-Content-Type-Options"] = "nosniff"
31
+ response.headers["Content-Security-Policy"] =
32
+ "default-src 'none'; img-src data:; style-src 'unsafe-inline'; " \
33
+ "base-uri 'none'; form-action 'none'; frame-ancestors 'self'; sandbox"
34
+ response.headers["Referrer-Policy"] = "no-referrer"
35
+ response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
36
+
37
+ # A published representation is derived from a specific source artifact.
38
+ # Refuse to serve either half of a version whose other half has stopped
39
+ # matching its publication digest; otherwise a corrupt source row could
40
+ # remain publicly vouched for merely because the rendered snapshot was
41
+ # untouched.
42
+ version.content_bytes
43
+ send_data version.rendered_bytes,
44
+ type: version.rendered_media_type.presence || version.media_type,
45
+ filename: download_filename(version, version.rendered_media_type.presence || version.media_type),
46
+ disposition: "inline"
47
+ rescue DocumentDigestMismatchError
48
+ # The stored bytes no longer match their recorded digest. Serving them
49
+ # anyway would be the one thing this action must never do.
50
+ head :unprocessable_entity
51
+ end
52
+
53
+ private
54
+
55
+ def download_filename(version, media_type)
56
+ base = [version.document&.document_key, version.version_label, version.locale].compact.join("-")
57
+
58
+ "#{base.parameterize}#{extension_for(media_type)}"
59
+ end
60
+
61
+ def extension_for(media_type)
62
+ case media_type.to_s.split(";", 2).first
63
+ when "text/markdown" then ".md"
64
+ when "text/html" then ".html"
65
+ when "text/plain" then ".txt"
66
+ when "application/pdf" then ".pdf"
67
+ else ""
68
+ end
69
+ end
70
+ end
71
+ end