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
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
|