clickwrap 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +90 -0
  3. data/CHANGELOG.md +650 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +245 -0
  7. data/app/controllers/clickwrap/application_controller.rb +79 -0
  8. data/app/controllers/clickwrap/captures_controller.rb +145 -0
  9. data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
  10. data/app/controllers/clickwrap/receipts_controller.rb +115 -0
  11. data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
  12. data/app/helpers/clickwrap/engine_helper.rb +97 -0
  13. data/app/views/clickwrap/captures/show.html.erb +34 -0
  14. data/app/views/clickwrap/receipts/index.html.erb +38 -0
  15. data/app/views/clickwrap/receipts/show.html.erb +91 -0
  16. data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
  17. data/app/views/clickwrap/shared/_fields.html.erb +100 -0
  18. data/app/views/clickwrap/shared/_statement.html.erb +105 -0
  19. data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
  20. data/config/locales/en.yml +160 -0
  21. data/config/locales/es.yml +154 -0
  22. data/config/routes.rb +41 -0
  23. data/exe/clickwrap +374 -0
  24. data/guides/README.md +30 -0
  25. data/guides/accessibility.md +249 -0
  26. data/guides/consent-and-lifecycle.md +299 -0
  27. data/guides/integrating.md +610 -0
  28. data/guides/integrity.md +212 -0
  29. data/guides/migrating.md +335 -0
  30. data/guides/naming.md +320 -0
  31. data/guides/organizations.md +320 -0
  32. data/guides/receipts-and-verification.md +415 -0
  33. data/guides/request-evidence.md +512 -0
  34. data/guides/retention-and-legal-holds.md +438 -0
  35. data/lib/clickwrap/actor_proxy.rb +147 -0
  36. data/lib/clickwrap/anonymous_actor.rb +47 -0
  37. data/lib/clickwrap/authority.rb +174 -0
  38. data/lib/clickwrap/canonical_json.rb +216 -0
  39. data/lib/clickwrap/capture/event_builder.rb +220 -0
  40. data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
  41. data/lib/clickwrap/capture.rb +650 -0
  42. data/lib/clickwrap/configuration.rb +1129 -0
  43. data/lib/clickwrap/controller_helpers.rb +758 -0
  44. data/lib/clickwrap/current_state.rb +282 -0
  45. data/lib/clickwrap/digest.rb +125 -0
  46. data/lib/clickwrap/doctor.rb +418 -0
  47. data/lib/clickwrap/document_definition.rb +255 -0
  48. data/lib/clickwrap/document_renderer.rb +83 -0
  49. data/lib/clickwrap/document_renderers/markdown.rb +175 -0
  50. data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
  51. data/lib/clickwrap/dsl/policy_builder.rb +462 -0
  52. data/lib/clickwrap/dsl/retention_builder.rb +89 -0
  53. data/lib/clickwrap/durable_commit_callback.rb +37 -0
  54. data/lib/clickwrap/engine.rb +187 -0
  55. data/lib/clickwrap/errors.rb +181 -0
  56. data/lib/clickwrap/form_builder_extensions.rb +341 -0
  57. data/lib/clickwrap/front_matter.rb +67 -0
  58. data/lib/clickwrap/identifier.rb +112 -0
  59. data/lib/clickwrap/import/external_receipt.rb +241 -0
  60. data/lib/clickwrap/import/fine_print.rb +290 -0
  61. data/lib/clickwrap/import/legacy.rb +450 -0
  62. data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
  63. data/lib/clickwrap/integrity/anchor.rb +130 -0
  64. data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
  65. data/lib/clickwrap/integrity/attestor.rb +221 -0
  66. data/lib/clickwrap/integrity/chain.rb +313 -0
  67. data/lib/clickwrap/integrity/timestamp.rb +143 -0
  68. data/lib/clickwrap/ip_geolocation/location.rb +112 -0
  69. data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
  70. data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
  71. data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
  72. data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
  73. data/lib/clickwrap/ip_geolocation.rb +16 -0
  74. data/lib/clickwrap/lifecycle.rb +534 -0
  75. data/lib/clickwrap/linter.rb +382 -0
  76. data/lib/clickwrap/localized_text.rb +101 -0
  77. data/lib/clickwrap/macros.rb +203 -0
  78. data/lib/clickwrap/models/application_record.rb +20 -0
  79. data/lib/clickwrap/models/chain_head.rb +79 -0
  80. data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
  81. data/lib/clickwrap/models/disposition_plan.rb +208 -0
  82. data/lib/clickwrap/models/document.rb +46 -0
  83. data/lib/clickwrap/models/document_version.rb +163 -0
  84. data/lib/clickwrap/models/event.rb +743 -0
  85. data/lib/clickwrap/models/event_document.rb +79 -0
  86. data/lib/clickwrap/models/event_statement.rb +92 -0
  87. data/lib/clickwrap/models/external_action.rb +150 -0
  88. data/lib/clickwrap/models/integrity_attestation.rb +90 -0
  89. data/lib/clickwrap/models/legal_hold.rb +81 -0
  90. data/lib/clickwrap/models/policy_revision.rb +115 -0
  91. data/lib/clickwrap/models/presentation.rb +59 -0
  92. data/lib/clickwrap/models/receipt_access.rb +53 -0
  93. data/lib/clickwrap/models/recording_sequence.rb +21 -0
  94. data/lib/clickwrap/models/request_evidence.rb +378 -0
  95. data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
  96. data/lib/clickwrap/models/statement_state.rb +130 -0
  97. data/lib/clickwrap/pending_receipt.rb +177 -0
  98. data/lib/clickwrap/policy.rb +283 -0
  99. data/lib/clickwrap/presentation_manifest.rb +210 -0
  100. data/lib/clickwrap/presenter.rb +729 -0
  101. data/lib/clickwrap/privacy.rb +419 -0
  102. data/lib/clickwrap/protected_outcome.rb +120 -0
  103. data/lib/clickwrap/receipt.rb +606 -0
  104. data/lib/clickwrap/receipt_html.rb +235 -0
  105. data/lib/clickwrap/receipt_verifier.rb +978 -0
  106. data/lib/clickwrap/reference.rb +44 -0
  107. data/lib/clickwrap/registration.rb +236 -0
  108. data/lib/clickwrap/registry.rb +54 -0
  109. data/lib/clickwrap/remediation_token.rb +155 -0
  110. data/lib/clickwrap/request_evidence_extractor.rb +590 -0
  111. data/lib/clickwrap/request_evidence_policy.rb +261 -0
  112. data/lib/clickwrap/retention/applier.rb +231 -0
  113. data/lib/clickwrap/retention/disposition.rb +221 -0
  114. data/lib/clickwrap/retention/planner.rb +502 -0
  115. data/lib/clickwrap/retention_class.rb +97 -0
  116. data/lib/clickwrap/reviewed_text.rb +28 -0
  117. data/lib/clickwrap/schema_requirements.rb +196 -0
  118. data/lib/clickwrap/services/authorize_external_action.rb +149 -0
  119. data/lib/clickwrap/services/load_policies.rb +69 -0
  120. data/lib/clickwrap/services/publish_documents.rb +251 -0
  121. data/lib/clickwrap/services/validate_policy_references.rb +166 -0
  122. data/lib/clickwrap/statement.rb +248 -0
  123. data/lib/clickwrap/subject_fingerprint.rb +28 -0
  124. data/lib/clickwrap/submission.rb +169 -0
  125. data/lib/clickwrap/system_actor.rb +31 -0
  126. data/lib/clickwrap/test_helpers.rb +676 -0
  127. data/lib/clickwrap/testing.rb +211 -0
  128. data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
  129. data/lib/clickwrap/verification.rb +504 -0
  130. data/lib/clickwrap/version.rb +12 -1
  131. data/lib/clickwrap/view_helpers.rb +190 -0
  132. data/lib/clickwrap/vocabulary.rb +294 -0
  133. data/lib/clickwrap.rb +497 -7
  134. data/lib/generators/clickwrap/document_generator.rb +164 -0
  135. data/lib/generators/clickwrap/hardening_generator.rb +177 -0
  136. data/lib/generators/clickwrap/install_generator.rb +1287 -0
  137. data/lib/generators/clickwrap/link_generator.rb +56 -0
  138. data/lib/generators/clickwrap/policy_generator.rb +118 -0
  139. data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
  140. data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
  141. data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
  142. data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
  143. data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
  144. data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
  145. data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
  146. data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
  147. data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
  148. data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
  149. data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
  150. data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
  151. data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
  152. data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
  153. data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
  154. data/lib/generators/clickwrap/views_generator.rb +101 -0
  155. data/lib/tasks/clickwrap.rake +569 -0
  156. metadata +311 -16
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # The abstract base class every model the gem ships inherits from. It plays
5
+ # the exact role an engine's `app/models/<engine>/application_record.rb`
6
+ # normally plays.
7
+ #
8
+ # It inherits from the HOST's `::ActiveRecord::Base`, NOT from the host's
9
+ # `::ApplicationRecord`. That matters here more than in most engines: evidence
10
+ # tables must behave identically in every host, and a default scope, a
11
+ # multitenancy filter, or a `before_save` bolted onto the app's base class
12
+ # could quietly change what gets recorded — or hide rows from an export that
13
+ # is supposed to be complete.
14
+ #
15
+ # The reference to `::ActiveRecord::Base` is fully qualified so Ruby's
16
+ # constant lookup can never re-bind it to a `Clickwrap::ActiveRecord`.
17
+ class ApplicationRecord < ::ActiveRecord::Base
18
+ self.abstract_class = true
19
+ end
20
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # The head of one tamper-evident event chain.
5
+ #
6
+ # Chaining is off unless configured. The scope is tenant-and-policy: a
7
+ # tenanted installation gets one chain per (tenant, policy). An UNTENANTED
8
+ # installation's scope is the literal "global/<policy_key>" — one chain per
9
+ # policy across all actors, which means every capture of that policy
10
+ # serializes behind every other. Enable chaining on hot policies knowing
11
+ # that queue exists; per-actor scoping is future work, not current behavior.
12
+ #
13
+ # What a chain detects: an event rewritten or removed after the fact, as long
14
+ # as the head remains trustworthy. What it does not do: stop a party with full
15
+ # control of the application and database from rewriting both the events and
16
+ # the head. That is what the optional independent anchor adapter is for, and
17
+ # even then the claim is only as strong as the anchor.
18
+ class ChainHead < ApplicationRecord
19
+ self.table_name = "clickwrap_chain_heads"
20
+
21
+ validates :chain_scope, presence: true, uniqueness: true
22
+
23
+ # Appending is two phases, and it has to be.
24
+ #
25
+ # An event's digest is computed from its own body, which the event does not
26
+ # have until it is built — but its `previous_event_digest` has to be set
27
+ # before it is saved. So `reserve!` hands out the predecessor's digest and
28
+ # the next sequence number, the event is written with those, and `record!`
29
+ # then stores the digest the event actually ended up with.
30
+ #
31
+ # Doing it in one call is how a chain quietly ends up with a head full of
32
+ # nils: every link would point at a digest that had not been computed yet.
33
+
34
+ # Takes the next position in the chain. The row lock is what stops two
35
+ # concurrent captures in the same scope from reading the same predecessor
36
+ # and forking the chain.
37
+ def self.reserve!(chain_scope:)
38
+ # The first probe must be an ordinary read. On MySQL/InnoDB, two
39
+ # `SELECT ... FOR UPDATE` calls for the same absent unique key both take
40
+ # gap locks; when both then INSERT, InnoDB has to deadlock one of them.
41
+ # Let the unique INSERT choose the first writer, absorb the loser's
42
+ # duplicate in a savepoint, and only then take the row lock.
43
+ head = find_by(chain_scope: chain_scope)
44
+
45
+ unless head
46
+ begin
47
+ transaction(requires_new: true) { create!(chain_scope: chain_scope, chain_sequence: 0) }
48
+ rescue ActiveRecord::RecordNotUnique
49
+ # The savepoint absorbs PostgreSQL's aborted-statement state before
50
+ # the winning row is read and locked in the caller's transaction.
51
+ end
52
+ end
53
+
54
+ # Always re-read under the lock. A plain read above is only an existence
55
+ # probe and may carry a stale sequence; this is the value from which the
56
+ # next link is actually reserved.
57
+ head = lock.find_by!(chain_scope: chain_scope)
58
+
59
+ next_sequence = head.chain_sequence + 1
60
+ previous_digest = head.last_event_digest
61
+
62
+ head.update!(chain_sequence: next_sequence)
63
+
64
+ [previous_digest, next_sequence]
65
+ end
66
+
67
+ # Records the digest the event was actually written with, so the next event
68
+ # in this scope links to something real.
69
+ def self.record!(chain_scope:, event_id:, event_digest:)
70
+ head = lock.find_by(chain_scope: chain_scope)
71
+ return nil unless head
72
+
73
+ head.update!(last_event_id: event_id, last_event_digest: event_digest)
74
+ head
75
+ end
76
+
77
+ def to_s = "chain #{chain_scope} at #{chain_sequence}"
78
+ end
79
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # What `has_clickwraps` mixes into the actor model.
5
+ #
6
+ # It adds the evidence proxy and the associations, and nothing else. Notably
7
+ # absent: any `dependent: :destroy`. Deleting an account must not silently
8
+ # erase the record of what that person agreed to — that is a retention
9
+ # decision, and it belongs to the host and its counsel, not to a foreign key.
10
+ #
11
+ # So the associations nullify the actor link on destroy and leave the stable
12
+ # pseudonymous `actor_reference` behind. The evidence remains queryable and
13
+ # verifiable; what disappears is the pointer to a row that no longer exists.
14
+ # A host that genuinely wants the evidence gone runs disposition through
15
+ # `Clickwrap::Privacy`, which records that it did.
16
+ module HasClickwraps
17
+ extend ActiveSupport::Concern
18
+
19
+ included do
20
+ has_many :clickwrap_events,
21
+ class_name: "Clickwrap::Event",
22
+ as: :actor,
23
+ inverse_of: :actor,
24
+ dependent: :nullify
25
+
26
+ has_many :clickwrap_statement_states,
27
+ class_name: "Clickwrap::StatementState",
28
+ as: :actor,
29
+ inverse_of: :actor,
30
+ dependent: :nullify
31
+
32
+ has_many :clickwrap_presentations,
33
+ class_name: "Clickwrap::Presentation",
34
+ as: :actor,
35
+ inverse_of: :actor,
36
+ dependent: :nullify
37
+ end
38
+
39
+ # The everyday API: `user.clickwraps.agreed_to?(:terms)`.
40
+ def clickwraps
41
+ @clickwraps ||= Clickwrap::ActorProxy.new(self)
42
+ end
43
+
44
+ # How this record is referenced in evidence.
45
+ #
46
+ # Uses GlobalID when the host loads it and a stable class/id string in a
47
+ # minimal Rails host. Either string remains in the evidence after the row is
48
+ # gone — which is the situation this reference exists for. Override this
49
+ # method when the host has its own stable pseudonymous identifier scheme;
50
+ # `identify_actor_with` asks for it first, so no initializer change is needed.
51
+ def clickwrap_actor_reference
52
+ Clickwrap::Reference.record(self)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # A reviewable plan to delete something.
5
+ #
6
+ # Deletion here is always two steps: plan, then apply. The plan is immutable,
7
+ # scoped, and expiring, and it is rechecked at apply time. A newly placed
8
+ # legal hold, a changed policy, a changed eligibility, or a stale plan stops
9
+ # the run rather than deleting a broader set than the person who reviewed it
10
+ # agreed to.
11
+ #
12
+ # The plan itself never decides whether an erasure request overrides a
13
+ # retention duty, a legal claim, or a hold. It shows what would happen.
14
+ class DispositionPlan < ApplicationRecord
15
+ self.table_name = "clickwrap_disposition_plans"
16
+ self.primary_key = "id"
17
+
18
+ KINDS = %w[retention actor_privacy].freeze
19
+ STATES = %w[open applying applied applied_with_errors superseded].freeze
20
+
21
+ DEFAULT_LIFETIME = 24.hours
22
+
23
+ validates :kind, inclusion: { in: KINDS }
24
+ validates :state, inclusion: { in: STATES }
25
+ validates :expires_at, presence: true
26
+ validates :plan_digest, presence: true
27
+
28
+ before_validation :assign_identifier, on: :create
29
+ before_validation :assign_plan_digest, on: :create
30
+ before_update :refuse_ordinary_update
31
+ before_destroy :refuse_destroy, prepend: true
32
+
33
+ scope :open_plans, -> { where(state: "open") }
34
+ scope :usable, ->(at = Clickwrap.now) { open_plans.where("expires_at > ?", at) }
35
+
36
+ def expired?(at = Clickwrap.now) = expires_at <= at
37
+ def applied? = state == "applied"
38
+
39
+ def usable?(at = Clickwrap.now)
40
+ state == "open" && !expired?(at)
41
+ end
42
+
43
+ # Raises with the specific reason this plan can no longer be applied, so an
44
+ # operator sees "the plan expired" or "this was already applied" rather than
45
+ # a generic refusal.
46
+ def ensure_usable!(at = Clickwrap.now)
47
+ raise DispositionPlanInvalid, "Disposition plan #{id} failed its immutable plan digest." unless digest_verified?
48
+ return true if usable?(at)
49
+
50
+ raise DispositionPlanInvalid,
51
+ case state
52
+ when "applied", "applied_with_errors" then "Disposition plan #{id} was already applied at #{applied_at}."
53
+ when "applying" then "Disposition plan #{id} is already being applied."
54
+ when "superseded" then "Disposition plan #{id} was superseded by a newer plan."
55
+ else "Disposition plan #{id} expired at #{expires_at}. Run the plan again and " \
56
+ "review the current set before applying it."
57
+ end
58
+ end
59
+
60
+ def claim_for_application!(by_reference:, recover_if_stale_after: nil,
61
+ because_recovery_is_needed: nil)
62
+ if by_reference.to_s.strip.empty?
63
+ raise DispositionPlanInvalid,
64
+ "Applying a disposition plan needs the stable reference of the operator doing it."
65
+ end
66
+
67
+ with_lock do
68
+ if state == "applying"
69
+ reclaim_stale_application!(
70
+ by_reference: by_reference,
71
+ stale_after: recover_if_stale_after,
72
+ because: because_recovery_is_needed
73
+ )
74
+ else
75
+ ensure_usable!
76
+ start_application_attempt!(by_reference)
77
+ end
78
+ end
79
+ self
80
+ end
81
+
82
+ def finish_application!(outcome_summary:, had_errors: false)
83
+ with_lock do
84
+ unless state == "applying"
85
+ raise DispositionPlanInvalid, "Disposition plan #{id} is not currently being applied."
86
+ end
87
+
88
+ update_columns(
89
+ state: had_errors ? "applied_with_errors" : "applied",
90
+ applied_at: Clickwrap.now,
91
+ application_outcome: outcome_summary
92
+ )
93
+ end
94
+ self
95
+ end
96
+
97
+ def supersede!(because:, by:)
98
+ raise DispositionPlanInvalid, "Superseding a plan needs a `because:`." if because.to_s.strip.empty?
99
+
100
+ with_lock do
101
+ ensure_usable!
102
+ update_columns(
103
+ state: "superseded",
104
+ superseded_at: Clickwrap.now,
105
+ superseded_by_reference: Reference.actor(by),
106
+ superseded_reason: because
107
+ )
108
+ end
109
+ self
110
+ end
111
+
112
+ def digest_verified?
113
+ Digest.secure_compare?(plan_digest.to_s, compute_plan_digest)
114
+ end
115
+
116
+ def to_s = "#{kind} disposition plan #{id} (#{item_count} items)"
117
+
118
+ private
119
+
120
+ def assign_identifier
121
+ self.id ||= Identifier.generate
122
+ self.expires_at ||= Clickwrap.now + DEFAULT_LIFETIME
123
+ self.created_at ||= Clickwrap.now
124
+ self.updated_at ||= created_at
125
+ end
126
+
127
+ def assign_plan_digest
128
+ self.plan_digest ||= compute_plan_digest
129
+ end
130
+
131
+ def compute_plan_digest
132
+ Digest.digest_canonical({
133
+ "id" => id,
134
+ "kind" => kind,
135
+ "scope" => disposition_scope,
136
+ "summary" => summary,
137
+ "item_count" => item_count,
138
+ "created_by_reference" => created_by_reference,
139
+ "reason" => reason,
140
+ "expires_at" => Receipt.format_time(expires_at),
141
+ "created_at" => Receipt.format_time(created_at)
142
+ })
143
+ end
144
+
145
+ def start_application_attempt!(by_reference)
146
+ started_at = Clickwrap.now
147
+ update_columns(
148
+ state: "applying",
149
+ application_started_at: started_at,
150
+ applied_by_reference: by_reference.to_s,
151
+ application_attempt_count: application_attempt_count.to_i + 1,
152
+ updated_at: started_at
153
+ )
154
+ end
155
+
156
+ def reclaim_stale_application!(by_reference:, stale_after:, because:)
157
+ raise DispositionPlanInvalid, "Disposition plan #{id} expired at #{expires_at}." if expired?
158
+
159
+ unless stale_after.respond_to?(:positive?) && stale_after.positive?
160
+ raise DispositionPlanInvalid,
161
+ "Disposition plan #{id} is already being applied. Recovering it requires " \
162
+ "recover_application_if_stale_for with a positive duration."
163
+ end
164
+
165
+ cutoff = Clickwrap.now - stale_after
166
+ if application_started_at.present? && application_started_at > cutoff
167
+ raise DispositionPlanInvalid,
168
+ "Disposition plan #{id} has only been applying since #{application_started_at}; " \
169
+ "it is not older than the #{stale_after.inspect} recovery threshold."
170
+ end
171
+
172
+ if because.to_s.strip.empty?
173
+ raise DispositionPlanInvalid,
174
+ "Recovering a stale disposition application needs " \
175
+ "because_recovery_is_needed in plain English."
176
+ end
177
+
178
+ recovered_at = Clickwrap.now
179
+ recoveries = Array(application_recoveries).dup
180
+ recoveries << {
181
+ "previous_application_started_at" => Receipt.format_time(application_started_at),
182
+ "previous_applied_by_reference" => applied_by_reference,
183
+ "recovered_at" => Receipt.format_time(recovered_at),
184
+ "recovered_by_reference" => by_reference.to_s,
185
+ "reason" => because.to_s.strip
186
+ }.compact
187
+
188
+ update_columns(
189
+ application_started_at: recovered_at,
190
+ applied_by_reference: by_reference.to_s,
191
+ application_attempt_count: application_attempt_count.to_i + 1,
192
+ application_recoveries: recoveries,
193
+ updated_at: recovered_at
194
+ )
195
+ end
196
+
197
+ def refuse_ordinary_update
198
+ raise ImmutableEvidenceError,
199
+ "Disposition plans refuse ordinary updates. Use claim_for_application!, " \
200
+ "finish_application!, or supersede!(because:, by:) for a named transition."
201
+ end
202
+
203
+ def refuse_destroy
204
+ raise ImmutableEvidenceError,
205
+ "Disposition plans cannot be destroyed; their reviewed scope and named transitions must remain."
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # A logical document — `:terms`, `:privacy_notice` — separate from the
5
+ # immutable versions that carry its actual bytes.
6
+ class Document < ApplicationRecord
7
+ self.table_name = "clickwrap_documents"
8
+
9
+ has_many :versions,
10
+ class_name: "Clickwrap::DocumentVersion",
11
+ foreign_key: :document_id,
12
+ inverse_of: :document,
13
+ dependent: :restrict_with_error
14
+
15
+ validates :document_key, presence: true, uniqueness: { scope: :tenant_key }
16
+
17
+ scope :for_tenant, ->(tenant_key) { where(tenant_key: tenant_key.presence) }
18
+
19
+ # The version a policy should present right now for a locale: published,
20
+ # already effective, and not retired. A version scheduled for the future is
21
+ # deliberately not presentable yet — that is what `effective_at` is for.
22
+ # The version to present right now: published, already effective, and not
23
+ # retired. A version scheduled for the future is deliberately not
24
+ # presentable yet — that is what `effective_at` is for.
25
+ #
26
+ # The ordering is by effective time, then by publication time as a
27
+ # tie-breaker for two versions scheduled for the same instant. Publishing
28
+ # always writes an `effective_at`, so no NULL reaches this comparison and
29
+ # PostgreSQL and SQLite agree about which document the server offered.
30
+ def current_version(locale: I18n.locale, at: Clickwrap.now)
31
+ versions
32
+ .published
33
+ .effective_at_or_before(at)
34
+ .not_retired_at(at)
35
+ .for_locale(locale)
36
+ .order(effective_at: :desc, published_at: :desc, created_at: :desc)
37
+ .first
38
+ end
39
+
40
+ def version(label, locale: I18n.locale)
41
+ versions.for_locale(locale).find_by(version_label: label)
42
+ end
43
+
44
+ def to_s = document_key
45
+ end
46
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # An immutable published version of a document.
5
+ #
6
+ # Once `published_at` is set, the bytes and their digest are frozen. There is
7
+ # no supported way to edit them: a change is a new version, and the old row
8
+ # stays exactly as it was because a receipt from last year points at it.
9
+ #
10
+ # `content_digest` covers the original source bytes. `rendered_content_digest`
11
+ # covers the representation actually offered when a source format was
12
+ # transformed for display. Keeping both is what lets a receipt distinguish
13
+ # "this Markdown file existed" from "the server offered this rendered HTML"
14
+ # instead of letting one claim borrow the other's credibility.
15
+ class DocumentVersion < ApplicationRecord
16
+ self.table_name = "clickwrap_document_versions"
17
+
18
+ STORAGE_BACKENDS = %w[database active_storage resolver].freeze
19
+
20
+ belongs_to :document, class_name: "Clickwrap::Document", inverse_of: :versions
21
+
22
+ has_many :event_documents,
23
+ class_name: "Clickwrap::EventDocument",
24
+ foreign_key: :document_version_id,
25
+ inverse_of: :document_version,
26
+ dependent: :restrict_with_error
27
+
28
+ validates :version_label, :locale, :media_type, :content_digest, presence: true
29
+ validates :version_label, uniqueness: { scope: %i[document_id locale] }
30
+ validates :storage_backend, inclusion: { in: STORAGE_BACKENDS }
31
+
32
+ scope :published, -> { where.not(published_at: nil) }
33
+ scope :for_locale, ->(locale) { where(locale: locale.to_s) }
34
+ scope :effective_at_or_before, ->(moment) { where(effective_at: ..moment).or(where(effective_at: nil)) }
35
+ scope :not_retired_at, ->(moment) { where(retired_at: nil).or(where(retired_at: moment...)) }
36
+
37
+ # Publishing freezes content. Editing a published version is refused here
38
+ # rather than in a code review, because the whole promise of this table is
39
+ # that its rows do not change.
40
+ before_update :refuse_to_change_published_version
41
+ before_destroy :refuse_to_destroy_published_version, prepend: true
42
+
43
+ def published? = published_at.present?
44
+ def retired? = retired_at.present?
45
+
46
+ def presentable_at?(moment = Clickwrap.now)
47
+ published? &&
48
+ (effective_at.nil? || effective_at <= moment) &&
49
+ (retired_at.nil? || retired_at > moment)
50
+ end
51
+
52
+ # Reads the bytes for this version, from wherever the storage adapter put
53
+ # them, and verifies them against the recorded digest before returning.
54
+ # Verification is not optional: silently returning bytes that no longer
55
+ # match would turn this method into a way to launder edited content into an
56
+ # export.
57
+ def content_bytes
58
+ bytes = read_bytes
59
+
60
+ unless Digest.matches?(bytes, "#{content_digest_algorithm}:#{bare_digest(content_digest)}")
61
+ raise DocumentDigestMismatchError,
62
+ "The stored bytes for document version #{self} no longer match the digest " \
63
+ "recorded when it was published. The evidence that references this version " \
64
+ "cannot be reproduced until that is explained. Recorded: #{content_digest}."
65
+ end
66
+
67
+ bytes
68
+ end
69
+
70
+ def verify_content_digest
71
+ content_bytes
72
+ true
73
+ rescue DocumentDigestMismatchError, DocumentNotPublishedError
74
+ false
75
+ end
76
+
77
+ def rendered_bytes
78
+ return content_bytes if rendered_content.nil?
79
+
80
+ unless rendered_content_digest.present? && Digest.matches?(rendered_content, rendered_content_digest)
81
+ raise DocumentDigestMismatchError,
82
+ "The rendered bytes for document version #{self} no longer match the digest " \
83
+ "recorded when they were published."
84
+ end
85
+
86
+ rendered_content
87
+ end
88
+
89
+ def verify_rendered_content_digest
90
+ rendered_bytes
91
+ true
92
+ rescue DocumentDigestMismatchError, DocumentNotPublishedError
93
+ false
94
+ end
95
+
96
+ def retire!(because:, at: Clickwrap.now)
97
+ raise DocumentVersionConflictError, "Retiring a document version needs a `because:`." if because.to_s.strip.empty?
98
+ raise DocumentVersionConflictError, "Document version #{self} is already retired." if retired?
99
+
100
+ update_columns(retired_at: at, retired_reason: because)
101
+ self
102
+ end
103
+
104
+ def prefixed_content_digest = content_digest
105
+
106
+ def to_s = "#{document&.document_key} #{version_label} (#{locale})"
107
+
108
+ private
109
+
110
+ def read_bytes
111
+ case storage_backend
112
+ when "database" then content.to_s
113
+ when "resolver" then read_from_resolver
114
+ when "active_storage" then read_from_active_storage
115
+ end
116
+ end
117
+
118
+ def read_from_resolver
119
+ resolver = Clickwrap.config.document_resolver
120
+
121
+ unless resolver
122
+ raise ConfigurationError,
123
+ "Document version #{self} is stored through a resolver, but no " \
124
+ "`document_resolver` is configured."
125
+ end
126
+
127
+ resolver.call(self).to_s
128
+ end
129
+
130
+ def read_from_active_storage
131
+ unless defined?(::ActiveStorage)
132
+ raise ConfigurationError,
133
+ "Document version #{self} is stored in Active Storage, but Active Storage is " \
134
+ "not loaded in this application."
135
+ end
136
+
137
+ ::ActiveStorage::Blob.find_signed!(storage_locator).download
138
+ end
139
+
140
+ def bare_digest(value)
141
+ value.to_s.split(":").last
142
+ end
143
+
144
+ def refuse_to_change_published_version
145
+ return unless published_at_was.present?
146
+
147
+ changed_frozen = changed - %w[retired_at retired_reason]
148
+ return if changed_frozen.empty? && !will_save_change_to_retired_at? && !will_save_change_to_retired_reason?
149
+
150
+ raise DocumentVersionConflictError,
151
+ "Document version #{self} has frozen published content. Use `retire!(because:)` to " \
152
+ "record the named retirement metadata and stop future presentation, or publish a new " \
153
+ "version; ordinary updates are refused."
154
+ end
155
+
156
+ def refuse_to_destroy_published_version
157
+ return unless published?
158
+
159
+ raise DocumentVersionConflictError,
160
+ "Published document version #{self} cannot be destroyed because receipts may cite it."
161
+ end
162
+ end
163
+ end