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,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ module Integrity
5
+ # The adapter contract for a timestamp provider, plus a reference base
6
+ # implementation that issues no tokens and says so. Configuration defaults
7
+ # to nil.
8
+ #
9
+ # config.timestamp_receipts_with = MyRfc3161TimestampProvider.new
10
+ #
11
+ # WHAT THIS SEAM IS FOR. Clickwrap records `recorded_at_by_server`, and calls
12
+ # it exactly that: the application server's own clock, which the application
13
+ # controls. An RFC 3161 time-stamp authority
14
+ # (https://www.rfc-editor.org/info/rfc3161/) or a qualified trust service
15
+ # supplies something different — a token from a third party over a digest
16
+ # you gave it, whose value depends entirely on that party, its practice
17
+ # statement, its certificate status, and what a reader is willing to accept
18
+ # about it.
19
+ #
20
+ # WHAT SUCH A TOKEN CLAIMS. Exactly what that provider supplies and nothing
21
+ # more. Clickwrap stores the token and the validation status the adapter
22
+ # reports, and preserves both verbatim; it never upgrades a provider receipt
23
+ # into a guarantee the provider did not make, never treats a token as
24
+ # identity, never treats it as a signature by a person, and never restates
25
+ # it in stronger words than the provider used. If the provider's own status
26
+ # is "unknown" or "expired", that is what travels into the receipt. eIDAS
27
+ # gives distinct legal effect to qualified signatures and seals
28
+ # (https://eur-lex.europa.eu/eli/reg/2014/910/2024-05-20/eng); whether a
29
+ # given provider's output has that effect is a question about that provider,
30
+ # not about this gem.
31
+ #
32
+ # WHAT THIS FILE DELIBERATELY IS NOT. It is not an RFC 3161 client. Clickwrap
33
+ # ships no ASN.1 encoder, no HTTP client, and no certificate-chain
34
+ # validation, and it adds no dependency that would. Timestamping is an
35
+ # optional integration. A host that needs it supplies an adapter that speaks
36
+ # to its own chosen authority; assigning this base class is useful only when
37
+ # an explicit unavailable result is wanted.
38
+ #
39
+ # WRITING ONE. Implement `#timestamp(digest)` and `#verify(token, digest)`,
40
+ # and report honestly from `#capabilities`.
41
+ # `Configuration#timestamp_receipts_with=` checks that the object responds to
42
+ # `#timestamp`.
43
+ class Timestamp
44
+ # What an adapter returns from `#timestamp`. `issued: false` is an
45
+ # ordinary outcome — no provider configured, provider unavailable, request
46
+ # declined — and the record says which rather than leaving a gap where a
47
+ # token should be.
48
+ Token = Data.define(:issued, :token, :digest, :provider_name, :protocol, :issued_at, :detail) do
49
+ def initialize(issued: false, token: nil, digest: nil, provider_name: nil, protocol: nil,
50
+ issued_at: nil, detail: nil)
51
+ super
52
+ end
53
+
54
+ def to_h
55
+ {
56
+ "issued" => issued,
57
+ "token" => token,
58
+ "digest" => digest,
59
+ "provider_name" => provider_name,
60
+ "protocol" => protocol,
61
+ # The provider's own time, described as the provider's own time.
62
+ "provider_reported_time" => issued_at && Receipt.format_time(issued_at),
63
+ "detail" => detail
64
+ }.compact
65
+ end
66
+ end
67
+
68
+ # The result of checking a token. `status` carries the provider's own
69
+ # validation vocabulary unchanged, because "valid according to this
70
+ # authority today" is a narrower and more useful statement than "valid".
71
+ Verification = Data.define(:checked, :verified, :status, :provider_name, :protocol, :detail) do
72
+ def initialize(checked: false, verified: false, status: nil, provider_name: nil,
73
+ protocol: nil, detail: nil)
74
+ super
75
+ end
76
+
77
+ def to_h
78
+ {
79
+ "checked" => checked,
80
+ "verified" => verified,
81
+ "provider_reported_status" => status,
82
+ "provider_name" => provider_name,
83
+ "protocol" => protocol,
84
+ "detail" => detail
85
+ }.compact
86
+ end
87
+ end
88
+
89
+ # Asks the provider to timestamp one digest. Never the raw receipt, never
90
+ # the personal data inside it: a digest is what a timestamp authority needs
91
+ # and the only thing it should ever be given.
92
+ #
93
+ # Called outside the capture transaction. A provider cannot join a
94
+ # database transaction, and a capture must never fail because a third
95
+ # party was slow.
96
+ def timestamp(digest)
97
+ Token.new(
98
+ issued: false,
99
+ digest: digest,
100
+ provider_name: provider_name,
101
+ detail: "This timestamp adapter issues no token, so the only " \
102
+ "recorded time remains the application server's own."
103
+ )
104
+ end
105
+
106
+ # Checks a stored token against the digest it was issued over, and reports
107
+ # what the provider said. A token nobody re-checks is a stored blob.
108
+ def verify(_token, _digest)
109
+ Verification.new(
110
+ checked: false,
111
+ verified: false,
112
+ provider_name: provider_name,
113
+ detail: "This timestamp adapter issues no token, so there is nothing to check in this " \
114
+ "token."
115
+ )
116
+ end
117
+
118
+ # What this adapter supplies, in the provider's own terms. Read by the
119
+ # receipt's integrity fragment and by `clickwrap:doctor`, which report the
120
+ # tier honestly rather than inferring a stronger one from the mere
121
+ # presence of an adapter.
122
+ def capabilities
123
+ {
124
+ "name" => provider_name,
125
+ "available" => available?,
126
+ "protocol" => nil,
127
+ "supplies" => "Nothing. This is the default placeholder that reports the absence of a " \
128
+ "timestamp provider for explicit adapter-contract tests. Configuration " \
129
+ "normally remains nil.",
130
+ "note" => "A configured provider supplies exactly the assurance and validation status " \
131
+ "that provider supplies. Clickwrap preserves it and never restates it more " \
132
+ "strongly."
133
+ }
134
+ end
135
+
136
+ def available? = false
137
+
138
+ def provider_name = "no_timestamp_provider"
139
+
140
+ def to_s = "#{self.class.name} (#{provider_name})"
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ module IpGeolocation
5
+ # One provider's estimate about ONE IP address, at one moment.
6
+ #
7
+ # Read that sentence literally, because the whole design follows from it.
8
+ # This object never describes where a person was. It describes what a
9
+ # provider's database or edge network said about a network address, with
10
+ # enough provenance attached that someone reading it in four years can tell
11
+ # how much weight it deserves. That is why provider name, source, estimated
12
+ # state, resolution time, and any database or accuracy metadata are members
13
+ # of the same value object as the country code: a country without the
14
+ # provider that guessed it, or coordinates without an accuracy radius, read
15
+ # as far more certain than they are.
16
+ #
17
+ # Every member defaults to nil so a resolver fills in only what it actually
18
+ # has. A resolver must NEVER substitute a placeholder — "Unknown", "XX",
19
+ # "N/A" — for a field it could not determine. A placeholder is
20
+ # indistinguishable from a real provider answer once it is written down, and
21
+ # Clickwrap keeps "we did not collect this", "the provider had no value",
22
+ # "the lookup failed", and "the provider answered" as four different states.
23
+ # nil is the honest answer; `unavailable_reason` is how a resolver explains
24
+ # a failure.
25
+ #
26
+ # `to_h` exists because this is a Data object. It is not a persistence
27
+ # format: the extractor copies out exactly the fields the server-owned
28
+ # policy authorized, one at a time, and a resolver that gains a new field
29
+ # upstream never widens what Clickwrap stores by accident.
30
+ Location = Data.define(
31
+ :country_code,
32
+ :country_name,
33
+ :region_name,
34
+ :region_code,
35
+ :city_name,
36
+ :postal_code,
37
+ :latitude,
38
+ :longitude,
39
+ :timezone,
40
+ :continent_code,
41
+ :metro_code,
42
+ :provider_name,
43
+ :provider_source,
44
+ :database_version,
45
+ :database_sha256,
46
+ :accuracy_radius_in_kilometers,
47
+ :accuracy_radius_confidence_percentage,
48
+ :estimated,
49
+ :source_was_verified_by_host,
50
+ :resolved_at,
51
+ :unavailable_reason
52
+ ) do
53
+ # `estimated` defaults to true and `source_was_verified_by_host` to false
54
+ # because those are the answers that overclaim least. A resolver has to
55
+ # say something deliberate to move either one, and moving
56
+ # `source_was_verified_by_host` requires a host decision about its own
57
+ # network path — never the mere presence of a provider's headers.
58
+ def initialize(country_code: nil, country_name: nil, region_name: nil, region_code: nil,
59
+ city_name: nil, postal_code: nil, latitude: nil, longitude: nil,
60
+ timezone: nil, continent_code: nil, metro_code: nil,
61
+ provider_name: nil, provider_source: nil, database_version: nil,
62
+ database_sha256: nil, accuracy_radius_in_kilometers: nil,
63
+ accuracy_radius_confidence_percentage: nil, estimated: true,
64
+ source_was_verified_by_host: false, resolved_at: nil,
65
+ unavailable_reason: nil)
66
+ super
67
+ end
68
+
69
+ # The answer a resolver returns when it has nothing to report. It still
70
+ # names the provider that was asked, because "MaxMind had no row for this
71
+ # address" and "no resolver was configured at all" are different facts and
72
+ # a receipt has to be able to tell them apart.
73
+ def self.unavailable(reason:, provider_name: nil, **provenance)
74
+ new(unavailable_reason: reason.to_s, provider_name:, **provenance)
75
+ end
76
+
77
+ def unavailable? = !unavailable_reason.to_s.strip.empty?
78
+
79
+ # An IP-geolocation result is an estimate about an address. This reader
80
+ # reports what the resolver said about its own result rather than
81
+ # hard-coding the answer, but a resolver claiming otherwise still does not
82
+ # turn an observation about a network address into a statement about where
83
+ # anyone was, and the receipt goes on labeling the value provider-reported
84
+ # either way.
85
+ def estimated? = estimated != false
86
+
87
+ # True only when the HOST told Clickwrap that this result arrived over a
88
+ # path it has verified. No adapter may set it from the presence of a
89
+ # provider's own headers: headers are attacker-supplied until the
90
+ # deployment proves otherwise.
91
+ def source_was_verified_by_host? = source_was_verified_by_host == true
92
+
93
+ # Latitude and longitude are one coupled answer. Half a coordinate is not
94
+ # a result, so nothing downstream is allowed to store one without the
95
+ # other.
96
+ def coordinates? = !latitude.nil? && !longitude.nil?
97
+
98
+ def accuracy_radius? = !accuracy_radius_in_kilometers.nil?
99
+
100
+ # True when the provider actually reported something about the address, as
101
+ # opposed to handing back a row of blanks. An adapter uses this to turn an
102
+ # empty answer into an explicit unavailable result with a reason, which is
103
+ # the whole point of refusing to write "Unknown" into a name column: an
104
+ # empty result and a real one must never look the same.
105
+ def any_data_field?
106
+ [country_code, country_name, region_name, region_code, city_name, postal_code,
107
+ latitude, longitude, timezone, continent_code, metro_code,
108
+ accuracy_radius_in_kilometers].any? { |value| !value.nil? }
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ module IpGeolocation
5
+ # The resolver Clickwrap uses when the host configured none.
6
+ #
7
+ # It exists so that "no IP geolocation provider is installed" is an ordinary
8
+ # answer with a reason on it, rather than a branch that has to be handled
9
+ # separately everywhere. Every capture path runs the same code; a host with
10
+ # no geolocation gem gets `ip_geolocation_unavailable_reason` set to
11
+ # `no_resolver_configured`, which is a true statement about the application
12
+ # and reads correctly on a receipt years later.
13
+ #
14
+ # That also means the entire request-evidence feature — policies, field
15
+ # allowlists, retention, receipts, disposal — is testable and reviewable
16
+ # without a provider account, a database download, or a network call.
17
+ #
18
+ # A policy that authorizes IP-geolocation fields while no resolver is
19
+ # configured is caught earlier, by `Configuration#validate!`, with a
20
+ # sentence saying so. This resolver is the safety net under that check, not
21
+ # a way to pretend the configuration is fine.
22
+ class NullResolver < Resolver
23
+ REASON = "no_resolver_configured"
24
+
25
+ def resolve(_ip_address, http_request: nil)
26
+ Location.unavailable(reason: REASON)
27
+ end
28
+
29
+ # Nothing. Not "everything but it always fails" — a host reading
30
+ # `capabilities` is asking what this resolver could ever supply, and the
31
+ # honest answer is no field at all.
32
+ def capabilities = [].freeze
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # IP geolocation adapters.
5
+ #
6
+ # Clickwrap has no geolocation dependency and never will. What it has is this
7
+ # narrow contract, one no-op default that lets the whole feature be exercised
8
+ # without any provider, a deterministic fixture resolver for tests, and one
9
+ # optional official adapter for `trackdown`. A host that wants a different
10
+ # provider writes forty lines against the contract below and configures it.
11
+ module IpGeolocation
12
+ # The adapter contract every IP geolocation resolver implements.
13
+ #
14
+ # class MyResolver < Clickwrap::IpGeolocation::Resolver
15
+ # def resolve(ip_address, http_request: nil)
16
+ # row = MyProvider.lookup(ip_address, http_request:) or
17
+ # return Location.unavailable(reason: "provider_had_no_row", provider_name: "my_provider")
18
+ #
19
+ # Location.new(
20
+ # country_code: row.country_iso,
21
+ # provider_name: "my_provider",
22
+ # provider_source: "my_provider_city_database",
23
+ # database_version: row.database_build,
24
+ # estimated: true,
25
+ # resolved_at: Clickwrap.now
26
+ # )
27
+ # end
28
+ #
29
+ # def capabilities = %i[country]
30
+ # end
31
+ #
32
+ # Subclassing is optional — `Clickwrap.config.ip_geolocation_resolver`
33
+ # accepts anything that responds to `#resolve` — but inheriting documents
34
+ # the intent and gives you the contract's own error messages when a method
35
+ # is missing.
36
+ #
37
+ # Three obligations that are easy to miss, and that the rest of the gem
38
+ # depends on:
39
+ #
40
+ # 1. Return nil ONLY for a field the provider genuinely did not supply.
41
+ # Never a placeholder string. "Unknown" written into a country name is
42
+ # indistinguishable from a country a provider actually reported, and
43
+ # collapsing those two states is exactly what a receipt must not do.
44
+ #
45
+ # 2. Say who answered. Set `provider_name`, and `provider_source` when the
46
+ # provider has more than one source (a local city database and an edge
47
+ # network are not the same evidence). Attach `database_version`,
48
+ # `database_sha256`, `accuracy_radius_in_kilometers`, and
49
+ # `accuracy_radius_confidence_percentage` whenever the provider gives
50
+ # them: the extractor stores that provenance with any coordinate it
51
+ # keeps, because coordinates without uncertainty overclaim.
52
+ #
53
+ # 3. Never infer trust from a provider's own headers. Leave
54
+ # `source_was_verified_by_host` false unless the HOST has explicitly
55
+ # told the adapter that its deployment blocks direct origin access or
56
+ # sanitizes those headers. A `CF-*` header is a client-supplied string
57
+ # until the deployment proves otherwise.
58
+ #
59
+ # A resolver is called SYNCHRONOUSLY, before the evidence and domain
60
+ # transaction opens, so it must be fast and must not raise for ordinary
61
+ # conditions. A private, loopback, or reserved address is an ordinary
62
+ # condition: return `Location.unavailable(...)`, do not raise. Clickwrap
63
+ # rescues a raising resolver and records the failure rather than losing it,
64
+ # but a reason string you chose is better evidence than an exception class
65
+ # Clickwrap had to guess a name from.
66
+ class Resolver
67
+ # Estimate a location for one observed IP address.
68
+ #
69
+ # Returns a Location — populated, or `Location.unavailable(reason:,
70
+ # provider_name:)`. Returning nil is permitted for an adapter that has
71
+ # nothing at all to say; Clickwrap records it as `resolver_returned_no_result`,
72
+ # which is a less useful receipt than a reason you wrote yourself.
73
+ def resolve(ip_address, http_request: nil)
74
+ raise NotImplementedError,
75
+ "#{self.class} must implement #resolve(ip_address, http_request: nil) and return a " \
76
+ "Clickwrap::IpGeolocation::Location (or nil). See the contract in " \
77
+ "lib/clickwrap/ip_geolocation/resolver.rb."
78
+ end
79
+
80
+ # Which of `Clickwrap::Vocabulary::IP_GEOLOCATION_DATA_FIELDS` this
81
+ # resolver can supply at all, as symbols.
82
+ #
83
+ # This is a statement about the adapter and its provider, not about any
84
+ # one lookup: a Cloudflare header set structurally supplies no accuracy
85
+ # radius, which is a different fact from a MaxMind database that supplies
86
+ # one but had no value for this address. Clickwrap uses the difference to
87
+ # explain an empty result honestly, and `clickwrap:doctor` uses it to tell
88
+ # a host that a policy authorizes a field its resolver can never fill.
89
+ def capabilities
90
+ raise NotImplementedError,
91
+ "#{self.class} must implement #capabilities and return the subset of " \
92
+ "Clickwrap::Vocabulary::IP_GEOLOCATION_DATA_FIELDS it can supply, as symbols. " \
93
+ "Return [] if it can supply none."
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ module IpGeolocation
5
+ # A resolver that answers from a fixed table of addresses.
6
+ #
7
+ # Clickwrap.config.ip_geolocation_resolver =
8
+ # Clickwrap::IpGeolocation::StaticResolver.new(
9
+ # "203.0.113.10" => {
10
+ # country_code: "ES",
11
+ # country_name: "Spain",
12
+ # city_name: "Madrid",
13
+ # latitude: 40.4168,
14
+ # longitude: -3.7038,
15
+ # accuracy_radius_in_kilometers: 20,
16
+ # provider_source: "test_city_database",
17
+ # database_version: "2026-08-01"
18
+ # }
19
+ # )
20
+ #
21
+ # This is what the gem's own tests run against, and it is the right resolver
22
+ # for a host's test and development environments too. A real provider makes
23
+ # request-evidence tests non-deterministic in the one place where
24
+ # determinism matters most: an assertion about exactly which fields a policy
25
+ # stored has to fail because the ALLOWLIST changed, never because a database
26
+ # was rebuilt or an address was reassigned between two runs.
27
+ #
28
+ # Values may be given as attribute hashes or as Location objects. An address
29
+ # that is not in the table resolves to an explicit unavailable result — not
30
+ # to a blank one, and not to a plausible-looking default, because a fixture
31
+ # that quietly invents a country is a test that proves nothing.
32
+ class StaticResolver < Resolver
33
+ PROVIDER_NAME = "static_ip_geolocation_fixture"
34
+ NO_FIXTURE_REASON = "no_fixture_for_this_ip_address"
35
+
36
+ # The fixture table can be written straight into the call, which is how it
37
+ # reads best in a test:
38
+ #
39
+ # StaticResolver.new("203.0.113.10" => { country_code: "ES" })
40
+ #
41
+ # Ruby hands a trailing bare hash over as keyword arguments, so those
42
+ # addresses arrive in `fixtures` and are merged with the positional form
43
+ # below. Wrap the table in braces when you also pass an option:
44
+ #
45
+ # StaticResolver.new({ "203.0.113.10" => { ... } }, capabilities: %i[country])
46
+ #
47
+ # `capabilities:` is worth setting deliberately. It models what a provider
48
+ # can supply AT ALL, which is a different fact from what it happened to
49
+ # return for one address: a Cloudflare visitor-header set supplies no
50
+ # accuracy radius ever, while a MaxMind city database supplies one but may
51
+ # have no value for a particular address. Clickwrap explains an empty
52
+ # result differently in those two cases, so a fixture that wants to
53
+ # exercise the Cloudflare shape should say
54
+ # `capabilities: %i[country region city latitude_and_longitude timezone]`.
55
+ def initialize(locations_by_ip_address = {}, capabilities: nil, provider_name: PROVIDER_NAME,
56
+ **fixtures)
57
+ super()
58
+ # Written as an explicit keyword, the table lands in `fixtures` like any
59
+ # other trailing hash. Take it back out so both spellings mean the same
60
+ # thing, rather than one of them quietly building a table with a single
61
+ # nonsense entry named after the parameter.
62
+ declared = fixtures.delete(:locations_by_ip_address).to_h
63
+
64
+ @provider_name = provider_name.to_s
65
+ @locations_by_ip_address = build_table(locations_by_ip_address.to_h.merge(declared).merge(fixtures)).freeze
66
+ @capabilities = normalize_capabilities(capabilities).freeze
67
+
68
+ freeze
69
+ end
70
+
71
+ attr_reader :capabilities
72
+
73
+ def resolve(ip_address, http_request: nil)
74
+ @locations_by_ip_address.fetch(ip_address.to_s.strip) do
75
+ Location.unavailable(reason: NO_FIXTURE_REASON, provider_name: @provider_name)
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def build_table(declaration)
82
+ declaration.to_h { |ip_address, location| [ip_address.to_s.strip, build_location(location)] }
83
+ end
84
+
85
+ # A fixture written as a hash still comes back as a fully formed result:
86
+ # provider name and resolution time are filled in when the hash omits
87
+ # them, because a stored IP-geolocation value without its provenance is
88
+ # exactly what the extractor refuses to write, and a fixture that could
89
+ # not survive that rule would be testing the wrong thing.
90
+ def build_location(location)
91
+ return location if location.is_a?(Location)
92
+
93
+ attributes = location.to_h.transform_keys(&:to_sym)
94
+ attributes[:provider_name] ||= @provider_name
95
+ attributes[:resolved_at] ||= Clickwrap.now
96
+
97
+ Location.new(**attributes)
98
+ end
99
+
100
+ def normalize_capabilities(declared)
101
+ return Vocabulary::IP_GEOLOCATION_DATA_FIELDS.map(&:to_sym) if declared.nil?
102
+
103
+ Array(declared).map { |field| field.to_s.to_sym }
104
+ end
105
+ end
106
+ end
107
+ end