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
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # Which optional tables this installation needs, and whether it has them.
5
+ #
6
+ # Seven of the seventeen tables this gem knows about cannot receive a row
7
+ # until a matching configuration is turned on, and every one of those is off
8
+ # by default. `clickwrap:install` therefore emits only the reachable ones, and
9
+ # each capability comes with its own generator flag.
10
+ #
11
+ # That trade has exactly one failure mode: a host turns the capability on and
12
+ # forgets the migration. It must not be discovered by a NoMethodError inside a
13
+ # capture at 3am, so it is discovered at boot, reported by the doctor, and
14
+ # refused at the entry points nothing else covers — always with the exact
15
+ # command that fixes it.
16
+ module SchemaRequirements
17
+ Feature = Data.define(:key, :tables, :flag, :because) do
18
+ def generator_command = "bin/rails generate clickwrap:install #{flag}"
19
+
20
+ def explanation
21
+ "#{because} That needs #{"the" if tables.length == 1}" \
22
+ "#{tables.length == 1 ? " #{tables.first} table" : " tables #{tables.join(" and ")}"}, " \
23
+ "which this database does not have. Add #{tables.length == 1 ? "it" : "them"}:\n\n " \
24
+ "#{generator_command}\n bin/rails db:migrate"
25
+ end
26
+ end
27
+
28
+ FEATURES = [
29
+ Feature.new(
30
+ key: :persisted_presentations,
31
+ tables: %w[clickwrap_presentations].freeze,
32
+ flag: "--with-persisted-presentations",
33
+ because: "A policy declares `persist_presentations_before_submission_for`, so Clickwrap " \
34
+ "is expected to keep the presentation it offered before the person submitted."
35
+ ),
36
+ Feature.new(
37
+ key: :request_evidence,
38
+ tables: %w[clickwrap_request_evidence].freeze,
39
+ flag: "--with-request-evidence",
40
+ because: "This installation records request evidence — an IP address, a browser " \
41
+ "user-agent, or provider-estimated IP geolocation."
42
+ ),
43
+ Feature.new(
44
+ key: :integrity,
45
+ tables: %w[clickwrap_chain_heads clickwrap_integrity_attestations].freeze,
46
+ flag: "--with-integrity",
47
+ because: "This installation configures event chaining, external anchoring, or " \
48
+ "third-party timestamping."
49
+ ),
50
+ Feature.new(
51
+ key: :retention_ops,
52
+ tables: %w[clickwrap_legal_holds clickwrap_disposition_plans].freeze,
53
+ flag: "--with-retention-ops",
54
+ because: "Legal holds and reviewed disposition are the operator tooling for deleting " \
55
+ "evidence on schedule and pausing that schedule."
56
+ ),
57
+ Feature.new(
58
+ key: :external_actions,
59
+ tables: %w[clickwrap_external_actions].freeze,
60
+ flag: "--with-external-actions",
61
+ because: "`Clickwrap.authorize_external_action!` commits a pending outbox row in the " \
62
+ "same transaction as the evidence that authorizes it."
63
+ )
64
+ ].freeze
65
+
66
+ FEATURES_BY_KEY = FEATURES.index_by(&:key).freeze
67
+
68
+ class << self
69
+ def feature!(key) = FEATURES_BY_KEY.fetch(key)
70
+
71
+ # Features whose tables are absent, among the ones the CONFIGURATION says
72
+ # this installation uses. Retention operations and external actions are
73
+ # not configured — they are called — so they are checked at their entry
74
+ # points instead, through `require!`.
75
+ #
76
+ # Empty while migrations are pending, and that is the whole point: `rails
77
+ # db:migrate` boots the application before it runs the migration that
78
+ # would satisfy this check, so a boot-time raise here would make the fix
79
+ # unrunnable. An installation mid-migration is not a misconfigured one.
80
+ def missing_for_configuration
81
+ return [] unless schema_is_settled?
82
+
83
+ %i[persisted_presentations request_evidence integrity]
84
+ .map { |key| feature!(key) }
85
+ .select { |feature| configured?(feature.key) && installed?(feature) == false }
86
+ end
87
+
88
+ # False when migrations are pending, when Clickwrap's own core tables are
89
+ # not there yet, and when the question cannot be answered at all. Each of
90
+ # those is an installation part-way through being set up, and the fix for
91
+ # a missing table is a migration — so refusing to boot before it can run
92
+ # would make the fix unreachable.
93
+ def schema_is_settled?
94
+ return false if table_presence("clickwrap_events") != true
95
+
96
+ !pending_migrations?
97
+ rescue StandardError
98
+ false
99
+ end
100
+
101
+ # `migration_context.migrations_paths` is relative to the application
102
+ # root, and nothing guarantees the process is running from there — a rake
103
+ # task, a test suite, and a console all disagree. Resolving against
104
+ # `Rails.root` is what makes the answer the same from all three.
105
+ def pending_migrations?
106
+ context = ::ActiveRecord::Base.connection_pool.migration_context
107
+ root = (::Rails.root if defined?(::Rails) && ::Rails.respond_to?(:root))
108
+
109
+ paths = Array(context.migrations_paths).map do |path|
110
+ root && !Pathname.new(path).absolute? ? root.join(path).to_s : path
111
+ end
112
+
113
+ ::ActiveRecord::MigrationContext.new(paths).needs_migration?
114
+ end
115
+
116
+ # "Is it safe to query this capability's tables?" — false only when the
117
+ # answer is a definite no. An unanswerable question is not a no: code that
118
+ # skipped work because a connection was not up yet would skip it silently.
119
+ def available?(key)
120
+ installed?(feature!(key)) != false
121
+ end
122
+
123
+ # Raised at the entry point of a capability nothing in the configuration
124
+ # announces. `installed?` returning nil means the question could not be
125
+ # asked (no database yet), and an unanswerable question is not a refusal.
126
+ def require!(key)
127
+ feature = feature!(key)
128
+ return if installed?(feature) != false
129
+
130
+ raise ConfigurationError, feature.explanation
131
+ end
132
+
133
+ # true, false, or nil when there is no database to ask. Memoized per
134
+ # feature and cleared with the rest of the global state, because asking
135
+ # the connection on every capture would be a query per event.
136
+ def installed?(feature)
137
+ cache = (@installed ||= {})
138
+ return cache[feature.key] if cache.key?(feature.key)
139
+
140
+ answers = feature.tables.map { |table| table_presence(table) }
141
+ # One unanswerable table makes the whole answer unknown. Folding nil
142
+ # into false here would report "not installed" for every application
143
+ # whose connection was not up yet, which is most of them at boot.
144
+ cache[feature.key] = answers.any?(&:nil?) ? nil : answers.all?
145
+ rescue StandardError
146
+ cache[feature.key] = nil
147
+ end
148
+
149
+ def reset!
150
+ @installed = nil
151
+ end
152
+
153
+ private
154
+
155
+ # Three answers, not two: true, false, and nil for "there is no live
156
+ # connection to ask". Deliberately not a predicate, because a predicate
157
+ # that can answer nil is a predicate somebody will read as false.
158
+ def table_presence(table)
159
+ connection = ::ActiveRecord::Base.connection
160
+ return nil unless connection.active?
161
+
162
+ connection.data_source_exists?(table)
163
+ end
164
+
165
+ def configured?(key)
166
+ case key
167
+ when :persisted_presentations then any_policy_persists_presentations?
168
+ when :request_evidence then records_request_evidence?
169
+ when :integrity then configures_integrity?
170
+ else false
171
+ end
172
+ end
173
+
174
+ def any_policy_persists_presentations?
175
+ Clickwrap.policies.any? { |policy| policy.persist_presentations_for.present? }
176
+ end
177
+
178
+ def records_request_evidence?
179
+ config = Clickwrap.config
180
+ return true if config.record_ip_address_by_default || config.record_browser_user_agent_by_default
181
+ return true if Vocabulary::IP_GEOLOCATION_DATA_FIELDS.any? do |field|
182
+ config.public_send(:"record_ip_geolocation_#{field}_by_default")
183
+ end
184
+
185
+ Clickwrap.policies.any? { |policy| policy.request_evidence.records_anything? }
186
+ end
187
+
188
+ def configures_integrity?
189
+ config = Clickwrap.config
190
+
191
+ [config.chain_event_history_with, config.anchor_event_history_with,
192
+ config.timestamp_receipts_with].any?(&:present?)
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ module Services
5
+ # `Clickwrap.authorize_external_action!` — the outbox.
6
+ #
7
+ # ===========================================================================
8
+ # READ THIS BEFORE CHANGING ANYTHING HERE.
9
+ #
10
+ # This is a DISTRIBUTED RELIABILITY PROTOCOL. It is NOT a cross-system ACID
11
+ # transaction, and no amount of care in this file could make it one.
12
+ #
13
+ # Stripe, an identity service, a timestamp authority, a remote signature
14
+ # provider: none of them can enlist in your database transaction. There is
15
+ # no two-phase commit here, no compensating rollback that reaches into
16
+ # someone else's ledger, and no moment at which "the evidence committed" and
17
+ # "the provider acted" are known to be true together. Clickwrap never claims
18
+ # atomicity across two independent systems, and the receipt this produces
19
+ # does not claim it either.
20
+ #
21
+ # What this protocol actually gives you is narrower and achievable:
22
+ #
23
+ # 1. ONE local transaction commits the evidence event and a `pending`
24
+ # outbox row carrying a server-generated idempotency key.
25
+ # 2. The provider is called OUTSIDE that transaction, by the host, with
26
+ # that key — so a retry reaches the provider as the same request rather
27
+ # than as a second one.
28
+ # 3. The outcome is appended back idempotently through
29
+ # `record_provider_success_and_consume!`, `record_provider_failure!`,
30
+ # or `record_provider_outcome_unknown!`.
31
+ # 4. `unknown` stays `unknown` until someone or something resolves it.
32
+ #
33
+ # Step 4 is the part people delete first and regret longest. A timeout is
34
+ # not a failure — it is an absence of information. Writing "failed" because
35
+ # the socket closed is how a second debit happens; writing "succeeded"
36
+ # because the retry returned 200 is how a fictional one does. The
37
+ # reconciliation task exists precisely so the ambiguous case can be settled
38
+ # later with better information than we have at the moment it occurs.
39
+ # ===========================================================================
40
+ class AuthorizeExternalAction
41
+ # The key handed to the provider. It is derived from the committed event
42
+ # id, which means one evidence event maps to exactly one external action
43
+ # forever: a retried capture that replays onto the same event reuses this
44
+ # key rather than minting a second one, and a provider that honors
45
+ # idempotency keys will therefore not act twice.
46
+ #
47
+ # It is server-generated. A client-supplied idempotency key would let a
48
+ # browser decide whether a second debit is a duplicate — which is exactly
49
+ # the decision the server exists to make.
50
+ def self.idempotency_key_for(policy_key:, event_id:)
51
+ "clickwrap-#{policy_key}-#{event_id}"
52
+ end
53
+
54
+ def initialize(policy:, provider_name: nil,
55
+ after_pending_action_is_saved_inside_transaction: nil,
56
+ **capture_options)
57
+ @policy = policy
58
+ @provider_name = provider_name&.to_s
59
+ @after_pending_action_is_saved_inside_transaction =
60
+ after_pending_action_is_saved_inside_transaction
61
+ @capture_options = capture_options
62
+
63
+ return if @after_pending_action_is_saved_inside_transaction.nil? ||
64
+ @after_pending_action_is_saved_inside_transaction.respond_to?(:call)
65
+
66
+ raise ArgumentError,
67
+ "after_pending_action_is_saved_inside_transaction must be callable. It receives " \
68
+ "pending_action: and pending_receipt: as keyword arguments."
69
+ end
70
+
71
+ attr_reader :policy, :provider_name, :capture_options,
72
+ :after_pending_action_is_saved_inside_transaction
73
+
74
+ # Captures the evidence and commits the pending outbox row in ONE local
75
+ # transaction, then returns the ExternalAction so the caller can hand its
76
+ # id and idempotency key to a job.
77
+ #
78
+ # The provider call belongs after this method returns, never inside it. A
79
+ # transaction held open across someone else's network is a transaction
80
+ # holding locks on evidence rows while waiting for a stranger's DNS.
81
+ def call
82
+ SchemaRequirements.require!(:external_actions)
83
+
84
+ action = nil
85
+
86
+ receipt = Capture.new(
87
+ policy: policy,
88
+ **capture_options,
89
+ consume_one_time_authorizations: false,
90
+ record_protected_outcome: false
91
+ ).capture_and! do |pending|
92
+ action = create_pending_action!(pending)
93
+ end
94
+
95
+ # A replayed capture returns the original receipt without re-running the
96
+ # block, so the outbox row for that event already exists. Finding it is
97
+ # the correct answer: the same authorization, with the same key, not a
98
+ # second one that could produce a second provider call.
99
+ action ||= ExternalAction.find_by(event_id: receipt.event_id)
100
+
101
+ unless action
102
+ raise ExternalActionError,
103
+ "The evidence for #{policy.key} committed as event #{receipt.event_id}, but no " \
104
+ "pending external action was found for it. Do not call the provider: without a " \
105
+ "committed outbox row there is nothing to resolve the outcome against."
106
+ end
107
+
108
+ action.reload
109
+ end
110
+
111
+ private
112
+
113
+ def create_pending_action!(pending)
114
+ now = Clickwrap.now
115
+
116
+ action = ExternalAction.create!(
117
+ event_id: pending.event_id,
118
+ policy_key: policy.key,
119
+ idempotency_key: self.class.idempotency_key_for(
120
+ policy_key: policy.key, event_id: pending.event_id
121
+ ),
122
+ provider_name: provider_name,
123
+ state: "pending",
124
+ attempt_count: 0,
125
+ requested_at: now,
126
+ created_at: now,
127
+ updated_at: now
128
+ )
129
+
130
+ # Some hosts need a temporary compatibility projection while they move
131
+ # an established audit ledger onto Clickwrap. This hook is deliberately
132
+ # named for its exact boundary: it runs only for the initial write,
133
+ # after the pending outbox row is saved, inside the SAME LOCAL database
134
+ # transaction as the event and action. A replay returns the committed
135
+ # action and never runs it again.
136
+ #
137
+ # Network/provider work never belongs here. A callback failure rolls all
138
+ # three local writes back, leaving no evidence that claims an external
139
+ # action was authorized when its required domain projection was not.
140
+ after_pending_action_is_saved_inside_transaction&.call(
141
+ pending_action: action,
142
+ pending_receipt: pending
143
+ )
144
+
145
+ action
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ module Services
5
+ # Loads the host's document, policy, and retention declarations.
6
+ #
7
+ # They live in ordinary Ruby files — `config/clickwrap.rb` and
8
+ # `config/clickwrap/*.rb` by default — because that makes them reviewable in
9
+ # a pull request and deployable like the code they are. The engine re-reads
10
+ # them through `to_prepare`, so a development reload picks up an edit the
11
+ # same way it picks up a model change.
12
+ #
13
+ # Compilation happens here, at boot, and a mistake raises. A policy that
14
+ # references a document nobody declared, a consent statement with no
15
+ # withdrawal route, a one-time authorization with no expiry — each of those
16
+ # is a bug that would otherwise be discovered by the first person who tried
17
+ # to sign up, and each one gets a sentence explaining what to do about it.
18
+ class LoadPolicies
19
+ def initialize(paths: nil, root: nil)
20
+ @paths = paths || Clickwrap.config.policy_paths
21
+ @root = root || default_root
22
+ end
23
+
24
+ attr_reader :paths, :root
25
+
26
+ def call
27
+ files = resolve_files
28
+ return [] if files.empty?
29
+
30
+ reset_registries!
31
+ files.each { |file| load_file(file) }
32
+ ValidatePolicyReferences.call
33
+ files
34
+ end
35
+
36
+ def resolve_files
37
+ return [] if root.nil?
38
+
39
+ paths.flat_map { |pattern| Dir[File.join(root, pattern)] }.uniq.sort
40
+ end
41
+
42
+ private
43
+
44
+ def default_root
45
+ defined?(::Rails) && ::Rails.root ? ::Rails.root.to_s : nil
46
+ end
47
+
48
+ # A reload replaces the declarations rather than adding to them, so a
49
+ # policy deleted from the file really is gone. Persisted evidence is
50
+ # untouched: an event references the frozen revision it was captured
51
+ # under, not whatever is in the registry today.
52
+ def reset_registries!
53
+ Clickwrap.documents.clear
54
+ Clickwrap.policies.clear
55
+ Clickwrap.retention_classes.clear
56
+ end
57
+
58
+ def load_file(file)
59
+ load file
60
+ rescue DefinitionError, ConfigurationError => error
61
+ raise error.class, "#{error.message}\n\n (while loading #{relative(file)})", error.backtrace
62
+ end
63
+
64
+ def relative(file)
65
+ root ? file.delete_prefix("#{root}/") : file
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ module Services
5
+ # `bin/rails clickwrap:publish` — reads the declared document bytes once,
6
+ # digests them, and freezes a database snapshot.
7
+ #
8
+ # After this runs, the snapshot is the evidence and the file on disk is only
9
+ # where it came from. Editing the file changes nothing about what was
10
+ # already published, which is exactly the property a receipt from three
11
+ # years ago depends on.
12
+ #
13
+ # The task is idempotent: re-publishing identical bytes under the same label
14
+ # is a no-op. Re-publishing *different* bytes under the same label is
15
+ # refused, because a version label that can mean two different documents is
16
+ # not a version label.
17
+ class PublishDocuments
18
+ Outcome = Data.define(:definition, :status, :version, :message) do
19
+ def published? = status == :published
20
+ def unchanged? = status == :unchanged
21
+ def planned? = status == :planned
22
+ end
23
+
24
+ def initialize(dry_run: false, definitions: nil)
25
+ @dry_run = dry_run
26
+ @definitions = definitions || Clickwrap.documents.values
27
+ end
28
+
29
+ attr_reader :dry_run, :definitions
30
+
31
+ def call
32
+ definitions.map { |definition| publish(definition) }
33
+ end
34
+
35
+ alias plan call
36
+
37
+ private
38
+
39
+ def publish(definition)
40
+ bytes = definition.read_bytes
41
+ digest = Digest.digest(bytes, algorithm: digest_algorithm)
42
+ existing = find_existing(definition)
43
+
44
+ if existing
45
+ return conflict(definition, existing, digest) unless matches?(existing, digest)
46
+
47
+ return Outcome.new(definition: definition, status: :unchanged, version: existing,
48
+ message: "Already published with identical bytes.")
49
+ end
50
+
51
+ if dry_run
52
+ return Outcome.new(definition: definition, status: :planned, version: nil,
53
+ message: "Would publish #{bytes.bytesize} bytes (#{digest}).")
54
+ end
55
+
56
+ Outcome.new(definition: definition, status: :published,
57
+ version: create_version(definition, bytes, digest),
58
+ message: "Published #{bytes.bytesize} bytes (#{digest}).")
59
+ end
60
+
61
+ def find_existing(definition)
62
+ document = ::Clickwrap::Document.find_by(
63
+ document_key: definition.key, tenant_key: definition.tenant_key
64
+ )
65
+ return nil unless document
66
+
67
+ document.versions.find_by(version_label: definition.version_label, locale: definition.locale)
68
+ end
69
+
70
+ def matches?(existing, digest)
71
+ Digest.secure_compare?(existing.content_digest, digest)
72
+ end
73
+
74
+ # The refusal a version label exists to make possible.
75
+ def conflict(definition, existing, digest)
76
+ raise DocumentVersionConflictError,
77
+ "Document #{definition} was already published with different bytes.\n " \
78
+ "published: #{existing.content_digest}\n " \
79
+ "on disk: #{digest}\n\n" \
80
+ "Reusing a version label for different content would silently change what every " \
81
+ "receipt that cites this version says the server offered. Publish the new text " \
82
+ "under a new version label instead."
83
+ end
84
+
85
+ def create_version(definition, bytes, digest)
86
+ rendered = render(definition, bytes)
87
+ backend = storage_backend_for(definition)
88
+
89
+ # Active Storage uploads may perform object-store network I/O. Finish
90
+ # that work before opening the evidence transaction; otherwise a slow
91
+ # S3 request holds database locks for its entire duration. If the
92
+ # subsequent database write fails, purge the just-created blob so a
93
+ # failed publication does not quietly accumulate unattached evidence
94
+ # objects.
95
+ uploaded_blob = backend == "active_storage" ? store_in_active_storage(definition, bytes) : nil
96
+
97
+ ::ActiveRecord::Base.transaction do
98
+ document = ::Clickwrap::Document.find_or_create_by!(
99
+ document_key: definition.key, tenant_key: definition.tenant_key
100
+ ) { |record| record.created_at = Clickwrap.now }
101
+
102
+ document.versions.create!(
103
+ version_label: definition.version_label,
104
+ locale: definition.locale,
105
+ media_type: definition.media_type,
106
+ content: backend == "database" ? text_for_database(definition, bytes) : nil,
107
+ storage_locator: uploaded_blob&.signed_id,
108
+ content_byte_size: bytes.bytesize,
109
+ content_digest_algorithm: digest_algorithm,
110
+ content_digest: digest,
111
+ rendered_content: rendered&.fetch(:bytes, nil),
112
+ rendered_media_type: rendered&.fetch(:media_type, nil),
113
+ rendered_content_digest: rendered && Digest.digest(rendered[:bytes], algorithm: digest_algorithm),
114
+ renderer_name: rendered&.fetch(:renderer_name, nil),
115
+ renderer_version: rendered&.fetch(:renderer_version, nil),
116
+ sanitizer_name: rendered&.fetch(:sanitizer_name, nil),
117
+ sanitizer_version: rendered&.fetch(:sanitizer_version, nil),
118
+ storage_backend: backend,
119
+ source_reference: definition.source_reference,
120
+ # A version with no declared schedule is effective as soon as it is
121
+ # published. Storing that explicitly rather than leaving NULL keeps
122
+ # "which version is current" a deterministic question: PostgreSQL
123
+ # sorts NULLs first in a descending order and SQLite sorts them
124
+ # last, so a nullable column here would mean two databases
125
+ # disagreeing about which document the server offered.
126
+ effective_at: definition.effective_at || Clickwrap.now,
127
+ published_at: Clickwrap.now,
128
+ created_at: Clickwrap.now
129
+ )
130
+ end
131
+ rescue StandardError => error
132
+ purge_failed_upload(uploaded_blob, original_error: error)
133
+ raise
134
+ end
135
+
136
+ # When a source format is transformed for display, the rendered bytes are
137
+ # stored and digested alongside the source. That keeps "this Markdown file
138
+ # existed" and "this rendered representation was offered" as two separate
139
+ # claims instead of letting one borrow the other's credibility.
140
+ #
141
+ # A custom renderer must return the exact bytes it offered. It is given
142
+ # no opportunity to render one thing and report another.
143
+ def render(definition, bytes)
144
+ renderer = definition.renderer || Clickwrap.config.document_renderer
145
+ return nil unless renderer
146
+
147
+ result = renderer.call(bytes, definition)
148
+ return nil if result.nil?
149
+
150
+ result = { bytes: result } if result.is_a?(String)
151
+ result = result.symbolize_keys
152
+
153
+ unless result[:bytes].is_a?(String)
154
+ raise ConfigurationError,
155
+ "A document renderer must return the exact rendered bytes as a String (or a Hash " \
156
+ "with a :bytes key). It returned #{result[:bytes].class}."
157
+ end
158
+
159
+ # The safety net for renderers that made NO sanitation decision: a bare
160
+ # String or a Hash without sanitizer provenance gets the safe list. A
161
+ # renderer that DECLARED its decision — including the deliberate
162
+ # "none" of a byte-parity host renderer — owns it; re-sanitizing here
163
+ # would reparse the bytes and silently overwrite the provenance,
164
+ # making the stored record claim a different pipeline than the one
165
+ # that ran.
166
+ if result.fetch(:media_type, "").to_s.start_with?("text/html") &&
167
+ result[:sanitizer_name].to_s.empty?
168
+ result[:bytes] = DocumentRenderer.sanitize_html(result[:bytes])
169
+ result[:sanitizer_name] = DocumentRenderer::SANITIZER_NAME
170
+ result[:sanitizer_version] = DocumentRenderer::SANITIZER_VERSION
171
+ end
172
+
173
+ result
174
+ end
175
+
176
+ # Documents are read as binary, because the digest has to cover the exact
177
+ # bytes on disk and nothing about them may be normalized on the way in.
178
+ # A text column, though, holds text — so the bytes are tagged UTF-8 here,
179
+ # and a document that is not valid UTF-8 is refused rather than mangled.
180
+ #
181
+ # An em dash is enough to hit this: the bytes come back as ASCII-8BIT and
182
+ # a database that is told to store them as text will either raise or
183
+ # quietly corrupt them, and a corrupted document is one whose digest will
184
+ # never verify again.
185
+ def text_for_database(definition, bytes)
186
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
187
+ return text if text.valid_encoding?
188
+
189
+ raise DefinitionError,
190
+ "Document #{definition} is not valid UTF-8, so it cannot be stored in a text " \
191
+ "column. Binary documents — a PDF, a scan, an image — need a storage backend " \
192
+ "that keeps bytes as bytes: set `config.store_document_contents_in = " \
193
+ ":active_storage`, or supply a resolver that returns them."
194
+ end
195
+
196
+ # Where the bytes come FROM and where they are KEPT are different
197
+ # questions, and a per-document `resolver:` answers the first one only —
198
+ # exactly like `from:` and `content:`. Publishing calls it once, and from
199
+ # then on the frozen snapshot is the evidence.
200
+ #
201
+ # `config.store_document_contents_in = :resolver` is the other question:
202
+ # it says the application will hand back the bytes on every read rather
203
+ # than have Clickwrap keep them. That adapter still has to return
204
+ # immutable bytes that hash to the recorded digest, which is verified on
205
+ # every read.
206
+ def storage_backend_for(_definition)
207
+ Clickwrap.config.store_document_contents_in.to_s
208
+ end
209
+
210
+ # Larger applications can keep document bodies in content-addressed object
211
+ # storage instead of the database. The contract does not change: whatever
212
+ # the adapter returns must be the exact immutable bytes, and the digest
213
+ # recorded here is verified against them on every read. A locator alone is
214
+ # never a document version.
215
+ def store_in_active_storage(definition, bytes)
216
+ unless defined?(::ActiveStorage)
217
+ raise ConfigurationError,
218
+ "store_document_contents_in is :active_storage, but Active Storage is not loaded " \
219
+ "in this application. Clickwrap does not depend on it; require it, or use " \
220
+ ":database."
221
+ end
222
+
223
+ ::ActiveStorage::Blob.create_and_upload!(
224
+ io: StringIO.new(bytes),
225
+ filename: "#{definition.key}-#{definition.version_label}-#{definition.locale}",
226
+ content_type: definition.media_type,
227
+ identify: false
228
+ )
229
+ end
230
+
231
+ def purge_failed_upload(blob, original_error:)
232
+ return if blob.nil?
233
+
234
+ blob.purge
235
+ rescue StandardError => error
236
+ if defined?(::Rails) && ::Rails.respond_to?(:error)
237
+ ::Rails.error.report(
238
+ error,
239
+ handled: true,
240
+ context: {
241
+ clickwrap_operation: "purge_failed_document_upload",
242
+ original_error_class: original_error.class.name
243
+ }
244
+ )
245
+ end
246
+ end
247
+
248
+ def digest_algorithm = Clickwrap.config.digest_canonical_receipts_with.to_s
249
+ end
250
+ end
251
+ end